diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 00000000..f904608b --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,53 @@ +name: Publish to AUR + +# Pushes the meatshell-bin package to the AUR after a GitHub Release is +# published. Requires three repository secrets (see packaging/aur/README.md): +# AUR_USERNAME, AUR_EMAIL, AUR_SSH_PRIVATE_KEY +# Without them the job is skipped, so this is harmless until you set AUR up. +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.3.6); blank = latest release" + required: false + +jobs: + aur: + runs-on: ubuntu-latest + # Only run once the AUR SSH key is configured. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.release != null }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + set pkgver + run: | + set -eu + VER="${{ github.event.inputs.version }}" + if [ -z "$VER" ] && [ -n "${{ github.event.release.tag_name }}" ]; then + VER="${{ github.event.release.tag_name }}" + fi + if [ -z "$VER" ]; then + VER=$(curl -fsSL https://api.github.com/repos/${{ github.repository }}/releases/latest \ + | grep -oP '"tag_name":\s*"\K[^"]+') + fi + VER="${VER#v}" # strip leading v + echo "Publishing meatshell-bin $VER" + sed -i "s/^pkgver=.*/pkgver=${VER}/" packaging/aur/PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/PKGBUILD + echo "VER=$VER" >> "$GITHUB_ENV" + + - name: Publish meatshell-bin to the AUR + # Skips cleanly until the AUR SSH key secret is configured. + if: ${{ secrets.AUR_SSH_PRIVATE_KEY != '' }} + uses: KSXGitHub/github-actions-deploy-aur@v2.7.2 + with: + pkgname: meatshell-bin + pkgbuild: packaging/aur/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update to ${{ env.VER }}" + updpkgsums: true + ssh_keyscan_types: rsa,ed25519 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e695f9d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,225 @@ +name: Release + +# Build native binaries for Windows / Linux / macOS. +# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives +# to a GitHub Release. +# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as +# downloadable workflow artifacts (no release created). + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write # needed to create / update the GitHub Release + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + name: windows-x86_64 + bin: meatshell.exe + - os: ubuntu-22.04 # build on the oldest supported runner for + target: x86_64-unknown-linux-gnu # broader glibc compatibility + name: linux-x86_64 + bin: meatshell + - os: ubuntu-22.04-arm # free native arm64 runner (public repos) + target: aarch64-unknown-linux-gnu # builds the Linux ARM64 .tar.gz (#82) + name: linux-aarch64 + bin: meatshell + - os: macos-14 # Apple Silicon + target: aarch64-apple-darwin + name: macos-aarch64 + bin: meatshell + - os: macos-14 # Apple Silicon runner — cross-compiles the + target: x86_64-apple-darwin # x86_64 Mac binary. (Dedicated Intel + name: macos-x86_64 # macos-13 runners queue for ages / time out.) + bin: meatshell + + steps: + - uses: actions/checkout@v4 + + # Slint (winit + femtovg) + rfd (GTK) + arboard need these system libs. + # Build-time only — no GPU/display required to compile. + - name: Install Linux GUI dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config cmake \ + 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 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build (release) + run: cargo build --release --target ${{ matrix.target }} + + - name: Build AppImage + if: matrix.name == 'linux-x86_64' + continue-on-error: true # don't block the .tar.gz if AppImage fails + env: + APPIMAGE_EXTRACT_AND_RUN: "1" # no FUSE needed in CI + run: | + set -eu + VERSION="${GITHUB_REF_NAME:-dev}" + wget -q -O linuxdeploy \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + wget -q -O linuxdeploy-plugin-appimage \ + https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage + chmod +x linuxdeploy linuxdeploy-plugin-appimage + + install -Dm755 target/${{ matrix.target }}/release/meatshell AppDir/usr/bin/meatshell + install -Dm644 assets/meatshell.desktop AppDir/usr/share/applications/meatshell.desktop + install -Dm644 assets/icon@512.png AppDir/usr/share/icons/hicolor/512x512/apps/meatshell.png + + # --output appimage already invokes the plugin; --plugin appimage is redundant and causes an error + ./linuxdeploy --appdir AppDir --output appimage + + mv meatshell*.AppImage "meatshell-${VERSION}-linux-x86_64.AppImage" + echo "APPIMAGE=meatshell-${VERSION}-linux-x86_64.AppImage" >> "$GITHUB_ENV" + + - name: Package + shell: bash + run: | + set -eu + VERSION="${GITHUB_REF_NAME:-dev}" + STAGE="meatshell-${VERSION}-${{ matrix.name }}" + + if [ "${{ runner.os }}" = "macOS" ]; then + # ── Build a proper .app bundle ──────────────────────────────────── + # Without a bundle macOS opens raw binaries in Terminal.app, which + # stays alive after the app exits and never shows a Dock icon. + APP="meatshell.app" + CONTENTS="$APP/Contents" + mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" + + cp "target/${{ matrix.target }}/release/meatshell" "$CONTENTS/MacOS/" + + # Generate .icns from the 512×512 PNG using sips + iconutil (both + # ship with every macOS, no extra tooling needed in CI). + mkdir -p meatshell.iconset + sips -z 16 16 "assets/icon@512.png" --out meatshell.iconset/icon_16x16.png >/dev/null + sips -z 32 32 "assets/icon@512.png" --out meatshell.iconset/icon_16x16@2x.png >/dev/null + sips -z 32 32 "assets/icon@512.png" --out meatshell.iconset/icon_32x32.png >/dev/null + sips -z 64 64 "assets/icon@512.png" --out meatshell.iconset/icon_32x32@2x.png >/dev/null + sips -z 128 128 "assets/icon@512.png" --out meatshell.iconset/icon_128x128.png >/dev/null + sips -z 256 256 "assets/icon@512.png" --out meatshell.iconset/icon_128x128@2x.png >/dev/null + sips -z 256 256 "assets/icon@512.png" --out meatshell.iconset/icon_256x256.png >/dev/null + sips -z 512 512 "assets/icon@512.png" --out meatshell.iconset/icon_256x256@2x.png >/dev/null + sips -z 512 512 "assets/icon@512.png" --out meatshell.iconset/icon_512x512.png >/dev/null + iconutil -c icns meatshell.iconset -o "$CONTENTS/Resources/meatshell.icns" + + # Substitute __VERSION__ placeholder (strip leading 'v' from tag) + VERSION_NUM="${VERSION#v}" + sed "s/__VERSION__/${VERSION_NUM}/g" assets/Info.plist > "$CONTENTS/Info.plist" + + # Ad-hoc code-sign the assembled bundle. + # The release profile strips the binary (Cargo.toml: strip = "symbols"), + # which invalidates the linker's automatic ad-hoc signature — on Apple + # Silicon that makes the OS refuse to launch it ("app is damaged"). + # Re-signing the whole bundle here restores a valid (ad-hoc) signature, + # turning the fatal "damaged" error into the normal "unidentified + # developer" prompt that users can bypass via Open Anyway / xattr. + codesign --force --deep --sign - "$APP" + codesign --verify --verbose "$APP" + + # Package with ditto (Apple's tool) — it preserves the bundle's + # signature and metadata. Plain `zip` mangles them and re-breaks the + # signature, which would reintroduce the "damaged" error. + ditto -c -k --keepParent "$APP" "${STAGE}.zip" + echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV" + + elif [ "${{ runner.os }}" = "Windows" ]; then + mkdir "$STAGE" + cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/" + cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true + 7z a "${STAGE}.zip" "$STAGE" >/dev/null + echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV" + + else + # Linux + mkdir "$STAGE" + cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/" + cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true + # Ship the desktop entry + installer + icon so the dock shows the icon. + cp assets/meatshell.desktop assets/install-linux.sh "assets/icon@512.png" "$STAGE/" 2>/dev/null || true + tar -czf "${STAGE}.tar.gz" "$STAGE" + echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV" + fi + + # MSI installer with a "choose install location" wizard (WixUI_InstallDir). + # Best-effort: if the WiX/cargo-wix tooling hiccups it must not block the + # .zip release, hence continue-on-error. + - name: Build MSI installer (Windows) + if: matrix.name == 'windows-x86_64' + continue-on-error: true + shell: pwsh + run: | + $VERSION = "$env:GITHUB_REF_NAME"; if (-not $VERSION) { $VERSION = "dev" } + # WiX 3 toolset (candle/light) that cargo-wix drives. + choco install wixtoolset -y --no-progress + $candle = Get-ChildItem "C:\Program Files (x86)\WiX Toolset*\bin\candle.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($candle) { $env:PATH = "$($candle.DirectoryName);$env:PATH" } + cargo install cargo-wix --locked + cargo wix --no-build --nocapture --target ${{ matrix.target }} --output "meatshell-$VERSION-windows-x86_64.msi" + echo "MSI=meatshell-$VERSION-windows-x86_64.msi" >> $env:GITHUB_ENV + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: ${{ env.ASSET }} + + - name: Upload AppImage artifact + if: matrix.name == 'linux-x86_64' + uses: actions/upload-artifact@v4 + with: + name: linux-x86_64-appimage + path: ${{ env.APPIMAGE }} + + - name: Upload MSI artifact + if: matrix.name == 'windows-x86_64' && env.MSI != '' + uses: actions/upload-artifact@v4 + with: + name: windows-x86_64-msi + path: ${{ env.MSI }} + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.ASSET }} + fail_on_unmatched_files: true + + - name: Attach AppImage to GitHub Release + if: matrix.name == 'linux-x86_64' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.APPIMAGE }} + fail_on_unmatched_files: true + + - name: Attach MSI to GitHub Release + if: matrix.name == 'windows-x86_64' && env.MSI != '' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.MSI }} + fail_on_unmatched_files: false diff --git a/.gitignore b/.gitignore index 9008d583..519f5aa9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Cargo.lock .DS_Store /.vscode /.idea +/.claude diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e409d46c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1002 @@ +# Changelog / 更新日志 + +All notable changes are documented here. 本文件记录所有重要变更。 +中英对照(English first, 中文在后). + +## [Unreleased] + +## [0.4.14] - 2026-06-22 + +### Added / 新增 + +- **面板可拖动吸附停靠(资源面板 + SFTP)。** 资源面板和 SFTP 面板现在都能拖到四条边 + (上/下/左/右):拖动面板手柄时,四条边浮现高亮放置区,松手即吸附到那条边。两个面板都 + 可拖动调节大小;折叠后会缩成停靠边缘的一个小展开按钮(彻底隐藏面板)。**自适应:** 资源 + 面板横向(上/下)停靠时,内部小组件自动改为横排;SFTP 竖向(左/右、窄)停靠时隐藏目录树, + 并随宽度**渐进隐藏「大小→时间」列**(名称快被挤成「…」时才让位),横向(上/下、宽)停靠 + 则恒显示全部列。SFTP 工具栏左侧新增专用拖动手柄,密集控件下也能稳稳拖动。 + **Drag-to-dock panels (resource panel + SFTP).** Both the resource panel and the SFTP + panel can now be dragged to any edge (top / bottom / left / right): dragging the + panel's handle shows highlighted drop zones on all four edges, and releasing snaps it + there. Both panels are drag-resizable and collapse to a small expand button on their + docked edge (fully hiding the panel). **Responsive:** the resource panel lays its + widgets out in a row when docked horizontally; the SFTP panel hides its directory tree + when docked vertically (narrow) and progressively drops the **Size → Modified** columns + as it narrows (only once the Name would elide to “…”), while a horizontal (wide) dock + always shows every column. A dedicated drag grip was added to the SFTP toolbar so the + panel is grabbable even though its toolbar is full of controls. + +- **布局持久化。** 两个面板的停靠边与宽/高,以及父窗口大小,都会在退出时保存、下次启动 + 恢复——可以保留你喜欢的窗口尺寸和面板布局。 + **Layout persistence.** Each panel's docked edge and size, plus the window size, are + saved on exit and restored on the next launch — so your preferred window size and + panel arrangement stick. + +### Fixed / 修复 + +- **macOS 欢迎页布局错位。** 欢迎页的标题、副标题、快速连接卡片在 macOS 上被拉开(标题与 + 副标题间出现大空隙)。现在 Welcome 显式填满内容区、头部固定在顶部按自然高度排列,卡片填满 + 其余空间。 + **macOS welcome-page layout spread apart.** The title, tagline and quick-connect card + were spaced out on macOS (a large gap between the title and tagline). The Welcome view + now explicitly fills the content area and the header is pinned to the top at its + natural height, with the card filling the rest. + +## [0.4.13] - 2026-06-21 + +### Fixed / 修复 + +- **堡垒机(JumpServer 等)密码登录“认证失败” (#86)。** 这类堡垒机默认只放行 + `keyboard-interactive` 认证、关闭 `password` 方法,旧版只尝试 `password`,因此直接 + “认证失败”——Xshell/MobaXterm/WindTerm 能登正是因为会自动回退。现在密码认证失败后会 + 断开并重连一条全新连接,改用 `keyboard-interactive` 以密码应答服务器提示。注意:russh + 在一次失败的认证后无法在同一句柄上切换认证方法(会卡死),因此回退必须重连。已在真实的 + keyboard-interactive-only sshd 上验证登录成功。 + **Password login through bastions (JumpServer etc.) failed with “authentication + failed” (#86).** Such bastions disable the `password` SSH method and only accept + `keyboard-interactive`; the old code only tried `password`, so it failed outright — + other clients get in because they fall back automatically. Now, on password-auth + failure we disconnect and reconnect on a fresh handle, then authenticate via + `keyboard-interactive`, answering each prompt with the password. (russh hangs if a + second auth method is attempted on a handle whose first attempt already failed, so a + reconnect is required.) Verified against a real keyboard-interactive-only sshd. + +### Changed / 优化 + +- **设置·界面简约重做。** 右侧从竖排改为「分区 + 标签左·控件右」的紧凑布局(iOS 风 + 开关、`[− 值 +]` 步进器、固定字号不随界面缩放放大,解决“字体过大”观感);仍为内嵌 + 模态浮层,打开时遮罩吞鼠标 + 抢焦点吞键盘,禁止对主窗口的一切输入,卡片只能在窗口内拖动。 + **Redesigned Interface settings.** The right pane moves from stacked fields to a + compact “section + label-left · control-right” layout (iOS-style switches, + `[− value +]` steppers, fixed typography that ignores UI scale — fixing the + “fonts too big” feel). Still an embedded modal overlay that blocks all input while + open (veil swallows mouse, focus scope swallows keys); the card only drags within + the window. + +- **初始窗口放大到 1440×900。** 从 1200×760 提升到更舒适的默认尺寸,对齐同类客户端。 + **Larger default window, 1440×900.** Up from 1200×760, matching comparable clients. + +- **Quieter startup logs.** Silenced fontdb's harmless "malformed font" warning for + system fonts it can't parse but skips anyway (e.g. Windows' `mstmc.ttf`), and + demoted the routine UI-font-selection line to `debug` — only an actual font-load + failure still warns. `error.log` stays clean. + **更安静的启动日志。** 屏蔽 fontdb 对无法解析(但会自动跳过)的系统字体发出的 + 「malformed font」无害告警(如 Windows 的 `mstmc.ttf`),并把常规的界面字体选择日志 + 降为 `debug`——只有真正的字体加载失败才会告警。`error.log` 保持干净。 + +### Added / 新增 + +- **侧栏可拖动调宽。** 在资源面板与主区之间加了可拖动分隔条,宽度可在 160–520px 间 + 调节并持久化到配置(重启保留);折叠侧栏时分隔条自动隐藏,拖动期间禁用折叠动画以跟手。 + **Drag-resize the sidebar.** A draggable splitter sits between the resource panel and + the main area; the width is adjustable within 160–520px and persisted to config + (survives restart). The splitter hides when the sidebar is collapsed, and the + collapse animation is disabled while dragging for 1:1 tracking. + +- **进程监视独立窗口。** 进程监视从内嵌浮层提升为真正的独立 OS 窗口,可拖出主窗口、 + 拖到第二块屏幕;无边框自绘标题栏 + 右下角缩放手柄,与主窗口实时共享同一份进程数据。 + **Detachable process-monitor window.** The process monitor is now a real top-level OS + window that can be dragged outside the main window or onto a second monitor, with a + frameless custom titlebar and a bottom-right resize grip; it shares one live process + model with the main window. + +- **Group quick commands, collapsible (#55).** Quick commands now take an optional + group/folder name. Leaving it empty drops the command into the implicit + "default" group. In the command-bar popup each group shows a header that can be + clicked to collapse/expand it — same behaviour as the welcome page's quick-connect + session groups. The manage dialog gained a "Group (optional)" field and shows the + grouping. + **快捷命令支持分组、可收起 (#55)。** 快捷命令新增可选的分组名,留空则归入隐式的 + 「default」分组。命令栏弹窗里每个分组带标题,点击即可收起/展开——和欢迎页快速连接的 + 会话分组体验一致。管理对话框新增「分组(可选)」输入框并按分组展示。 + +- **Full quick-command management, mirroring the session panel (#55).** Right-click + a command — in the command-bar popup or the manage dialog — for Edit / Duplicate / + Delete / Move to group, and right-click a group header for Rename / Delete (empty) / + New group; the manage dialog also has a "+ New group" button. Same right-click + model as the welcome page's quick-connect sessions. Groups start **collapsed** by + default, and empty groups persist so you can pre-create folders. + **快捷命令完整管理,对齐会话面板 (#55)。** 在命令栏弹窗或管理对话框里右键命令(编辑、 + 复制、删除、移动到分组),右键分组标题(重命名、删除空分组、新建分组),管理对话框另有 + 「+ 新建分组」按钮——与欢迎页快速连接会话的右键体验一致。分组**默认收起**,空分组会被 + 保留以便预先建好文件夹。 + +## [0.4.12] - 2026-06-20 + +### Fixed / 修复 + +- **macOS 26 blank text — switch the default CJK UI font to one femtovg can render + (#129, #108).** Root cause finally pinned: on some macOS 26 machines femtovg + cannot rasterize the *modern* system CJK fonts (PingFang SC, Hiragino) — fontdb + finds them but every glyph comes out blank — while the older Heiti/STHeiti/Songti + faces render perfectly (verified per-font on an M2 / macOS 26). It was never the + renderer (0.4.11's femtovg revert alone didn't help) nor font *loading* (fontdb + loaded 900+ faces). The UI now prefers the reliably-rendering "Heiti SC" (a clean + sans-serif that ships on every macOS), with STHeiti/Songti as further fallbacks + and the embedded "Meatshell Mono" as a last resort so the window is never blank. + A `MEATSHELL_UI_FONT=""` env var can force any family without a rebuild. + **修复 macOS 26 文字全白——默认中文界面字体改用 femtovg 能渲染的字体 (#129, #108)。** + 根因最终定位:部分 macOS 26 机器上 femtovg 无法栅格化*新版*系统中文字体(PingFang + SC、Hiragino)——fontdb 能找到它们,但每个字形都画成空白;而老字体 + Heiti/STHeiti/Songti 渲染完全正常(已在 M2 / macOS 26 上逐字体实测)。既不是渲染器 + (0.4.11 单独退回 femtovg 没用),也不是字体*加载*(fontdb 加载了 900+ 个 face)。 + 界面现在优先用稳定渲染的「Heiti SC」(所有 macOS 自带的干净黑体),STHeiti/Songti + 作为后备,内置「Meatshell Mono」兜底,确保窗口永不全白。可用环境变量 + `MEATSHELL_UI_FONT="<字体名>"` 免重编强制指定任意字体。 + +## [0.4.11] - 2026-06-20 + +### Fixed / 修复 + +- **macOS text-invisible regression — renderer no longer force-switched (#129, #108).** + 0.4.10 force-set the Skia renderer on macOS to work around femtovg failing to + render text on macOS 26 (#108). That shipped unverified and broke a *different* + set of Macs (Apple Silicon, macOS 26.5): Skia could not resolve the "PingFang SC" + UI font, so all text vanished there instead (icons survived because they use an + embedded font). The default now stays femtovg (known-good for the majority); + Skia is still compiled in on macOS and can be opted into at launch with + `SLINT_BACKEND=winit-skia` for machines where femtovg fails. + **修复 macOS 文本全部消失的回退问题——不再强制切换渲染器 (#129, #108)。** 0.4.10 为 + 绕过 macOS 26 上 femtovg 取字失败(#108),在 macOS 强制改用 Skia 渲染器;该改动 + 未经真机验证就发布,反而弄坏了另一批 Mac(Apple Silicon / macOS 26.5):Skia 无法 + 解析「PingFang SC」界面字体,导致这些机器上文字全部消失(图标因使用内嵌字体而正常)。 + 现默认改回 femtovg(对绝大多数机器正常);macOS 仍编译 Skia,femtovg 失效的机器可在 + 启动时用 `SLINT_BACKEND=winit-skia` 手动启用。 + +### Added / 新增 + +- **Cancel an in-progress upload, with remote cleanup (#100).** Uploads can now be + cancelled like downloads; cancelling removes the half-written file on the remote + so no partial junk is left behind. + **上传也支持取消并清理远端半成品 (#100)。** 上传可像下载一样取消;取消会删除远端已 + 写入的半成品文件,服务端不留垃圾。 + +- **Sponsor / donation link in the README.** Added a WeChat sponsor QR for anyone + who'd like to support development. + **README 增加赞助/捐赠入口。** 加入微信赞助二维码,欢迎支持项目开发。 + +### Changed / 优化 + +- **Silenced ICU4X segmentation-data log noise.** Suppressed the spurious ICU4X + data-error warnings so they no longer clutter the log / error.log. + **屏蔽 ICU4X 段落数据噪音日志。** 抑制无意义的 ICU4X data-error 警告,不再污染日志 + 与 error.log。 + +## [0.4.10] - 2026-06-19 + +### Added / 新增 + +- **SFTP multi-select with one-archive download (#100).** Check multiple files in + the SFTP panel and download them together: the selection is packed into a single + `tar` on the remote (named after the first item, e.g. `11等文件.tar`), pulled in + one transfer, then the temp is removed. Any download action (right-click, row, + toolbar) packs the whole checked set when 2+ are checked; a single selection + downloads as a plain file. Batch delete is also supported, and an empty folder + is reported instead of creating an empty local directory. + **SFTP 文件多选 + 打包下载 (#100)。** 在 SFTP 面板勾选多个文件即可一起下载:选中 + 项在远端打包成单个 `tar`(以第一个文件命名,如 `11等文件.tar`),一次性下载后删除 + 临时包。勾选 ≥2 项时,任意下载动作(右键/行内/工具栏)都打包整组;单选则按普通 + 文件下载。同时支持批量删除;下载空文件夹会给出提示而非创建空目录。 + +- **Cancel an in-progress transfer (#100).** Each transfer row shows a cancel + button while active or preparing; cancelling removes the partial local file and, + for archive downloads, the remote temp archive — no junk left on either side. + **可取消进行中的传输 (#100)。** 传输记录每行在下载中/准备中时显示取消按钮;取消会 + 删除本地半成品文件,打包下载还会删除远端临时包,本地与服务端都不留垃圾。 + +- **Name port-forward rules (#100).** Port-forward rules can be given an optional + name so they're easy to tell apart in the list. + **端口转发规则可命名 (#100)。** 转发规则可设置可选名称,便于在列表中区分。 + +- **Global UI scale setting (#100 #117 #118).** A scale control in Interface + settings zooms the whole UI (fonts, spacing, radii) from 80% to 200%. + **界面整体缩放设置 (#100 #117 #118)。** 界面设置新增缩放控件,可将整个界面(字体、 + 间距、圆角)从 80% 到 200% 缩放。 + +### Changed / 优化 + +- **Much faster downloads (#100).** Downloads now use a dedicated, pipelined SFTP + channel that keeps many READ requests in flight at once (like uploads already + did), hiding round-trip latency — large files and archive bundles download + noticeably faster. + **下载大幅提速 (#100)。** 下载改用专用、流水线化的 SFTP 通道,多个读请求并发在途 + (与上传一致),掩盖往返延迟 —— 大文件和打包包下载明显更快。 + +- **Switch directories during transfers.** SFTP transfers run on their own task, + so listing and changing directories stays responsive while files move. + **传输时仍可切换目录。** SFTP 传输在独立任务上运行,文件传输期间列目录、切换目录 + 依然流畅。 + +### Fixed / 修复 + +- **macOS 26 (Tahoe): all UI text invisible (#108).** The default femtovg renderer + failed CoreText font lookup on macOS 26, blanking every glyph including the + embedded mono font. macOS now uses the Skia renderer (Windows/Linux unchanged). + **macOS 26 (Tahoe) 界面文本全部消失 (#108)。** 默认 femtovg 渲染器在 macOS 26 上 + 取字失败,所有文字(含内嵌等宽字体)消失。macOS 现改用 Skia 渲染器(Windows/Linux + 不变)。 + +- **Welcome session list now scrolls (#116).** When there are more sessions than + fit, the welcome screen's session list scrolls instead of clipping. + **欢迎页会话列表可滚动 (#116)。** 会话过多时,欢迎页的会话列表可滚动,不再被裁切。 + +## [0.4.9] - 2026-06-19 + +### Added / 新增 + +- **Searchable command-history dropdown (#101).** The command-history list is now + filterable — type in the search box to narrow entries instantly, then click or + press Enter to run the match. + **命令历史下拉支持搜索 (#101)。** 历史列表新增搜索框,输入关键字即可实时过滤, + 点击或回车直接执行匹配项。 + +- **Readline keys in the command box + shortcuts reference (#103).** The command + box now honours common readline bindings (Ctrl+A/E/K/U/W, Alt+B/F/D/Backspace, + etc.) for fast inline editing; a keyboard-shortcuts reference panel is also + added so users can discover available bindings at a glance. + **命令输入框支持 Readline 快捷键 + 快捷键参考 (#103)。** 命令框现在支持常见 + readline 绑定(Ctrl+A/E/K/U/W、Alt+B/F/D/Backspace 等)进行快速行内编辑; + 另加快捷键参考面板,方便用户一览可用组合键。 + +- **Scroll arrows when tabs overflow (#122).** When open tabs exceed the tab bar + width, left/right arrow buttons appear so users can scroll through the hidden + tabs instead of losing access to them. + **标签溢出时显示滚动箭头 (#122)。** 当打开的标签超出标签栏宽度时,左右箭头 + 按钮出现,可滚动查看被遮挡的标签。 + +- **Slim scrollbar for the terminal output area (#103).** The terminal's vertical + scrollbar is now a thin, auto-hiding overlay that doesn't eat into the column + count, giving more screen real estate to the actual output. + **终端输出区窄滚动条 (#103)。** 终端纵向滚动条改为细窄的自动隐藏覆盖层, + 不再占用列数,把更多屏幕空间留给实际输出。 + +### Fixed / 修复 + +- **Preserve the MOTD/banner when hiding the injected setup line (#98).** The + previous approach stripped too aggressively and could swallow the server's + MOTD/banner that arrives before the shell prompt; the matcher now only discards + the single injected line, leaving the banner intact. + **隐藏注入设置行时保留 MOTD/横幅 (#98)。** 之前的做法剥离过度,会把 shell 提示符 + 之前到达的服务器 MOTD/横幅一并吞掉;现在匹配器仅丢弃注入的那一行,横幅原样保留。 + +- **Reserve space for toolbar icons + scroll overflowing tabs (#122).** The tab + bar now leaves a right margin so the last tab's close button isn't hidden + behind the toolbar icons; tabs that still overflow are scrollable. + **为工具栏图标预留空间 + 溢出标签可滚动 (#122)。** 标签栏右侧留出余量, + 最后一个标签的关闭按钮不再被工具栏图标遮挡;仍然溢出的标签可滚动查看。 + +## [0.4.8] - 2026-06-18 + +### Added / 新增 + +- **Immersive frameless title bar.** On Windows/Linux the app draws its own + themed title bar (app icon + name, minimize/maximize/close, draggable to move, + double-click to maximize, edge/corner resize) instead of the OS chrome — so the + top follows the light/dark theme instead of staying a mismatched native bar. + macOS keeps its native decorations. (#119) + **沉浸式无边框标题栏。** Windows/Linux 下自绘主题色标题栏(应用图标+名称、 + 最小化/最大化/关闭、拖动移动、双击最大化、边角缩放),不再使用系统标题栏,顶部 + 跟随明暗主题;macOS 保留原生标题栏。 + +### Fixed / 修复 + +- **htop/btop box-drawing and braille no longer render as tofu** on machines + without Cascadia Mono installed (e.g. Win11 Home). The embedded font is now a + uniquely-named family ("Meatshell Mono") so the OS can't substitute a + glyph-poor fallback for it. (#114) + **htop/btop 的线框和盲文字符不再显示为方块**(在未安装 Cascadia Mono 的机器上, + 如 Win11 家庭版)。内嵌字体改用独一无二的族名「Meatshell Mono」,系统无法再用 + 缺字形的字体顶替它。 +- **The injected setup line no longer leaks to the terminal on connect**, even + when it wraps across the terminal width. Output is buffered until the hook's + OSC sequence arrives, then everything up to it is discarded. (#98) + **连接后不再出现注入的设置命令**,即使它按终端宽度换行也能正确隐藏。 +- **Smooth scrollback across the live/scrolled boundary.** After shrinking then + restoring the terminal (e.g. dragging the SFTP panel over it and back), + scrolling back through history no longer jumps near the bottom. (#119) + **回滚历史在实时/滚动边界处平滑。** 把 SFTP 面板拉上来盖住终端再放下后,往回翻 + 历史时接近底部不再跳。 +- **Fast drag-selection in the terminal works again.** A quick drag is no longer + stolen by the Flickable, so selecting text by dragging fast still selects. (#119) + **终端里快速拖动选择恢复正常。** 快速拖动不再被滚动容器抢走,快速拖选也能选中。 +- **The Interface dialog's close button can't be dragged off-screen.** Its drag + is clamped inside the window, so the modal dialog can no longer become + unclosable. (#119) + **「界面」设置对话框的关闭按钮不会被拖出屏幕。** 拖动被限制在窗口内,模态对话框 + 不会再变得无法关闭。 + +## [0.4.7] - 2026-06-16 + +### Added / 新增 + +- **Host-key verification with a first-connect confirmation dialog.** On first + contact a dialog shows the host, key type and SHA256 fingerprint; the key is + remembered (a known_hosts file beside sessions.json) only after you trust it. + A later key that differs is flagged as a possible MITM and needs re-confirming. + Replaces the previous "accept any key" behaviour. (#109) + **主机密钥校验 + 首次连接确认弹窗。** 首次连接会弹窗显示主机、密钥类型和 SHA256 + 指纹,确认信任后才记住(known_hosts 文件,与 sessions.json 同目录);之后密钥若 + 变化会作为疑似中间人攻击提示并要求重新确认。取代了原先「接受任意密钥」的行为。 +- **Quick-connect login, Xshell-style.** New SSH/Telnet sessions now require a + host. The username no longer defaults to `root`; if a session is missing its + username and/or (password-auth) password, you're prompted for them on connect, + with an optional "remember". Auto-naming uses `user@host`, or just the host + when no username is given. (#110) + **类 Xshell 的快速连接登录。** 新建 SSH/Telnet 会话需填主机;用户名不再默认 + `root`;会话缺用户名 和/或(密码认证)密码时,连接时弹窗补充,可勾选「记住」。 + 自动命名用 `user@host`,无用户名时仅用主机名。 +- **Commands typed in the terminal now join the command history.** Captured via + the shell integration hook (bash/zsh), so the command box and ↑/↓ recall + include what you ran in the terminal — passwords typed at prompts are never + captured. (#113) + **终端里直接敲的命令现在也进命令历史。** 通过 shell 集成钩子(bash/zsh)捕获, + 命令栏和 ↑/↓ 回溯都会包含;在提示符处输入的密码不会被捕获。 + +### Changed / 变更 + +- **Command history is de-duplicated, most-recent last.** Re-running a command + moves it to the end instead of leaving duplicates; existing history is cleaned + up on load. (#113) + **命令历史全局去重,最近使用排在最后。** 重复执行只会把命令移到末尾而不再留重复 + 项;已有历史在加载时清理一次。 + +### Fixed / 修复 + +- **The injected prompt-setup line no longer leaks to the terminal on connect.** + When the echoed setup line was split across packets the matcher missed it, + showing `test -z "$FISH_VERSION" && eval '…'`; output is now buffered until the + line is complete so it's reliably stripped however it's chunked. (#98) + **连接后不再出现注入的设置命令。** 该回显行被分包拆开时旧逻辑匹配不到,会显示 + `test -z "$FISH_VERSION" && eval '…'`;现在缓冲到该行完整再剥离,无论如何分块都能隐藏。 +- **ZMODEM `sz a b c` now receives every file**, not just the first — ZEOF ends a + file, not the session. (#109) + **ZMODEM `sz a b c` 现在会接收每个文件**,而不只是第一个(ZEOF 表示单个文件结束, + 而非整个会话结束)。 +- **A denied directory listing is handled gracefully.** Instead of spinning + forever on a permission error, the panel stops loading and shows a clear + "permission denied" message while keeping the current view. (#112) + **目录无权限时优雅处理。** 不再卡在加载转圈,面板会停止加载并明确提示「权限不足」, + 同时保留当前视图。 +- **IPv6 bind addresses are bracketed** for `-L`/`-D` port forwards + (`[::1]:8080`). (#109/#105) + **端口转发的 IPv6 绑定地址加方括号**(`[::1]:8080`),`-L`/`-D` 现在可用。 + +## [0.4.6] - 2026-06-14 + +### Fixed / 修复 + +- **Session-sync upload now works for drag-and-drop too.** Dropping a file onto + the SFTP panel used a separate code path that skipped the session-sync mirror; + now both the upload button and drag-and-drop mirror the file to every other + online session, each into its own current SFTP directory. (Removed the + temporary upload diagnostics added in 0.4.5.) + **会话同步上传现在对「拖拽」也生效。** 拖文件到 SFTP 面板走的是另一条代码路径, + 之前漏掉了会话同步;现在上传按钮和拖拽都会把文件同步到其他在线会话(各进各自 + 当前目录)。(移除了 0.4.5 加的临时上传诊断日志。) + +## [0.4.5] - 2026-06-14 + +### Fixed / 修复 + +- **Session-sync upload now targets each session's own current directory.** + Uploading from one session no longer reuses that session's path for the others + (which failed when paths differed, e.g. /home/jeff vs /home/root); each session + receives the file in its own current SFTP directory. (Includes temporary + diagnostics to nail down a remaining report.) + **会话同步上传改为各会话用自己的当前目录。** 从某会话上传不再把它的路径套用到 + 其他会话(路径不同就会失败,如 /home/jeff 与 /home/root);每个会话都收到文件到 + 它自己当前的 SFTP 目录。(含临时诊断日志以定位残留问题。) + +## [0.4.4] - 2026-06-14 + +### Added / 新增 + +- **Session sync / broadcast input.** A new ⟳ toggle in the top-right bar + mirrors keystrokes typed in any terminal to every online session + (Xshell-style). Off by default, runtime-only. Settings → Session sync also + adds "Sync file uploads during session sync": an upload is mirrored to the + same path on each session (or that session's current SFTP dir if the path + doesn't exist there). + **会话同步 / 广播输入。** 右上角新增 ⟳ 开关,把任意终端里敲的键同步到所有在线 + 会话(Xshell 风格);默认关闭、仅本次运行有效。设置 → 会话同步 还有「会话同步时 + 文件上传同步」:上传会同步到各会话的相同路径(该路径不存在则用该会话当前 SFTP + 目录)。 + +- **Tooltips on the top-bar icons** (theme / download / settings / session sync). + **右上角图标悬停提示**(切换主题 / 下载 / 设置 / 会话同步)。 + +### Fixed / 修复 + +- **Light-mode dialogs.** Inputs and buttons in the group / rename / quick-command + dialogs no longer blend into the background under the light theme — Slint's + std-widget palette now follows the app theme. + **浅色模式对话框。** 分组 / 重命名 / 快捷命令等对话框里的输入框和按钮在浅色主题 + 下不再与背景融为一体——std-widget 调色板现在跟随应用主题。 + +- **Empty session groups** now show a collapse chevron and can be expanded / + collapsed, lining up with non-empty groups. + **空会话分组** 现在也显示折叠箭头、可展开 / 收起,与非空分组对齐。 + +## [0.4.3] - 2026-06-14 + +### Fixed / 修复 + +- **Wide CJK glyphs are grid-aligned in the terminal.** With a Chinese path, the + trailing `/` after `ll`, the cursor after `cd`, and the prompt `$` no longer + overlap or drift away from the last CJK character — each wide character now + occupies exactly its two terminal cells. + **终端里的中文(宽字符)对齐到网格。** 中文路径下,`ll` 后目录名的 `/`、`cd` + 之后的光标、提示符 `$` 不再与中文末字重叠或拉开很远——每个宽字符现在正好 + 占它的两个终端格。 + +## [0.4.1] - 2026-06-14 + +### Added / 新增 + +- **Run / copy / delete actions on command history (#96).** Each entry in the + command-history dropdown now has run (▶), copy (⧉) and delete (🗑) buttons — + run executes it immediately, copy puts it on the clipboard, delete removes it. + **命令历史的运行 / 复制 / 删除 (#96)。** 历史下拉里每条记录新增 ▶ 运行、 + ⧉ 复制、🗑 删除按钮——运行即时执行,复制到剪贴板,删除移除该条。 + +- **Default-collapse settings for the sidebars (#78).** Settings → Interface → + Sidebars adds two checkboxes to collapse the left resource panel and the bottom + SFTP panel on startup — handy on low-spec jump hosts. + **侧栏默认收起设置 (#78)。** 设置 → 界面 → 侧栏 新增两个复选框,可在启动时 + 收起左侧资源面板和底部 SFTP 面板——适合低配跳板机。 + +## [0.4.0] - 2026-06-14 + +### Added / 新增 + +- **SSH port forwarding / tunnels (#56).** Per-session tunnels configured in the + session dialog's Advanced section: local (-L), remote (-R) and dynamic + (-D / SOCKS5). They auto-establish on connect and tear down on disconnect. + **SSH 端口转发 / 隧道 (#56)。** 在会话对话框「高级」里按会话配置:本地 -L、 + 远程 -R、动态 -D(SOCKS5)。连接时自动建立,断开时拆除。 + +- **Quick commands, command box & history (#55).** A command bar below the + terminal: save named commands and click to run them, type into a command box + (with an "all sessions" broadcast toggle), and recall history with ↑/↓. + **快捷命令、命令输入框与历史 (#55)。** 终端下方的命令栏:保存命名命令点击即发、 + 命令输入框(含「所有会话」群发开关)、↑/↓ 回溯历史。 + +- **Remote process monitor (#23).** The server-resource panel gains a "Processes" + button that opens a read-only table (PID / user / CPU% / MEM% / command), sorted + by CPU and refreshed live. + **远端进程监控 (#23)。** 服务器资源面板新增「进程」按钮,打开只读进程表 + (PID / 用户 / CPU% / 内存% / 命令),按 CPU 排序、实时刷新。 + +- **Encrypted private keys (#90).** Key auth now accepts a passphrase for + encrypted private keys. + **加密私钥 (#90)。** 私钥认证支持为加密私钥输入密码短语。 + +### Fixed / 修复 + +- **CJK rendering (#54).** Chinese (especially isolated punctuation like 、:()) + no longer renders as tofu in editable inputs or the terminal — editable fields + and the window now use a CJK-capable font, and CJK terminal spans fall back + correctly. + **中文渲染 (#54)。** 输入框和终端里的中文(尤其「、:()」这类孤立标点)不再 + 显示为方块——可编辑控件与窗口改用支持 CJK 的字体,终端的 CJK 片段也正确回退。 + +- **SFTP cd-follow under zsh (#91).** The SFTP panel now follows `cd` under zsh + (and other shells), not just bash — the cwd notification is registered via the + shell's proper hook instead of bash's `PROMPT_COMMAND` only. + **zsh 下 SFTP 跟随 cd (#91)。** SFTP 面板现在在 zsh(及其它 shell)下也能跟随 + `cd`,不再只支持 bash——按各 shell 正确的钩子注册 cwd 通知。 + +- **Compact, scrollable session dialog.** Proxy and port-forwarding settings are + collapsed under an "Advanced" toggle, and the dialog scrolls instead of being + clipped when it would exceed the window. + **会话对话框更紧凑、可滚动。** 代理与端口转发收进「高级」折叠;对话框超出窗口 + 时内部滚动而非被截断。 + +## [0.3.8] - 2026-06-12 + +### Added / 新增 + +- **Confirm before closing when there are active sessions (#88).** Double-clicking + the title-bar icon (or X / Alt+F4) no longer silently drops live sessions — a + confirm dialog appears; with no sessions the window closes as before. + **有活动会话时关闭前先确认 (#88)。** 双击标题栏图标(或点 X / Alt+F4)不再静默 + 断开正在进行的会话——会弹出确认框;没有会话时则照旧直接关闭。 + +- **"Always ask where to save" download option (#87).** Settings → Interface → + Download adds a checkbox (default off); when on, every download prompts for the + folder instead of using the preset. + **「总是询问保存去何处」下载选项 (#87)。** 设置 → 界面 → 下载 新增复选框 + (默认关闭);勾选后每次下载都询问保存位置,而非直接用预设目录。 + +- **The transfers popup opens automatically when a download starts**, so progress + is visible without opening it by hand. + **下载开始时自动弹出传输面板**,无需手动打开即可看到进度。 + +- **Capped diagnostic log file (groundwork for #86).** Writes to + `/error.log` at WARN and above — a single file capped at 5 MiB that + auto-overwrites when full — so users can share what went wrong (e.g. a bastion + disconnect reason) without setting RUST_LOG. + **容量受限的诊断日志文件(为 #86 铺路)。** 写入 `<配置目录>/error.log` + (WARN 及以上)——单文件、上限 5 MiB、满了自动覆盖——用户无需设置 RUST_LOG 即可 + 把出错信息(如堡垒机断开原因)发来。 + +### Fixed / 修复 + +- **Settings checkboxes now persist visually after reopening the dialog (#87).** + "Always ask where to save" and "SFTP follows cd" used a one-way binding, so + reopening the settings dialog (without restarting) showed the stale state even + though the value was saved; switched to a two-way binding. + **设置里的复选框重新打开对话框后状态保持 (#87)。** 「总是询问保存去何处」和 + 「SFTP 跟随 cd」原用单向绑定,不重启 app 时重开设置对话框会显示旧状态(尽管值 + 已保存);改为双向绑定。 + +## [0.3.7] - 2026-06-12 + +### Added / 新增 + +- **Right-click anywhere in the SFTP file list (#84).** The list fills the panel + height, so right-clicking the whitespace below the items works too; item + actions grey out when nothing was hit, leaving new folder / new file / refresh. + **SFTP 文件列表任意处可右键 (#84)。** 列表填满面板高度,条目下方空白也能右键; + 未点中条目时,条目相关操作置灰,仅保留 新建文件夹 / 新建文件 / 刷新。 + +- **Visual permissions dialog (#84).** Permissions opens a checkbox matrix + (Owner/Group/Other × Read/Write/Execute) prefilled from the file's current + mode, instead of typing an octal string. + **可视化权限对话框 (#84)。** 「权限」打开勾选矩阵(所有者/组/其他 × 读取/写入/ + 执行),按文件当前权限预填,不再手输八进制。 + +- **Open / Edit externally (#81).** New SFTP context-menu items hand the file to + the OS default app (e.g. VS Code) for syntax highlighting / large files; edit + mode watches the temp copy and re-uploads on change. + **外部程序查看 / 编辑 (#81)。** SFTP 右键新增菜单项,把文件交给系统默认程序 + (如 VS Code)打开以获得语法高亮 / 处理大文件;编辑模式监听临时副本并自动重传。 + +- **Line numbers in the built-in editor (#81).** + **内置编辑器加行号 (#81)。** + +- **Upload a whole folder from the Upload button (#85).** The button now offers + "Upload file" (multi-select) / "Upload folder". + **上传按钮支持整个文件夹 (#85)。** 现在提供「上传文件」(可多选) /「上传文件夹」。 + +- **Linux ARM64 release build (#82)** and **AUR packaging scaffolding (#61).** + **Linux ARM64 发布构建 (#82)** 与 **AUR 打包脚手架 (#61)。** + +### Changed / 变更 + +- **Downloads default to the user's Downloads folder (#85)** instead of prompting + every time; the settings button reads "Choose save path". + **下载目录默认设为用户的「下载」文件夹 (#85)**,不再每次询问;设置按钮文案改为 + 「选择保存路径」。 + +### Fixed / 修复 + +- **No more error under fish (#71).** The OSC 7 prompt injection is guarded with + `test -z "$FISH_VERSION"`, so it's a no-op under fish (which emits OSC 7 on its + own, keeping cd-follow working) and unchanged under bash/zsh/sh. + **fish 下不再报错 (#71)。** OSC 7 提示符注入加了 `test -z "$FISH_VERSION"` 守卫, + 在 fish 下为空操作(fish 自带 OSC 7,cd 跟随照常),bash/zsh/sh 行为不变。 + +## [0.3.5] - 2026-06-12 +- 我和claude都快冒烟了,不想写,让我摆烂一会吧。Claude and I are both about to explode with frustration. We don't want to write anymore. Let me just laze around for a while. + +## [0.3.3] - 2026-06-11 + +### Added / 新增 + +- **In-app new-version notification (#48).** On startup a background thread + checks the GitHub releases API; if a newer version exists, a dismissible + top-centre banner offers a Download button (opens the release page). Purely + informational — the app keeps working on the current version, never forced. + **应用内新版本通知 (#48)。** 启动时后台线程查询 GitHub releases API,若有更 + 新版本则在顶部居中显示一个可关闭的横幅,带"下载"按钮(打开 release 页)。纯 + 提示性质——应用照常在当前版本运行,绝不强制更新。 + +- **Editable SFTP path bar with copy & paste-to-jump (#54).** The path bar is + now an input: type or paste a path and press Enter to jump there, plus a copy + button and a paste-and-jump button. + **可编辑的 SFTP 路径栏,支持复制和粘贴跳转 (#54)。** 路径栏现在是输入框:输入 + 或粘贴路径后回车即跳转,另有复制按钮和粘贴跳转按钮。 + +### Fixed / 修复 + +- **SFTP panel no longer gets stuck "loading" after manual navigation (#59).** + The panel only follows a real cd now (cwd actually changed), not the OSC 7 that + every prompt re-emits — so manual browsing isn't overridden and a later cd + reloads correctly instead of hanging. + **SFTP 面板手动导航后不再卡"加载中" (#59)。** 面板现在只跟随真正的 cd(cwd 确 + 实变化),而非每个命令提示符都重发的 OSC 7——手动浏览不被覆盖,之后的 cd 也能 + 正确重新加载而非卡住。 + +- **SFTP path bar renders Chinese instead of tofu (#54).** The embedded Cascadia + Mono has no CJK glyphs and native TextInput doesn't glyph-fallback; editable + inputs now use a CJK-capable system font. + **SFTP 路径栏正常显示中文,不再是豆腐块 (#54)。** 嵌入的 Cascadia Mono 没有 + CJK 字形,而原生 TextInput 不做字形回退;可编辑输入现改用含 CJK 的系统字体。 + +## [0.3.2] - 2026-06-11 + +### Added / 新增 + +- **MSI installer for Windows (best-effort).** Built by cargo-wix with a + WixUI_InstallDir wizard, so you can change the install location during setup. + **Windows MSI 安装包(尽力而为)。** 由 cargo-wix 构建,带 WixUI_InstallDir + 向导,安装时可更改安装位置。 + +- **Explicit session folders (#41).** Groups are now first-class: create / rename + / delete them, keep empty folders, and right-click a group header to manage it. + Right-click a session to "move to" any group (incl. empty ones). + **显式会话文件夹 (#41)。** 分组成为一等公民:可新建 / 重命名 / 删除,可保留空 + 文件夹,右键分组标题进行管理;右键会话可"移动到"任意分组(含空文件夹)。 + +### Changed / 变更 + +- **Interface settings dialog** is now draggable (by its title bar), truly modal + (background click no longer closes it), and its font controls no longer span + the full pane. + **「界面」设置对话框**现在可拖动(拖标题栏)、真模态(点背景不再关闭),字体控件 + 也不再占满整个面板。 + +- **Session right-click menu reordered:** Edit / Duplicate / Delete above the + divider, the "move to group" list below it with a header hint. + **会话右键菜单重排:** 编辑 / 复制副本 / 删除在分割线上方,"移动到分组"列表带 + 提示在下方。 + +### Fixed / 修复 + +- **Wide (CJK) characters no longer misalign the cursor (#60).** A wide glyph's + blank continuation cell was being filled with a space, pushing the line and + cursor one cell right per character; it now emits nothing. + **宽(CJK)字符不再导致光标错位 (#60)。** 宽字形的空白延续格此前被补了空格, + 每个字符把行和光标右推一格;现在延续格不输出任何内容。 + +- **Download & settings popups close on click-outside.** A full-window backdrop + under each popup closes it when you click outside. + **下载/设置弹窗点击外部即关闭。** 每个弹窗下方铺一层全窗背景,点击外部即关闭。 + +- **Disk tooltip clears when the pointer leaves the panel**, and the OSC 7 + shell-integration command no longer leaves an extra blank prompt on connect. + **磁盘 tooltip 在指针离开面板时消失**,OSC 7 shell 集成命令也不再在连接时留下 + 多余的空提示符。 + +## [0.3.1] - 2026-06-10 + +### Security / 安全 + +- **Restrict sessions.json to owner-only on Unix (#34).** The config file holds + (encrypted) credentials, so it is now written with mode 0600 — like + secret.key — and other local accounts can't read it. Windows %APPDATA% is + already owner-restricted by default ACLs. + **将 sessions.json 限制为仅属主可读(Unix)(#34)。** 配置文件含(加密的)凭据, + 现在以 0600 权限写入(与 secret.key 一致),其它本地账户无法读取。Windows 的 + %APPDATA% 默认 ACL 已限制为属主。 + +### Build / 构建 + +- **Build macos-x86_64 by cross-compiling on Apple Silicon runners.** The + dedicated Intel (macos-13) runners queue for ages and often time out, so the + x86_64 Mac binary is now cross-compiled on a plentiful macos-14 runner. + **在 Apple Silicon runner 上交叉编译 macos-x86_64。** 专用 Intel(macos-13) + runner 排队极久且常超时,x86_64 Mac 二进制改为在充足的 macos-14 runner 上 + 交叉编译。 + +## [0.3.0] - 2026-06-10 + +### Added / 新增 + +- **Interface settings — terminal font & size.** The gear menu's new "Interface" + item opens a modal dialog (27% nav / 73% content) whose Font page lets you pick + from the system's installed monospace fonts and set the size (8–32 px) with a + live preview. Both apply immediately (cell size / cols / rows re-derive) and + persist. + **「界面」设置 —— 终端字体与字号。** 齿轮菜单新增「界面」项,打开模态对话框 + (27% 导航 / 73% 内容),「字体」页可从系统已安装的等宽字体中选择并设置字号 + (8–32 px),带实时预览。两者即时生效(cell 尺寸 / 列 / 行重新推导)并持久化。 + +- **Session folders / groups (#41).** Each session can belong to an optional + group; Quick Connect shows folder headings (sorted; ungrouped under "default") + that collapse when clicked. Right-click a session to move it to another group + or duplicate it; right-click a group header to create a new group. + **会话文件夹 / 分组 (#41)。** 每个会话可归入可选分组;「快速连接」按分组显示 + 文件夹标题(排序;未分组归入「default」),点击标题可折叠。右键会话可移动到其它 + 分组或复制副本;右键分组标题可新建分组。 + +- **Recursive SFTP folder transfer (#50).** Upload, download and delete whole + directory trees: drag a folder onto the panel to upload, right-click a folder + to download, and delete now removes non-empty directories too. + **SFTP 文件夹递归传输 (#50)。** 可上传、下载、删除整个目录树:把文件夹拖到面板 + 上传,右键文件夹下载,删除现在也能删非空目录。 + +- **Collapsible panels (#41).** Both the left sidebar and the SFTP panel can be + minimized to reclaim screen space. + **可折叠面板 (#41)。** 左侧栏与 SFTP 面板都可最小化以腾出屏幕空间。 + +- **Export / import connections (#46).** New "Export connections" / "Import + connections" in the settings menu to migrate sessions between machines. The + exported JSON keeps host/user/port in plaintext and obfuscates only the + password with a built-in key, so it opens on any machine; key-auth sessions + export the key path. Imports skip duplicates (host+user+port+kind). + **导出 / 导入连接 (#46)。** 设置菜单新增「导出连接」「导入连接」,用于在多台 + 机器间迁移会话。导出的 JSON 中 host/user/port 为明文,仅密码用内置 key 混淆, + 因此在任意机器都能打开;密钥认证的会话导出私钥路径。导入会跳过重复 + (host+user+port+kind)。 + +- **Pick SOCKS5 or HTTP proxy type in the session dialog (#46).** The proxy + field is now a None / SOCKS5 / HTTP selector plus a `host:port` input. HTTP + CONNECT was already supported by the backend but wasn't selectable in the UI. + The stored proxy URL format is unchanged. + **会话对话框选择 SOCKS5 或 HTTP 代理类型 (#46)。** 代理项改为 不使用 / SOCKS5 / + HTTP 选择器加 `host:port` 输入框。HTTP CONNECT 后端早已支持,只是 UI 无法选择。 + 存储的代理 URL 格式不变。 + +- **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 / 修复 + +- **Drag-select no longer auto-scrolls within the visible area (#41).** Selecting + text now only scrolls once the drag leaves the viewport edge, so the view no + longer jumps and the selection no longer snaps to an edge row. + **拖动选择在可见区内不再自动滚动 (#41)。** 现在仅当拖动离开视口边缘才滚动, + 视图不再乱跳、选区也不再吸附到边缘行。 + +- **Alt no longer clears the typed command (#43).** Slint encodes a lone + modifier key as a C0 code point (Alt=0x12); pressing Alt (e.g. to Alt+Tab + away) sent ESC+0x12 to the PTY, which bash/readline treated as Meta and + discarded the input line. Bare modifier codes are now dropped, with a guard + that preserves a real Ctrl+P..Ctrl+X. + **按 Alt 不再清空已输入命令 (#43)。** Slint 把单独的修饰键编码成 C0 码位 + (Alt=0x12);按 Alt(如 Alt+Tab 切换)会向 PTY 发送 ESC+0x12,被 bash/readline + 当作 Meta 而丢弃输入行。现在丢弃单独的修饰键码位,并保留真实的 Ctrl+P..Ctrl+X。 + +- **Multi-line / backslash-continued commands now paste intact.** Pasted text + kept its CRLF/LF line breaks, but the terminal expects CR for Enter, so CRLF + made the shell see two breaks per line and end a `\`-continued command early. + Pasted line endings are normalised to a single CR. + **多行 / 反斜杠续行命令现在能完整粘贴。** 粘贴文本保留了 CRLF/LF 换行,而终端 + 回车应为 CR,CRLF 会让 shell 每行看到两个换行、提前结束 `\` 续行命令。现在把 + 粘贴的换行统一规范为单个 CR。 + +- **Session dialog no longer mis-lays-out when switching connection type.** The + card had a fixed height, so Telnet/Serial (with fewer fields) left slack space + that stretched inputs apart. The card height now follows its content. + **切换连接类型时会话对话框不再排版错乱。** 卡片此前固定高度,Telnet/串口 + (字段更少)会留出空白把输入框撑开。现在卡片高度跟随内容。 + +- **Copy/paste works on Wayland sessions (#47).** arboard's default Linux + backend is X11, which fails on Wayland (Debian sid / KDE) without XWayland. + Enabled arboard's native `wayland-data-control` backend, and copy now uses + set().wait() so the selection survives after the clipboard handle is dropped. + **Wayland 会话下复制粘贴恢复可用 (#47)。** arboard 默认 Linux 后端是 X11,在 + 无 XWayland 的 Wayland(Debian sid / KDE)下失效。启用 arboard 原生 + `wayland-data-control` 后端,复制改用 set().wait() 使选区在剪贴板句柄 drop 后 + 仍然有效。 + +- **Hide the shell-integration command from the terminal.** meatshell injects a + one-line `PROMPT_COMMAND` (OSC 7) on connect so the SFTP panel can follow the + terminal's working directory. Its echo used to show up on every connect (and + pollute shell history); the line now carries a leading space (kept out of + history) and its echo is stripped from the output before display. + **隐藏 shell 集成注入命令。** meatshell 连接时会注入一行 `PROMPT_COMMAND` + (OSC 7),让 SFTP 面板跟随终端当前目录。此前它的回显每次连接都显示在终端 + (并污染命令历史);现在该行带前导空格(不进历史),回显也会在显示前被剥离。 + +- **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 / 安全 + +- **Redact proxy credentials and zero them in memory (#32).** The HTTP/SOCKS + proxy password is now wrapped in `Secret` (zeroed on drop) and ProxyConfig has + a manual Debug that redacts auth, so credentials can't leak via {:?}/tracing + or linger in core dumps. + **代理凭据脱敏并在内存清零 (#32)。** HTTP/SOCKS 代理密码改用 `Secret` 包装 + (drop 时清零),ProxyConfig 手写 Debug 对凭据脱敏,使其无法经 {:?}/tracing + 泄露或残留于 core dump。 + +- **Validate HostName when importing ~/.ssh/config (#33).** Imported HostName + values are now checked — IP literals and DNS hostnames accepted; shell + metacharacters, whitespace and scheme prefixes rejected — and invalid entries + are skipped with a warning. + **导入 ~/.ssh/config 时校验 HostName (#33)。** 现在校验导入的 HostName(接受 + IP 字面量与 DNS 域名,拒绝 shell 元字符、空白与协议前缀),非法条目跳过并告警。 + +- **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.3.8]: https://github.com/jeff141/meatshell/releases/tag/v0.3.8 +[0.3.7]: https://github.com/jeff141/meatshell/releases/tag/v0.3.7 +[0.3.3]: https://github.com/jeff141/meatshell/releases/tag/v0.3.3 +[0.3.2]: https://github.com/jeff141/meatshell/releases/tag/v0.3.2 +[0.3.1]: https://github.com/jeff141/meatshell/releases/tag/v0.3.1 +[0.3.0]: https://github.com/jeff141/meatshell/releases/tag/v0.3.0 +[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 00000000..e1a002c9 --- /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.lock b/Cargo.lock index a06fd530..b72e6860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,7 +34,7 @@ dependencies = [ "accesskit_consumer", "atspi-common", "serde", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -76,7 +76,7 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -119,33 +119,56 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", ] [[package]] name = "aes-gcm" -version = "0.10.3" +version = "0.11.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ - "aead", + "aead 0.6.1", "aes", - "cipher", + "cipher 0.5.2", "ctr", "ghash", "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", ] [[package]] @@ -275,6 +298,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -289,6 +313,18 @@ dependencies = [ "syn", ] +[[package]] +name = "argon2" +version = "0.6.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.0", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -335,7 +371,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -516,11 +552,11 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus", + "zbus 5.14.0", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names", - "zvariant", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -531,7 +567,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -595,11 +631,34 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -615,9 +674,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bcrypt-pbkdf" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", "pbkdf2", @@ -684,22 +743,41 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.11.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", ] [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -735,12 +813,12 @@ dependencies = [ [[package]] name = "blowfish" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -862,11 +940,11 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -918,8 +996,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", ] [[package]] @@ -942,8 +1046,21 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", ] [[package]] @@ -975,6 +1092,21 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "color_quant" version = "1.1.0" @@ -1023,9 +1155,9 @@ dependencies = [ [[package]] name = "const-oid" -version = "0.9.6" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "const-random" @@ -1135,6 +1267,12 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1144,6 +1282,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1201,12 +1348,17 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "97bb4a855e3b10f84c4e7e895a7de01db7f9a7b7eb7f73ed9773fd52ac686451" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", "subtle", "zeroize", ] @@ -1217,11 +1369,32 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "ctor" version = "0.10.1" @@ -1233,11 +1406,21 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cipher", + "cmov", + "subtle", ] [[package]] @@ -1248,14 +1431,14 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" -version = "4.1.3" +version = "5.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -1273,6 +1456,22 @@ dependencies = [ "syn", ] +[[package]] +name = "dark-light" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" +dependencies = [ + "dconf_rs", + "detect-desktop-environment", + "dirs", + "objc", + "rust-ini", + "web-sys", + "winreg", + "zbus 4.4.0", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -1285,6 +1484,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dconf_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" + [[package]] name = "delegate" version = "0.13.5" @@ -1298,9 +1503,9 @@ dependencies = [ [[package]] name = "der" -version = "0.7.10" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid", "pem-rfc7468", @@ -1343,23 +1548,39 @@ dependencies = [ [[package]] name = "des" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ - "cipher", + "cipher 0.5.2", ] +[[package]] +name = "detect-desktop-environment" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", "const-oid", - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1368,7 +1589,27 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", ] [[package]] @@ -1421,6 +1662,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1479,25 +1726,32 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" dependencies = [ "der", - "digest", + "digest 0.11.3", "elliptic-curve", "rfc6979", "signature", "spki", + "zeroize", ] [[package]] name = "ed25519" -version = "2.2.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", "signature", @@ -1505,15 +1759,16 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.2.0" +version = "3.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.6.4", + "rand_core 0.10.1", "serde", "sha2", + "signature", "subtle", "zeroize", ] @@ -1526,20 +1781,22 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.0-rc.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", - "generic-array", "group", "hkdf", + "hybrid-array", + "once_cell", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core 0.10.1", "sec1", "subtle", "zeroize", @@ -1551,6 +1808,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1716,19 +1985,19 @@ dependencies = [ [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "field-offset" @@ -1757,6 +2026,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1779,7 +2054,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" dependencies = [ - "ahash", + "ahash 0.8.12", "num_cpus", "parking_lot", "seize", @@ -1812,6 +2087,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + [[package]] name = "fontdb" version = "0.23.0" @@ -1821,7 +2119,7 @@ dependencies = [ "log", "slotmap", "tinyvec", - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -1839,7 +2137,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parlance", "read-fonts", - "roxmltree", + "roxmltree 0.21.1", "smallvec", "windows 0.62.2", "windows-core 0.62.2", @@ -1882,6 +2180,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -2013,7 +2317,17 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", ] [[package]] @@ -2042,10 +2356,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -2067,19 +2379,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] name = "ghash" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "opaque-debug", "polyval", ] @@ -2196,12 +2510,12 @@ checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -2229,6 +2543,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2287,35 +2610,26 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-literal" -version = "0.4.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] @@ -2324,6 +2638,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "i-slint-backend-linuxkms" version = "1.16.1" @@ -2339,10 +2665,11 @@ dependencies = [ "i-slint-common", "i-slint-core", "i-slint-renderer-femtovg", + "i-slint-renderer-skia", "i-slint-renderer-software", "input", "memmap2", - "nix", + "nix 0.30.1", "raw-window-handle", "xkbcommon", ] @@ -2406,7 +2733,7 @@ dependencies = [ "webbrowser", "windows 0.62.2", "winit", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -2714,6 +3041,7 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "log", "serde", "stable_deref_trait", "writeable", @@ -2834,9 +3162,18 @@ name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ "block-padding", - "generic-array", + "hybrid-array", ] [[package]] @@ -2867,6 +3204,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.1", + "rand_core 0.10.1", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2878,6 +3227,16 @@ dependencies = [ "syn", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -2993,6 +3352,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3026,9 +3405,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128fmt" @@ -3086,6 +3462,16 @@ dependencies = [ "redox_syscall 0.7.4", ] +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -3206,6 +3592,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3227,39 +3631,48 @@ dependencies = [ [[package]] name = "md5" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "meatshell" -version = "0.1.0" +version = "0.4.14" dependencies = [ "anyhow", "arboard", "async-trait", + "base64", + "chacha20poly1305", "chrono", + "dark-light", "directories", + "fontdb 0.16.2", "futures", "i-slint-backend-winit", + "icu_provider", + "image", "rand 0.8.6", "rfd", "russh", - "russh-keys", "russh-sftp", "serde", "serde_json", + "serialport", "slint", "slint-build", "ssh-key", "sysinfo", "thiserror 1.0.69", "tokio", + "tokio-socks", "tracing", "tracing-subscriber", + "ureq", "uuid", "vt100", "winresource", + "zeroize", ] [[package]] @@ -3313,6 +3726,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3384,6 +3822,30 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -3396,6 +3858,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.3" @@ -3456,23 +3930,6 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", - "rand 0.8.6", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", ] [[package]] @@ -3495,17 +3952,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -3559,6 +4005,15 @@ dependencies = [ "syn", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3966,6 +4421,16 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3976,66 +4441,84 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", "sha2", ] [[package]] name = "p384" -version = "0.13.1" +version = "0.14.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02" dependencies = [ "ecdsa", "elliptic-curve", + "fiat-crypto", + "primefield", "primeorder", "sha2", ] [[package]] name = "p521" -version = "0.13.3" +version = "0.14.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae" dependencies = [ "base16ct", "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "rand_core 0.6.4", "sha2", ] [[package]] name = "pageant" -version = "0.0.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" dependencies = [ + "base16ct", + "byteorder", "bytes", "delegate", "futures", - "rand 0.8.6", - "thiserror 1.0.69", + "log", + "rand 0.10.1", + "sha2", + "thiserror 2.0.18", "tokio", - "windows 0.58.0", + "windows 0.62.2", + "windows-strings 0.5.1", ] [[package]] @@ -4100,6 +4583,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -4114,19 +4606,19 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", ] [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -4137,6 +4629,27 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4194,25 +4707,25 @@ dependencies = [ [[package]] name = "pkcs1" -version = "0.7.5" +version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ "der", - "pkcs8", "spki", ] [[package]] name = "pkcs5" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" dependencies = [ "aes", "cbc", "der", "pbkdf2", + "rand_core 0.10.1", "scrypt", "sha2", "spki", @@ -4220,13 +4733,13 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "pkcs5", - "rand_core 0.6.4", + "rand_core 0.10.1", "spki", ] @@ -4294,21 +4807,31 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", + "zeroize", ] [[package]] name = "polyval" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -4350,11 +4873,25 @@ dependencies = [ "syn", ] +[[package]] +name = "primefield" +version = "0.14.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8675564771a62f69a0af716b03e89b917b963c7b173b5855575e84fd4f605ca0" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf" dependencies = [ "elliptic-curve", ] @@ -4497,6 +5034,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4535,6 +5083,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rav1e" version = "0.8.1" @@ -4720,9 +5274,9 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" dependencies = [ "hmac", "subtle", @@ -4761,6 +5315,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rowan" version = "0.16.1" @@ -4773,6 +5341,12 @@ dependencies = [ "text-size", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "roxmltree" version = "0.21.1" @@ -4784,22 +5358,20 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.10" +version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", "pkcs1", "pkcs8", - "rand_core 0.6.4", + "rand_core 0.10.1", "sha2", "signature", "spki", - "subtle", "zeroize", ] @@ -4818,92 +5390,44 @@ dependencies = [ [[package]] name = "russh" -version = "0.49.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b206640a622d63529540fc48036aa39f211b2b71432a451156004f40655cdb4" +checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" dependencies = [ "aes", - "aes-gcm", - "async-trait", + "aws-lc-rs", "bitflags 2.11.1", - "byteorder", - "bytes", - "cbc", - "chacha20", - "ctr", - "curve25519-dalek", - "delegate", - "des", - "digest", - "elliptic-curve", - "flate2", - "futures", - "generic-array", - "hex-literal", - "hmac", - "log", - "num-bigint", - "once_cell", - "p256", - "p384", - "p521", - "poly1305", - "rand 0.8.6", - "rand_core 0.6.4", - "rsa", - "russh-cryptovec", - "russh-keys", - "russh-sftp", - "russh-util", - "sha1", - "sha2", - "signature", - "ssh-encoding", - "ssh-key", - "subtle", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "russh-cryptovec" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d8e7e854e1a87e4be00fa287c98cad23faa064d0464434beaa9f014ec3baa98" -dependencies = [ - "libc", - "ssh-encoding", - "winapi", -] - -[[package]] -name = "russh-keys" -version = "0.49.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788a2439ce385856585346beb37c48e7c9eb5de5f4f00736720a19ffdb3f5bb5" -dependencies = [ - "aes", - "async-trait", - "bcrypt-pbkdf", "block-padding", "byteorder", "bytes", "cbc", + "cipher 0.5.2", + "crypto-bigint", "ctr", + "curve25519-dalek", "data-encoding", + "delegate", "der", - "digest", + "digest 0.11.3", "ecdsa", "ed25519-dalek", "elliptic-curve", + "enum_dispatch", + "flate2", "futures", - "getrandom 0.2.17", + "generic-array 1.4.2", + "getrandom 0.4.2", + "ghash", + "hex-literal", "hmac", - "home", - "inout", + "inout 0.2.2", + "internal-russh-num-bigint", + "keccak", "log", "md5", - "num-integer", + "ml-kem", + "module-lattice", + "num-bigint", "p256", "p384", "p521", @@ -4912,26 +5436,43 @@ dependencies = [ "pkcs1", "pkcs5", "pkcs8", - "rand 0.8.6", - "rand_core 0.6.4", + "polyval", + "rand 0.10.1", + "rand_core 0.10.1", + "ring", "rsa", "russh-cryptovec", "russh-util", + "salsa20", + "scrypt", "sec1", - "serde", - "sha1", + "sha1 0.11.0", "sha2", + "sha3", "signature", "spki", "ssh-encoding", "ssh-key", - "thiserror 1.0.69", + "subtle", + "thiserror 2.0.18", "tokio", - "tokio-stream", "typenum", + "universal-hash 0.6.1", "zeroize", ] +[[package]] +name = "russh-cryptovec" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + [[package]] name = "russh-sftp" version = "2.1.1" @@ -4951,9 +5492,9 @@ dependencies = [ [[package]] name = "russh-util" -version = "0.48.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c7dd577958c0cefbc8f8a2c05c48c88c42e2fdb760dbe9b96ae31d4de97a1f" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" dependencies = [ "chrono", "tokio", @@ -4961,6 +5502,16 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5008,6 +5559,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -5025,7 +5611,7 @@ dependencies = [ "core_maths", "log", "smallvec", - "ttf-parser", + "ttf-parser 0.25.1", "unicode-bidi-mirroring", "unicode-ccc", "unicode-properties", @@ -5034,11 +5620,12 @@ dependencies = [ [[package]] name = "salsa20" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ - "cipher", + "cfg-if", + "cipher 0.5.2", ] [[package]] @@ -5070,10 +5657,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrypt" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ + "cfg-if", "pbkdf2", "salsa20", "sha2", @@ -5094,14 +5682,14 @@ dependencies = [ [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", + "hybrid-array", "subtle", "zeroize", ] @@ -5173,34 +5761,84 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "serde_core", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", ] [[package]] @@ -5230,12 +5868,12 @@ dependencies = [ [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", - "rand_core 0.6.4", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] @@ -5510,6 +6148,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "as-raw-xcb-connection", "bytemuck", + "drm", "fastrand", "js-sys", "memmap2", @@ -5533,12 +6172,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - [[package]] name = "spin_on" version = "0.1.1" @@ -5550,9 +6183,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", "der", @@ -5560,53 +6193,62 @@ dependencies = [ [[package]] name = "ssh-cipher" -version = "0.2.0" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" dependencies = [ + "aead 0.6.1", "aes", "aes-gcm", "cbc", - "chacha20", - "cipher", + "chacha20 0.10.0", + "cipher 0.5.2", "ctr", - "poly1305", + "ctutils", + "des", + "poly1305 0.9.0", "ssh-encoding", - "subtle", + "zeroize", ] [[package]] name = "ssh-encoding" -version = "0.2.0" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a" dependencies = [ "base64ct", "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", "pem-rfc7468", - "sha2", + "zeroize", ] [[package]] name = "ssh-key" -version = "0.6.7" +version = "0.7.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b" dependencies = [ + "argon2", "bcrypt-pbkdf", + "ctutils", "ed25519-dalek", - "num-bigint-dig", + "hex", + "hmac", "p256", "p384", "p521", - "rand_core 0.6.4", + "rand_core 0.10.1", "rsa", "sec1", + "sha1 0.11.0", "sha2", "signature", "ssh-cipher", "ssh-encoding", - "subtle", "zeroize", ] @@ -5958,15 +6600,15 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.18" +name = "tokio-socks" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ - "futures-core", - "pin-project-lite", + "either", + "futures-util", + "thiserror 1.0.69", "tokio", - "tokio-util", ] [[package]] @@ -6120,6 +6762,23 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6168,6 +6827,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicase" version = "2.9.0" @@ -6252,16 +6920,54 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -6290,19 +6996,19 @@ dependencies = [ "base64", "data-url", "flate2", - "fontdb", + "fontdb 0.23.0", "imagesize", "kurbo", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher", "strict-num", "svgtypes", "tiny-skia-path 0.12.0", - "ttf-parser", + "ttf-parser 0.25.1", "unicode-bidi", "unicode-script", "unicode-vo", @@ -6706,6 +7412,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6753,16 +7477,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -6818,19 +7532,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -6890,17 +7591,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -6923,17 +7613,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -6986,15 +7665,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -7013,16 +7683,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -7296,7 +7956,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash", + "ahash 0.8.12", "android-activity", "atomic-waker", "bitflags 2.11.1", @@ -7360,6 +8020,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winresource" version = "0.1.31" @@ -7464,6 +8133,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.45.0" @@ -7541,6 +8228,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon" version = "0.9.0" @@ -7629,6 +8326,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.14.0" @@ -7659,9 +8394,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.14.0", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -7671,7 +8406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -7685,7 +8420,20 @@ dependencies = [ "syn", "zbus-lockstep", "zbus_xml", - "zvariant", + "zvariant 5.10.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -7698,9 +8446,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7711,7 +8470,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.15", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -7722,8 +8481,8 @@ checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" dependencies = [ "quick-xml 0.38.4", "serde", - "zbus_names", - "zvariant", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -7844,6 +8603,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.10.0" @@ -7855,8 +8627,21 @@ dependencies = [ "serde", "url", "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -7869,7 +8654,18 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 49457f73..fee85754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "meatshell" -version = "0.1.0" +version = "0.4.14" edition = "2021" authors = ["meatshell contributors"] description = "A lightweight FinalShell-style SSH/terminal client written in Rust + Slint" @@ -11,6 +11,12 @@ rust-version = "1.75" [dependencies] slint = { version = "1.8", features = ["compat-1-2"] } +# ICU4X (pulled in by Slint's text layout) spams stderr with raw `eprintln!` +# ("ICU4X data error: No segmentation model for language: ja") because its +# `log` shim falls back to eprintln when the `logging` feature is off. Turning +# `logging` on routes those through the real `log` crate → our tracing +# subscriber, where init_tracing silences the `icu_provider` target. +icu_provider = { version = "2", features = ["logging"] } # 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"] } @@ -22,9 +28,8 @@ chrono = { version = "0.4", features = ["serde"] } sysinfo = "0.33" 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" +russh = { version = "0.61", features = ["ring"] } +ssh-key = "0.7.0-rc.10" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } rand = "0.8" @@ -34,6 +39,46 @@ vt100 = "0.15" 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" +# Enumerate installed system fonts for the Interface (font) settings picker. +fontdb = "0.16" +# 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" +# Lightweight synchronous HTTPS client (bundled rustls, no OpenSSL) for the +# in-app update check against the GitHub releases API (#48). Runs on a +# background thread so it never blocks startup; failure is silently ignored. +ureq = { version = "2", features = ["tls"] } + +# 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. +[target.'cfg(target_os = "linux")'.dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } +# Native Wayland clipboard (wlr-data-control protocol) so copy/paste works on +# Wayland sessions (KDE/wlroots) without relying on XWayland (issue #47). +# Feature is additive over the base `arboard = "3"` dependency above. +arboard = { version = "3", features = ["wayland-data-control"] } + +# Compile the Skia renderer in *only* on macOS (feature is additive over the base +# slint dependency) so users can opt into it at launch with SLINT_BACKEND=winit-skia. +# The default stays femtovg — see the renderer history note in main.rs (#108/#129). +# Windows/Linux never compile Skia. +[target.'cfg(target_os = "macos")'.dependencies] +slint = { version = "1.8", features = ["renderer-skia"] } [build-dependencies] slint-build = "1.8" diff --git a/README.en.md b/README.en.md index 4a26445f..4ab19533 100644 --- a/README.en.md +++ b/README.en.md @@ -8,34 +8,79 @@ 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. -## Roadmap +## Screenshots -### v0.1 (current) +

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

-- [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 +

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

+ +## Download & install + +Every `v*` tag triggers a GitHub Actions build that produces native binaries for +**Windows / Linux / macOS**, published on the +[Releases](https://github.com/jeff141/meatshell/releases) page. + +### Windows + +Download `meatshell-*-windows-x86_64.zip`, unzip, and run `meatshell.exe`. + +### 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. + +## Features + +### Done + +- [x] FinalShell-style UI with dark / light / follow-system themes +- [x] Local + remote resource monitoring (CPU / memory / swap / network / disk) +- [x] Remote process monitor (read-only table sorted by CPU) +- [x] Full VT/ANSI terminal emulation (btop / htop / vim render correctly) +- [x] Tabs (welcome page + multiple sessions) +- [x] Session management: create / edit / delete / groups, local JSON, export / import - 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] Line-buffered terminal view (type a line → Enter to send) +- [x] SSH (`russh`, pure Rust): password / private key / encrypted key (passphrase) +- [x] SFTP browser + upload / download (drag-and-drop) + in-terminal ZMODEM (`sz`) receive +- [x] SSH port forwarding / tunnels: local -L / remote -R / dynamic -D (SOCKS5) +- [x] Quick commands + command box (broadcast to all sessions) + command history +- [x] Serial / Telnet sessions +- [x] Outbound proxy (SOCKS5 / HTTP) +- [x] Import `~/.ssh/config` +- [x] Session passwords encrypted at rest (ChaCha20-Poly1305) -### v0.2 +### Planned -- [ ] Full VT/ANSI terminal emulation (integrate [`alacritty_terminal`](https://crates.io/crates/alacritty_terminal)) -- [ ] Remote host resource monitoring (run a remote collector script, like FinalShell) -- [x] SFTP file browser + drag-and-drop upload/download - [ ] Known-hosts (`known_hosts`) verification - [ ] Store session passwords in the OS keychain - -### v0.3+ - - [ ] Split panes for tabbed terminals -- [ ] Session groups / folders -- [ ] Theme switching (light / follow system) -- [ ] Command history & snippet management ## Tech stack diff --git a/README.md b/README.md index b61c38c4..0d084cc3 100644 --- a/README.md +++ b/README.md @@ -7,34 +7,76 @@ (资源监控侧栏、会话管理、多标签页终端)的同时,把内存占用从 400 MB+ 的 JVM 压到几十 MB 原生级别。 -## 路线图 +## 截图 -### v0.1(当前) +

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

-- [x] FinalShell 风格深色主题 UI -- [x] 左侧本机系统监控(CPU / 内存 / 交换 / 网络吞吐,1 Hz) -- [x] 多标签页(欢迎页 + 多个终端会话) -- [x] 会话管理:新建 / 编辑 / 删除,本地 JSON 持久化 +

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

+ +## 下载与安装 + +每次打 `v*` 标签,GitHub Actions 会自动构建 **Windows / Linux / macOS** 三平台二进制, +发布到 [Releases](https://github.com/jeff141/meatshell/releases) 页面。 + +### Windows + +下载 `meatshell-*-windows-x86_64.zip`,解压后双击 `meatshell.exe`。 + +### 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 +``` + +> 从源码构建见下方 [运行](#运行)。 + +## 功能 + +### 已实现 + +- [x] FinalShell 风格 UI,深色 / 浅色 / 跟随系统主题 +- [x] 本机 + 远端资源监控(CPU / 内存 / 交换 / 网络 / 磁盘) +- [x] 远端进程监控(按 CPU 排序的只读进程表) +- [x] 完整 VT/ANSI 终端模拟(btop / htop / vim 全屏正常渲染) +- [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] 行缓冲终端视图(输入一行 → 回车发送) +- [x] SSH(`russh`,纯 Rust):密码 / 私钥 / 加密私钥(密码短语) +- [x] SFTP 文件浏览 + 上传 / 下载(拖拽)+ 终端内 ZMODEM(`sz`)接收 +- [x] SSH 端口转发 / 隧道:本地 -L / 远程 -R / 动态 -D(SOCKS5) +- [x] 快捷命令 + 命令输入框(可群发到所有会话)+ 命令历史 +- [x] 串口 / Telnet 会话 +- [x] 出站代理(SOCKS5 / HTTP) +- [x] 导入 `~/.ssh/config` +- [x] 会话密码加密存储(ChaCha20-Poly1305) -### v0.2 +### 计划中 -- [ ] 完整 VT/ANSI 终端模拟(接入 [`alacritty_terminal`](https://crates.io/crates/alacritty_terminal)) -- [ ] 远端主机资源监控(与 FinalShell 一样执行远端脚本收集) -- [x] SFTP 文件浏览 + 拖拽上传/下载 - [ ] 已知主机 (known_hosts) 校验 -- [ ] 会话密码使用 OS 钥匙串存储 - -### v0.3+ - +- [ ] 会话密码改用 OS 钥匙串存储 - [ ] 多标签页终端分屏 -- [ ] 会话分组 / 文件夹 -- [ ] 主题切换(浅色 / 跟随系统) -- [ ] 命令历史与片段管理 ## 技术栈 @@ -88,6 +130,15 @@ meatshell/ - 目前 `check_server_key` 接受任意服务端密钥(类似 `StrictHostKeyChecking=no`), 生产使用前请接入 known_hosts 校验。 +## 赞赏 / 请我喝杯咖啡 + +觉得作品还不错的话,请我喝杯咖啡吧 ☕ + +

+ 亮出网络乞丐乞讨专用码
+ 微信赞赏码 +

+ ## License MIT OR Apache-2.0(双许可)。 diff --git a/assets/Info.plist b/assets/Info.plist new file mode 100644 index 00000000..a24a21c5 --- /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/install-linux.sh b/assets/install-linux.sh new file mode 100644 index 00000000..b75605ac --- /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/meatshell.desktop b/assets/meatshell.desktop new file mode 100644 index 00000000..fbb21ae5 --- /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/build.rs b/build.rs index 0615df24..c278f341 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,16 @@ 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_style("fluent".into()) + .with_bundled_translations("lang") + .with_default_translation_context(slint_build::DefaultTranslationContext::None), ) .expect("Slint build failed"); diff --git a/docs/screenshots/01-welcome-en.png b/docs/screenshots/01-welcome-en.png new file mode 100644 index 00000000..b03022c2 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 00000000..cd7e8e9b 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 00000000..20ee5c67 Binary files /dev/null and b/docs/screenshots/02-terminal-btop.png differ diff --git a/docs/screenshots/sponsor-wechat.png b/docs/screenshots/sponsor-wechat.png new file mode 100644 index 00000000..7c9ced2f Binary files /dev/null and b/docs/screenshots/sponsor-wechat.png differ diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po new file mode 100644 index 00000000..3dc53680 --- /dev/null +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -0,0 +1,382 @@ +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 "Choose save path" +msgstr "Choose save path" + +msgid "Upload file" +msgstr "Upload file" + +msgid "Upload folder" +msgstr "Upload 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 "Key passphrase" +msgstr "Key passphrase" + +msgid "Leave blank if the key isn't encrypted" +msgstr "Leave blank if the key isn't encrypted" + +msgid "Leave blank to keep the current passphrase" +msgstr "Leave blank to keep the current passphrase" + +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 "Update available:" +msgstr "Update available:" + +msgid "Open externally" +msgstr "Open externally" + +msgid "Edit externally" +msgstr "Edit externally" + +msgid "Rename" +msgstr "Rename" + +msgid "Permissions" +msgstr "Permissions" + +msgid "Copy path" +msgstr "Copy path" + +msgid "Refresh" +msgstr "Refresh" + +msgid "New folder" +msgstr "New folder" + +msgid "New file" +msgstr "New file" + +msgid "New name" +msgstr "New name" + +msgid "Octal mode, e.g. 755" +msgstr "Octal mode, e.g. 755" + +msgid "Read-only" +msgstr "Read-only" + +msgid "Modified" +msgstr "Modified" + +msgid "View" +msgstr "View" + +msgid "Edit" +msgstr "Edit" + +msgid "Delete" +msgstr "Delete" + +msgid "Duplicate" +msgstr "Duplicate" + +msgid "New group" +msgstr "New group" + +msgid "Edit group" +msgstr "Edit group" + +msgid "Delete group" +msgstr "Delete group" + +msgid "Group name" +msgstr "Group name" + +msgid "Move to" +msgstr "Move to" + +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 "Processes" +msgstr "Processes" + +msgid "User" +msgstr "User" + +msgid "Command" +msgstr "Command" + +msgid "No processes" +msgstr "No processes" + +msgid "Quick" +msgstr "Quick" + +msgid "Quick commands" +msgstr "Quick commands" + +msgid "Type a command, Enter to send" +msgstr "Type a command, Enter to send" + +msgid "All sessions" +msgstr "All sessions" + +msgid "Send" +msgstr "Send" + +msgid "No quick commands yet" +msgstr "No quick commands yet" + +msgid "Manage…" +msgstr "Manage…" + +msgid "No history yet" +msgstr "No history yet" + +msgid "e.g. tail log" +msgstr "e.g. tail log" + +msgid "Port forwarding" +msgstr "Port forwarding" + +msgid "Advanced (proxy, tunnels)" +msgstr "Advanced (proxy, tunnels)" + +msgid "Local (-L)" +msgstr "Local (-L)" + +msgid "Remote (-R)" +msgstr "Remote (-R)" + +msgid "Dynamic (-D)" +msgstr "Dynamic (-D)" + +msgid "Listen port" +msgstr "Listen port" + +msgid "Target host" +msgstr "Target host" + +msgid "Target port" +msgstr "Target port" + +msgid "Add" +msgstr "Add" + +msgid "-L: local port → target reached from the server" +msgstr "-L: local port → target reached from the server" + +msgid "-R: server port → target reached from here" +msgstr "-R: server port → target reached from here" + +msgid "-D: local SOCKS5 proxy" +msgstr "-D: local SOCKS5 proxy" + +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" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po new file mode 100644 index 00000000..9640837a --- /dev/null +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -0,0 +1,391 @@ +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 "Choose save path" +msgstr "选择保存路径" + +msgid "Upload file" +msgstr "上传文件" + +msgid "Upload 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 "Key passphrase" +msgstr "私钥密码" + +msgid "Leave blank if the key isn't encrypted" +msgstr "若私钥未加密则留空" + +msgid "Leave blank to keep the current passphrase" +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 "Update available:" +msgstr "发现新版本:" + +msgid "Open externally" +msgstr "外部程序查看" + +msgid "Edit externally" +msgstr "外部程序编辑" + +msgid "Rename" +msgstr "重命名" + +msgid "Permissions" +msgstr "权限" + +msgid "Copy path" +msgstr "复制路径" + +msgid "Refresh" +msgstr "刷新" + +msgid "New folder" +msgstr "新建文件夹" + +msgid "New file" +msgstr "新建文件" + +msgid "New name" +msgstr "新名称" + +msgid "Octal mode, e.g. 755" +msgstr "八进制权限,例如 755" + +msgid "Read-only" +msgstr "只读" + +msgid "Modified" +msgstr "已修改" + +msgid "View" +msgstr "查看" + +msgid "Edit" +msgstr "编辑" + +msgid "Delete" +msgstr "删除" + +msgid "Duplicate" +msgstr "复制副本" + +msgid "New group" +msgstr "新建分组" + +msgid "Edit group" +msgstr "编辑分组" + +msgid "Delete group" +msgstr "删除分组" + +msgid "Group name" +msgstr "分组名称" + +msgid "Move to" +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 "Processes" +msgstr "进程" + +msgid "User" +msgstr "用户" + +msgid "Command" +msgstr "命令" + +msgid "No processes" +msgstr "无进程" + +msgid "Quick" +msgstr "快捷" + +msgid "Quick commands" +msgstr "快捷命令" + +msgid "Type a command, Enter to send" +msgstr "输入命令,回车发送" + +msgid "All sessions" +msgstr "所有会话" + +msgid "Send" +msgstr "发送" + +msgid "No quick commands yet" +msgstr "暂无快捷命令" + +msgid "Group (optional)" +msgstr "分组(可选)" + +msgid "Rename group" +msgstr "重命名分组" + +msgid "leave empty → default" +msgstr "留空 → 默认分组" + +msgid "Manage…" +msgstr "管理…" + +msgid "No history yet" +msgstr "暂无历史" + +msgid "e.g. tail log" +msgstr "例如:查看日志" + +msgid "Port forwarding" +msgstr "端口转发" + +msgid "Advanced (proxy, tunnels)" +msgstr "高级(代理、隧道)" + +msgid "Local (-L)" +msgstr "本地 -L" + +msgid "Remote (-R)" +msgstr "远程 -R" + +msgid "Dynamic (-D)" +msgstr "动态 -D" + +msgid "Listen port" +msgstr "监听端口" + +msgid "Target host" +msgstr "目标主机" + +msgid "Target port" +msgstr "目标端口" + +msgid "Add" +msgstr "添加" + +msgid "-L: local port → target reached from the server" +msgstr "-L:本地端口 → 由服务器侧访问的目标" + +msgid "-R: server port → target reached from here" +msgstr "-R:服务器端口 → 由本机访问的目标" + +msgid "-D: local SOCKS5 proxy" +msgstr "-D:本地 SOCKS5 代理" + +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 "确认" diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 00000000..6f6053e8 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,34 @@ +# Maintainer: jeff141 +# +# Binary package: installs the pre-built release tarball from GitHub, so Arch +# users don't have to compile Rust + Slint themselves. The CI in +# .github/workflows/aur-publish.yml keeps pkgver + checksums in sync on each +# release; you can also `makepkg -si` this file directly. +pkgname=meatshell-bin +pkgver=0.3.6 +pkgrel=1 +pkgdesc="Lightweight FinalShell-style SSH/SFTP/terminal client (Rust + Slint)" +arch=('x86_64' 'aarch64') +url="https://github.com/jeff141/meatshell" +license=('MIT' 'Apache-2.0') +provides=('meatshell') +conflicts=('meatshell') +depends=('fontconfig' 'freetype2' 'libxkbcommon' 'gtk3' 'hicolor-icon-theme') +# The released binary is already stripped (Cargo.toml: strip = "symbols"). +options=('!strip') +source_x86_64=("meatshell-${pkgver}-x86_64.tar.gz::${url}/releases/download/v${pkgver}/meatshell-v${pkgver}-linux-x86_64.tar.gz") +source_aarch64=("meatshell-${pkgver}-aarch64.tar.gz::${url}/releases/download/v${pkgver}/meatshell-v${pkgver}-linux-aarch64.tar.gz") +# `updpkgsums` (run by CI, or by you locally) fills these in. SKIP just disables +# the integrity check for a manual build. +sha256sums_x86_64=('SKIP') +sha256sums_aarch64=('SKIP') + +package() { + # The tarball extracts to meatshell-v-linux-/ ; $CARCH is + # x86_64 / aarch64, matching the archive's name suffix. + local _dir="${srcdir}/meatshell-v${pkgver}-linux-${CARCH}" + install -Dm755 "${_dir}/meatshell" "${pkgdir}/usr/bin/meatshell" + install -Dm644 "${_dir}/meatshell.desktop" "${pkgdir}/usr/share/applications/meatshell.desktop" + install -Dm644 "${_dir}/icon@512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/meatshell.png" + install -Dm644 "${_dir}/README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md" +} diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 00000000..fddc01cd --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,65 @@ +# 发布到 AUR(meatshell-bin) + +仓库里已经备好 `PKGBUILD` 和发布工作流 `.github/workflows/aur-publish.yml`, +走的是 **-bin 二进制包**:直接安装 GitHub Release 里预编译好的 Linux tar.gz, +Arch 用户不用自己编译 Rust + Slint。 + +剩下的几步只能你来做(涉及你的 AUR 账号身份)。配好之后,**以后每次发 Release +就会自动同步到 AUR**。 + +## 一次性配置 + +### 1. 注册 AUR 账号并加 SSH 公钥 +- 在 https://aur.archlinux.org 注册账号。 +- 本地生成一把专用 SSH key(**不要**复用日常 key): + ```sh + ssh-keygen -t ed25519 -f ~/.ssh/aur -C "meatshell-aur" + ``` +- 把 **公钥** `~/.ssh/aur.pub` 的内容贴到 AUR 账号设置的「SSH Public Key」里。 + +### 2. 在 GitHub 仓库加 3 个 Secret +仓库 → Settings → Secrets and variables → Actions → New repository secret: +| 名称 | 值 | +|------|----| +| `AUR_USERNAME` | 你的 AUR 用户名 | +| `AUR_EMAIL` | 你的邮箱 | +| `AUR_SSH_PRIVATE_KEY` | `~/.ssh/aur` **私钥**的完整内容 | + +> 没配 `AUR_SSH_PRIVATE_KEY` 之前,发布工作流会**自动跳过**那一步,不会报错。 + +### 3. 首次手动创建 AUR 包仓库 +AUR 上必须先存在 `meatshell-bin` 这个包,工作流才能 push 更新。第一次手动建: +```sh +git clone ssh://aur@aur.archlinux.org/meatshell-bin.git +cd meatshell-bin +# 把本仓库的 packaging/aur/PKGBUILD 复制进来,按需把 pkgver 改成最新 release 版本 +cp /path/to/meatshell/packaging/aur/PKGBUILD . +# 填邮箱、刷新校验和、生成 .SRCINFO +updpkgsums +makepkg --printsrcinfo > .SRCINFO +# 本地装一下验证能跑 +makepkg -si +# 推到 AUR +git add PKGBUILD .SRCINFO +git commit -m "Initial import: meatshell-bin" +git push +``` + +### 4. 改 PKGBUILD 里的维护者邮箱 +`PKGBUILD` 顶部 `# Maintainer:` 把 `REPLACE_WITH_YOUR_EMAIL` 换成你的邮箱 +(提交到本仓库即可,工作流会用它)。 + +## 之后 + +发布新版本(推 `vX.Y.Z` tag → Release 工作流出包并 publish Release)后, +`aur-publish.yml` 会自动:把 `pkgver` 改成新版本 → `updpkgsums` 刷新校验和 → +生成 `.SRCINFO` → push 到 AUR。 + +也可以在 Actions 里手动触发 `Publish to AUR`(可填指定版本)补发。 + +## 备注 +- 包名用 `meatshell-bin`(二进制包惯例)。若以后想要从源码编译的 `meatshell` + 包,可另加一份从源码 `makepkg` 的 PKGBUILD(需要 Arch 上有 Rust 工具链 + + 那串 Slint 的 GUI 依赖)。 +- ArchLinuxCN 源:在 AUR 稳定后,可以向 archlinuxcn/repo 提 PR 把它纳入二进制源 + (他们会基于 AUR 的 PKGBUILD 打包),那是另外的流程,按其仓库说明走即可。 diff --git a/src/app.rs b/src/app.rs index b6d29837..bfb8c94d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ //! * Route Slint callbacks to the right domain module. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::rc::Rc; use std::sync::{Arc, Mutex}; @@ -22,8 +22,19 @@ struct TermBuffer { parser: vt100::Parser, /// Active find query for this tab ("" = no search). find_query: String, - /// Drag selection (start_row, start_col, end_row, end_col) in grid cells. - sel: Option<(u16, u16, u16, u16)>, + /// 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. @@ -58,25 +69,32 @@ use i_slint_backend_winit::WinitWindowAccessor; use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; use tokio::runtime::Runtime; -use crate::config::{AuthMethod, ConfigStore, Session}; +use crate::config::{AuthMethod, ConfigStore, Secret, Session, SessionKind}; +use crate::i18n::t; use crate::sftp::{spawn_sftp, SftpHandle}; use crate::ssh::{ - format_mtime, format_size, spawn_session, SessionCommand, SessionEvent, SessionHandle, + format_mtime, format_size, spawn_session, ProcInfo, SessionCommand, SessionEvent, + SessionHandle, }; -use crate::system::{format_bytes_per_sec, SystemSampler, SystemSnapshot}; +use crate::system::{format_bytes_per_sec, format_mem, SystemSampler, SystemSnapshot}; 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 last cwd the SFTP panel followed (from OSC 7). Used to ignore the +/// OSC 7 every prompt re-emits at an unchanged directory; manual SFTP +/// navigation REMOVES the entry so the very next OSC 7 — same directory or +/// not — snaps the panel back to the shell's cwd (cd-follow never goes stale). +type SftpLastCwd = 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 + host: String, // "root@192.168.100.2" + session_id: String, // saved-session id, used to reconnect in place (#79) + state: u8, // 0 = connecting, 1 = connected, 2 = disconnected cpu: f32, // 0.0..1.0 mem_used_kib: u64, mem_total_kib: u64, @@ -90,6 +108,8 @@ struct TabStatus { net_hist: Vec, /// Per-filesystem (mount, available_bytes, total_bytes). disks: Vec<(String, u64, u64)>, + /// Top remote processes by CPU, for the process monitor popup (#23). + procs: Vec, } type TabStatuses = Arc>>; /// Last local-machine sample (shown on the welcome tab). @@ -101,6 +121,33 @@ slint::include_modules!(); /// Number of samples kept for the sparkline. const NET_HISTORY_LEN: usize = 60; +/// 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( @@ -109,6 +156,9 @@ pub fn run() -> Result<()> { let store = Rc::new(RefCell::new( ConfigStore::load().context("failed to load config")?, )); + // Reachable from the Slint-thread event handler for recording terminal + // commands into history (#113). + HISTORY_STORE.with(|s| *s.borrow_mut() = Some(store.clone())); // Per-tab SSH handles (shell only; lives on Slint thread via Rc). let handles: Rc>> = @@ -117,8 +167,8 @@ pub fn run() -> Result<()> { // 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 cwd the SFTP panel last followed (see SftpLastCwd). + let sftp_last_cwd: SftpLastCwd = 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). @@ -138,6 +188,280 @@ pub fn run() -> Result<()> { let _ = slint::set_xdg_app_id("meatshell"); let window = AppWindow::new().context("failed to build Slint window")?; + // Show the crate version (from Cargo.toml at compile time) in the sidebar, + // so the footer never drifts out of sync with the actual build. + window.set_app_version(env!("CARGO_PKG_VERSION").into()); + + // 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); + + // The window defaults to frameless + custom title bar (#119). macOS keeps + // its native decorations, so turn the custom bar off there. + #[cfg(target_os = "macos")] + window.set_custom_titlebar(false); + + // --- Detachable process monitor window (#23) ----------------------------- + // The process table is its own top-level OS window so it can be dragged + // outside the main window (or onto a second monitor). Both windows render + // the *same* VecModel, so the table stays live wherever it's parked; closing + // it just hides it, so reopening is instant. + let proc_rows_model: Rc> = Rc::new(VecModel::default()); + window.set_proc_list(ModelRc::from(proc_rows_model.clone())); + let proc_win = ProcWindow::new().context("failed to build process window")?; + proc_win.set_custom_titlebar(cfg!(not(target_os = "macos"))); + proc_win.set_proc_list(ModelRc::from(proc_rows_model.clone())); + { + // ✕ hides the window (data keeps flowing into the shared model). + let weak = proc_win.as_weak(); + proc_win.on_close(move || { + if let Some(w) = weak.upgrade() { + let _ = w.hide(); + } + }); + } + { + // Frameless titlebar drag, via winit on the process window's own handle. + let weak = proc_win.as_weak(); + proc_win.on_win_drag(move || { + if let Some(w) = weak.upgrade() { + w.window().with_winit_window(|ww| { + let _ = ww.drag_window(); + }); + } + }); + } + { + // Bottom-right resize grip. + use i_slint_backend_winit::winit::window::ResizeDirection; + let weak = proc_win.as_weak(); + proc_win.on_win_resize_se(move || { + if let Some(w) = weak.upgrade() { + w.window().with_winit_window(|ww| { + let _ = ww.drag_resize_window(ResizeDirection::SouthEast); + }); + } + }); + } + { + // The sidebar "Processes" button shows / focuses the window. + let win_weak = window.as_weak(); + let proc_weak = proc_win.as_weak(); + window.on_open_processes(move || { + let (Some(main), Some(pw)) = (win_weak.upgrade(), proc_weak.upgrade()) + else { + return; + }; + pw.set_host(main.get_connection_state()); + sync_proc_theme(&main, &pw); + let _ = pw.show(); + pw.window().with_winit_window(|ww| ww.focus_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 the saved terminal font (Interface settings). An empty family keeps + // the built-in default; the size always applies (defaults to 13). + { + let s = store.borrow(); + let fam = s.font_family().to_string(); + if !fam.is_empty() { + window.set_term_font_family(fam.into()); + } + window.set_term_font_size(s.font_size() as f32); + window.set_ui_scale(s.ui_scale() as f32 / 100.0); // global UI zoom (#100) + } + // Editable inputs (e.g. the SFTP path bar) need a CJK-capable font: the + // embedded mono font has no Chinese glyphs and native TextInput doesn't + // glyph-fallback like Text does, so typed Chinese would render as tofu (#54). + // + // We must NOT hard-code one system font name: on macOS 26 (Tahoe) fontdb + // failed to register "PingFang SC", so the UI default font resolved to nothing + // and *all* text vanished (#129) — icons survived only because they use an + // embedded font. Instead probe what fontdb actually loaded and pick the first + // resolvable CJK family, falling back to the embedded "Meatshell Mono" so the + // window is never fully blank even when the system font DB is unreadable. + window.set_ui_font_family(resolve_ui_font_family()); + // Populate the Interface font picker with installed monospace families. + window.set_term_fonts(ModelRc::from(Rc::new(VecModel::from(system_monospace_fonts())))); + + // Command bar (#55): seed quick commands + history from the config. Groups + // start collapsed by default (#55). + window.set_quick_commands(quick_cmd_model( + &store.borrow(), + &all_quick_group_names(&store.borrow()), + )); + window.set_command_history(history_model(&store.borrow())); + window.set_history_view(history_view_model(&store.borrow(), "")); // #101 + + // Interface setting: SFTP follows the terminal's cd. The shell event pumps + // read this AtomicBool on every CwdChanged, so toggling applies live to + // already-open sessions too. + let sftp_follow_cd = Arc::new(std::sync::atomic::AtomicBool::new( + store.borrow().sftp_follow_cd(), + )); + window.set_sftp_follow_cd(store.borrow().sftp_follow_cd()); + { + let store = store.clone(); + let flag = sftp_follow_cd.clone(); + window.on_set_sftp_follow_cd(move |follow| { + flag.store(follow, std::sync::atomic::Ordering::Relaxed); + let mut s = store.borrow_mut(); + s.set_sftp_follow_cd(follow); + let _ = s.save(); + }); + } + + // Interface setting: always ask where to save on download (#87). Read live + // by the download handler from the window property, so just set + persist. + window.set_download_always_ask(store.borrow().download_always_ask()); + { + let store = store.clone(); + window.on_set_download_always_ask(move |ask| { + let mut s = store.borrow_mut(); + s.set_download_always_ask(ask); + let _ = s.save(); + }); + } + + // Interface setting: collapse the sidebars by default (#78). Seed the + // checkboxes, apply the collapsed state once at startup, and persist toggles. + { + let s = store.borrow(); + let collapse_sidebar = s.collapse_sidebar_default(); + let collapse_sftp = s.collapse_sftp_default(); + window.set_collapse_sidebar_default(collapse_sidebar); + window.set_collapse_sftp_default(collapse_sftp); + // Restore the persisted panel docking layout (#dock). + window.set_sidebar_width(s.sidebar_width()); + window.set_sidebar_height(s.sidebar_height()); + window.set_sidebar_dock(s.sidebar_dock().into()); + window.set_sftp_panel_width(s.sftp_panel_width()); + window.set_sftp_panel_height(s.sftp_panel_height()); + window.set_sftp_dock(s.sftp_dock().into()); + if collapse_sidebar { + window.set_sidebar_collapsed(true); + } + if collapse_sftp { + window.set_sftp_collapsed(true); + window.set_sftp_saved_height(s.sftp_panel_height()); + } + // Restore the user's preferred window size, if any (#dock). + let (ww, wh) = s.window_size(); + if ww > 0.0 && wh > 0.0 { + window + .window() + .set_size(slint::LogicalSize::new(ww, wh)); + } + } + { + let store = store.clone(); + window.on_set_collapse_sidebar_default(move |v| { + let mut s = store.borrow_mut(); + s.set_collapse_sidebar_default(v); + let _ = s.save(); + }); + } + { + let store = store.clone(); + window.on_persist_sidebar_width(move |w| { + let mut s = store.borrow_mut(); + s.set_sidebar_width(w); + let _ = s.save(); + }); + } + { + let store = store.clone(); + window.on_set_collapse_sftp_default(move |v| { + let mut s = store.borrow_mut(); + s.set_collapse_sftp_default(v); + let _ = s.save(); + }); + } + + // Session-sync upload setting (#sync). Persisted; only has effect while the + // session-sync toggle is on. Read live from the window in the upload handler. + window.set_sync_upload_enabled(store.borrow().sync_upload()); + { + let store = store.clone(); + window.on_set_sync_upload_enabled(move |v| { + let mut s = store.borrow_mut(); + s.set_sync_upload(v); + let _ = s.save(); + }); + } + + // Interface settings: apply + persist the terminal font family / size. + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_set_term_font(move |family: SharedString| { + { + let mut s = store.borrow_mut(); + s.set_font_family(family.to_string()); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_term_font_family(family); + } + }); + } + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_set_term_font_size(move |size: i32| { + { + let mut s = store.borrow_mut(); + s.set_font_size(size as u32); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_term_font_size(size as f32); + } + }); + } + // Global UI scale (#100): persist the percent and apply it live. + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_set_ui_scale(move |percent: i32| { + let clamped = (percent.max(0) as u32).clamp(80, 200); + { + let mut s = store.borrow_mut(); + s.set_ui_scale(clamped); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_ui_scale(clamped as f32 / 100.0); + } + }); + } + let sessions_model: Rc> = Rc::new(VecModel::default()); window.set_sessions(ModelRc::from(sessions_model.clone())); sync_sessions_to_model(&store.borrow(), &sessions_model); @@ -145,7 +469,7 @@ pub fn run() -> Result<()> { let tabs_model: Rc> = Rc::new(VecModel::default()); tabs_model.push(TabInfo { id: "welcome".into(), - title: "新标签页".into(), + title: t("新标签页", "New tab").into(), kind: "welcome".into(), connected: false, }); @@ -173,10 +497,11 @@ pub fn run() -> Result<()> { runtime.clone(), last_term_size.clone(), sftp_handles.clone(), - sftp_manual_nav.clone(), + sftp_last_cwd.clone(), tab_statuses.clone(), local_snap.clone(), local_net_hist.clone(), + sftp_follow_cd.clone(), ); // Recompute the sidebar whenever the active tab changes (fired from Slint's @@ -193,6 +518,114 @@ pub fn run() -> Result<()> { }); } + // 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(); + let tabs_model = tabs_model.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(); + } + // Re-translate the welcome tab's dynamic title. + for i in 0..tabs_model.row_count() { + if let Some(mut row) = tabs_model.row_data(i) { + if row.id.as_str() == "welcome" { + row.title = t("新标签页", "New tab").into(); + tabs_model.set_row_data(i, row); + } + } + } + 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(); + let proc_weak = proc_win.as_weak(); + 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); + // Mirror the flip onto the detached process window (its Theme global + // is a separate instance) so an open process window follows. + if let Some(p) = proc_weak.upgrade() { + sync_proc_theme(&w, &p); + } + // 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(); + }); + } + + // Host-key confirmation dialog (#109-5): the user trusts or rejects the + // presented server key; the decision fans back out to the blocked SSH/SFTP + // handler(s) and the next queued prompt (if any) is shown. + { + let weak = window.as_weak(); + window.on_hostkey_accept(move || { + if let Some(w) = weak.upgrade() { + resolve_front_hostkey(&w, true); + } + }); + } + { + let weak = window.as_weak(); + window.on_hostkey_reject(move || { + if let Some(w) = weak.upgrade() { + resolve_front_hostkey(&w, false); + } + }); + } + + // Connect-time credential prompt (#110): the user supplies the missing + // username/password (or cancels); the answer unblocks the SSH/SFTP auth. + { + let weak = window.as_weak(); + window.on_cred_accept(move || { + if let Some(w) = weak.upgrade() { + resolve_front_cred(&w, true); + } + }); + } + { + let weak = window.as_weak(); + window.on_cred_reject(move || { + if let Some(w) = weak.upgrade() { + resolve_front_cred(&w, false); + } + }); + } + // NIC selector: remember the user's choice for the active tab and refresh. { let weak = window.as_weak(); @@ -211,6 +644,18 @@ pub fn run() -> Result<()> { } // Settings: preset download directory (load + pick + open). + // Default to the user's Downloads folder so files land somewhere sensible + // without a prompt; only fall back to "ask every time" if we can't locate it + // (#85). Persist it on first run so the setting reflects the real path. + if store.borrow().download_dir().is_empty() { + if let Some(dl) = directories::UserDirs::new() + .and_then(|u| u.download_dir().map(|p| p.to_string_lossy().to_string())) + { + let mut s = store.borrow_mut(); + s.set_download_dir(dl); + let _ = s.save(); + } + } window.set_download_dir(store.borrow().download_dir().to_string().into()); { let weak = window.as_weak(); @@ -248,6 +693,52 @@ pub fn run() -> Result<()> { }); } + // --- In-app update check (#48) ----------------------------------------- + // "Download" on the banner opens the latest-release page in the browser. + window.on_open_update_url(move || { + let url = "https://github.com/jeff141/meatshell/releases/latest"; + #[cfg(windows)] + let _ = std::process::Command::new("explorer").arg(url).spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(url).spawn(); + #[cfg(all(not(windows), not(target_os = "macos")))] + let _ = std::process::Command::new("xdg-open").arg(url).spawn(); + }); + // Query the GitHub releases API on a background thread; if a newer version + // exists, flip the banner on. Best-effort: any network/parse error is + // silently ignored and the app keeps working on the current version. + { + let weak = window.as_weak(); + std::thread::spawn(move || { + let body = match ureq::get( + "https://api.github.com/repos/jeff141/meatshell/releases/latest", + ) + .set("User-Agent", "meatshell-update-check") + .timeout(std::time::Duration::from_secs(8)) + .call() + { + Ok(resp) => resp.into_string().unwrap_or_default(), + Err(_) => return, + }; + let json: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return, + }; + let tag = json["tag_name"].as_str().unwrap_or("").to_string(); + let newer = matches!( + (parse_version(&tag), parse_version(env!("CARGO_PKG_VERSION"))), + (Some(latest), Some(cur)) if latest > cur + ); + if !newer { + return; + } + let _ = weak.upgrade_in_event_loop(move |w| { + w.set_update_version(tag.into()); + w.set_update_available(true); + }); + }); + } + // 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())); @@ -255,28 +746,41 @@ pub fn run() -> Result<()> { let tm = transfers_model.clone(); window.on_clear_transfers(move || tm.set_vec(Vec::::new())); } + { + // Cancel a transfer by id. The id is a UUID unique across sessions, so we + // broadcast to every SFTP handle — only the owning one has it registered + // and will act on it (#100). + let sftp_handles = sftp_handles.clone(); + window.on_cancel_transfer(move |id: SharedString| { + if let Ok(handles) = sftp_handles.lock() { + for h in handles.values() { + h.cancel_transfer(id.to_string()); + } + } + }); + } // Open-source libraries shown in the About popup. { let libs: Vec = [ - "Slint — 图形界面框架 (GUI)", - "russh / russh-keys — SSH 协议实现", - "russh-sftp — SFTP 文件传输", - "ssh-key — SSH 密钥解析", - "tokio — 异步运行时", - "vt100 — 终端 (VT100/xterm) 解析", - "sysinfo — 本机资源采集", - "serde / serde_json — 配置序列化", - "arboard — 系统剪贴板", - "rfd — 原生文件对话框", - "directories — 配置目录定位", - "chrono — 日期时间处理", - "uuid — 唯一标识符", - "anyhow / thiserror — 错误处理", - "tracing / tracing-subscriber — 日志", - "futures / async-trait — 异步辅助", - "rand — 随机数", - "winresource — Windows 图标/资源嵌入", + 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("sysinfo — 本机资源采集", "sysinfo — local resource sampling"), + 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()) @@ -291,10 +795,29 @@ pub fn run() -> Result<()> { handles.clone(), bufs.clone(), sftp_handles.clone(), - sftp_manual_nav.clone(), + sftp_last_cwd.clone(), + ); + wire_sftp_callbacks(&window, sftp_handles.clone(), sftp_last_cwd.clone()); + wire_key_input( + &window, + handles.clone(), + bufs.clone(), + last_term_size.clone(), + store.clone(), + ConnectCtx { + weak: window.as_weak(), + runtime: runtime.clone(), + handles: handles.clone(), + sftp_handles: sftp_handles.clone(), + sftp_last_cwd: sftp_last_cwd.clone(), + bufs: bufs.clone(), + tab_statuses: tab_statuses.clone(), + local_snap: local_snap.clone(), + local_net_hist: local_net_hist.clone(), + last_term_size: last_term_size.clone(), + sftp_follow_cd: sftp_follow_cd.clone(), + }, ); - wire_sftp_callbacks(&window, sftp_handles.clone(), sftp_manual_nav.clone()); - wire_key_input(&window, handles.clone(), bufs.clone(), last_term_size.clone()); // --- System sampler (1 Hz) ------------------------------------------ let sampler = Rc::new(Mutex::new(SystemSampler::new())); @@ -341,15 +864,130 @@ pub fn run() -> Result<()> { use i_slint_backend_winit::EventResult; let weak = window.as_weak(); let sh = sftp_handles.clone(); + let close_handles = handles.clone(); + let ev_store = store.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()); + match event { + WEvent::DroppedFile(path) => { + if let Some(win) = weak.upgrade() { + handle_file_drop(&win, &sh, path.to_string_lossy().to_string()); + } + } + WEvent::Resized(_) => { + // Keep the maximize/restore icon (and resize-edge gating) in + // sync when the OS changes the window state (#119). + if let Some(win) = weak.upgrade() { + let maxed = win + .window() + .with_winit_window(|ww| ww.is_maximized()) + .unwrap_or(false); + win.set_window_maximized(maxed); + } } + WEvent::CloseRequested => { + // Confirm before closing if there are open session tabs (#88), + // so a stray double-click on the title-bar icon / X / Alt+F4 + // doesn't silently drop live sessions. The confirm dialog's + // "Close" calls quit_event_loop to actually exit. + if !close_handles.borrow().is_empty() { + if let Some(win) = weak.upgrade() { + win.set_confirm_close_open(true); + } + return EventResult::PreventDefault; + } + // No sessions → the window is about to close; persist layout. + if let Some(win) = weak.upgrade() { + save_layout(&win, &ev_store); + } + } + _ => {} } EventResult::Propagate }); } + // Confirm-close dialog "Close" → actually quit the event loop (#88). + { + let weak = window.as_weak(); + let cc_store = store.clone(); + window.on_confirm_close_yes(move || { + if let Some(w) = weak.upgrade() { + save_layout(&w, &cc_store); + } + let _ = slint::quit_event_loop(); + }); + } + + // --- Custom title-bar window controls (#119) -------------------------- + { + let weak = window.as_weak(); + window.on_win_minimize(move || { + if let Some(w) = weak.upgrade() { + w.window().with_winit_window(|ww| ww.set_minimized(true)); + } + }); + } + { + let weak = window.as_weak(); + window.on_win_maximize_toggle(move || { + if let Some(w) = weak.upgrade() { + let now = w.window().with_winit_window(|ww| { + let m = !ww.is_maximized(); + ww.set_maximized(m); + m + }); + if let Some(m) = now { + w.set_window_maximized(m); + } + } + }); + } + { + let weak = window.as_weak(); + let close_handles = handles.clone(); + let wc_store = store.clone(); + window.on_win_close(move || { + if let Some(w) = weak.upgrade() { + // Mirror the native-X behaviour: confirm if sessions are open. + if close_handles.borrow().is_empty() { + save_layout(&w, &wc_store); + let _ = slint::quit_event_loop(); + } else { + w.set_confirm_close_open(true); + } + } + }); + } + { + let weak = window.as_weak(); + window.on_win_drag(move || { + if let Some(w) = weak.upgrade() { + w.window().with_winit_window(|ww| { + let _ = ww.drag_window(); + }); + } + }); + } + { + use i_slint_backend_winit::winit::window::ResizeDirection; + let weak = window.as_weak(); + window.on_win_resize(move |dir: i32| { + if let Some(w) = weak.upgrade() { + let d = match dir { + 0 => ResizeDirection::North, + 1 => ResizeDirection::South, + 2 => ResizeDirection::East, + 3 => ResizeDirection::West, + 4 => ResizeDirection::NorthEast, + 5 => ResizeDirection::NorthWest, + 6 => ResizeDirection::SouthEast, + _ => ResizeDirection::SouthWest, + }; + w.window().with_winit_window(|ww| { + let _ = ww.drag_resize_window(d); + }); + } + }); + } // 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). @@ -477,9 +1115,24 @@ fn handle_file_drop(win: &AppWindow, sftp_handles: &SftpHandles, path: String) { if dir.is_empty() { return; } + // Session-sync (#sync): when both toggles are on, also mirror the drop to + // every other online session — each into *its own* current SFTP dir. This + // matches the upload button's behaviour (drag-and-drop is a separate path). + let sync = win.get_sync_input() && win.get_sync_upload_enabled(); + let other_dirs = if sync { terminal_sftp_paths(win) } else { HashMap::new() }; if let Ok(handles) = sftp_handles.lock() { if let Some(h) = handles.get(&active) { - h.upload(path, dir); + h.upload(path.clone(), dir); + } + if sync { + for (id, h) in handles.iter() { + if id == &active { + continue; + } + if let Some(d) = other_dirs.get(id).filter(|d| !d.is_empty()) { + h.upload(path.clone(), d.clone()); + } + } } } } @@ -492,32 +1145,96 @@ fn handle_file_drop(_win: &AppWindow, _sftp_handles: &SftpHandles, _path: String // --------------------------------------------------------------------------- fn sync_sessions_to_model(store: &ConfigStore, model: &VecModel) { - let rows: Vec = store - .sessions() + // Group sessions by their `group` (named groups alphabetically, ungrouped + // last), then by name within each group, and tag the first row of every + // group with a header so the welcome list can render a folder heading (#41). + let sessions = store.sessions(); + + // Ordered list of display groups: + // - "default" only when there are ungrouped sessions (group == "") + // - named groups: explicit folders (incl. empty ones) ∪ sessions' groups, + // de-duplicated, alphabetical. + let has_default = sessions.iter().any(|s| s.group.is_empty()); + let mut named: Vec = store + .groups() .iter() - .map(|s| SessionInfo { - id: s.id.clone().into(), - name: s.name.clone().into(), - host: s.host.clone().into(), - port: s.port as i32, - user: s.user.clone().into(), - auth: s.auth.as_str().into(), - last_used: s - .last_used - .clone() - .unwrap_or_else(|| "never".to_string()) - .into(), - }) + .cloned() + .chain( + sessions + .iter() + .filter(|s| !s.group.is_empty()) + .map(|s| s.group.clone()), + ) .collect(); - model.set_vec(rows); -} + named.sort_by_key(|g| g.to_lowercase()); + named.dedup(); -// --------------------------------------------------------------------------- -// Session callbacks (welcome page + dialog) -// --------------------------------------------------------------------------- + let mut display_groups: Vec = Vec::new(); + if has_default { + display_groups.push("default".to_string()); + } + display_groups.extend(named); + + // Placeholder row for an empty folder; id == "" marks it as a group header + // with no session (used by the UI to gate the "delete group" action). + let blank = |group: &str| SessionInfo { + id: "".into(), + name: "".into(), + host: "".into(), + port: 0, + user: "".into(), + auth: "".into(), + last_used: "".into(), + group: group.into(), + group_header: group.into(), + collapsed: false, + }; -fn wire_session_callbacks( - window: &AppWindow, + let mut rows: Vec = Vec::new(); + for group in &display_groups { + let mut gs: Vec<&Session> = if group == "default" { + sessions.iter().filter(|s| s.group.is_empty()).collect() + } else { + sessions.iter().filter(|s| &s.group == group).collect() + }; + gs.sort_by_key(|s| s.name.to_lowercase()); + + if gs.is_empty() { + rows.push(blank(group)); + } else { + for (i, s) in gs.iter().enumerate() { + rows.push(SessionInfo { + id: s.id.clone().into(), + name: s.name.clone().into(), + host: s.host.clone().into(), + port: s.port as i32, + user: s.user.clone().into(), + auth: s.auth.as_str().into(), + last_used: s + .last_used + .clone() + .unwrap_or_else(|| "never".to_string()) + .into(), + group: group.clone().into(), + group_header: if i == 0 { + group.clone().into() + } else { + "".into() + }, + collapsed: false, + }); + } + } + } + model.set_vec(rows); +} + +// --------------------------------------------------------------------------- +// Session callbacks (welcome page + dialog) +// --------------------------------------------------------------------------- + +fn wire_session_callbacks( + window: &AppWindow, store: Rc>, sessions_model: Rc>, tabs_model: Rc>, @@ -527,46 +1244,195 @@ fn wire_session_callbacks( runtime: Arc, last_term_size: Arc>, sftp_handles: SftpHandles, - sftp_manual_nav: SftpManualNav, + sftp_last_cwd: SftpLastCwd, tab_statuses: TabStatuses, local_snap: LocalSnap, local_net_hist: NetHist, + sftp_follow_cd: Arc, ) { + // Working set of port forwards (#56) for the session being created/edited. + // The forward add/delete callbacks mutate it; saving reads it into + // Session.forwards; opening the dialog (new/edit) resets it. + let edit_forwards: Rc>> = + Rc::new(RefCell::new(Vec::new())); + // New session -> open dialog with blank draft. let weak = window.as_weak(); + let ef_new = edit_forwards.clone(); window.on_new_session_clicked(move || { if let Some(w) = weak.upgrade() { + ef_new.borrow_mut().clear(); + w.set_dialog_forwards(forward_model(&[])); let empty = Session::new_empty(); w.set_dialog_id(empty.id.into()); w.set_dialog_name("".into()); w.set_dialog_host("".into()); w.set_dialog_port("22".into()); - w.set_dialog_user("root".into()); + // No default username (#110): leaving it blank makes the connect-time + // prompt ask for it, Xshell-style. + w.set_dialog_user("".into()); w.set_dialog_auth("password".into()); w.set_dialog_password("".into()); w.set_dialog_key_path("".into()); + w.set_dialog_proxy_type("none".into()); + w.set_dialog_proxy_hostport("".into()); + w.set_dialog_group("".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); } }); + // 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(); + 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(); + 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 }, + auth, + private_key_path: h.identity_file, + ..Session::new_empty() + }); + added += 1; + } + if added > 0 { + let _ = s.save(); + } + } + 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()); + } + }); + } + + // Export all sessions to a portable JSON file (issue #46). Passwords are + // obfuscated with the built-in export key; host/user/port stay plaintext. + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_export_sessions(move || { + if let Some(path) = rfd::FileDialog::new() + .set_file_name("meatshell-connections.json") + .add_filter("JSON", &["json"]) + .save_file() + { + let res = store.borrow().export_to(&path); + if let Some(w) = weak.upgrade() { + let hint = match res { + Ok(n) => format!("{} {}", t("已导出连接", "exported"), n), + Err(e) => format!("{}: {}", t("导出失败", "export failed"), e), + }; + w.set_ssh_import_hint(hint.into()); + } + } + }); + } + + // Import sessions from a portable JSON file (issue #46). + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_import_sessions(move || { + if let Some(path) = rfd::FileDialog::new() + .add_filter("JSON", &["json"]) + .pick_file() + { + let res = store.borrow_mut().import_from(&path); + if let Some(w) = weak.upgrade() { + let hint = match res { + Ok((added, skipped)) => { + sync_sessions_to_model(&store.borrow(), &sessions_model); + format!( + "{} {} / {} {}", + t("已导入", "imported"), + added, + t("跳过重复", "skipped"), + skipped + ) + } + Err(e) => format!("{}: {}", t("导入失败", "import failed"), e), + }; + w.set_ssh_import_hint(hint.into()); + } + } + }); + } + // Edit -> open dialog prefilled. { let weak = window.as_weak(); let store = store.clone(); + let ef_edit = edit_forwards.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; }; + *ef_edit.borrow_mut() = session.forwards.clone(); if let Some(w) = weak.upgrade() { + w.set_dialog_forwards(forward_model(&session.forwards)); w.set_dialog_id(session.id.clone().into()); w.set_dialog_name(session.name.clone().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()); - w.set_dialog_password(session.password.clone().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()); + let (proxy_type, proxy_hostport) = split_proxy(&session.proxy); + w.set_dialog_proxy_type(proxy_type.into()); + w.set_dialog_proxy_hostport(proxy_hostport.into()); + w.set_dialog_group(session.group.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); } @@ -594,26 +1460,207 @@ fn wire_session_callbacks( }); } + // Duplicate a session: clone it with a fresh id and a " (copy)" name (#41). + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_duplicate_session(move |id: SharedString| { + { + let mut s = store.borrow_mut(); + if let Some(orig) = s.get(&id.to_string()).cloned() { + let mut copy = orig; + copy.id = uuid::Uuid::new_v4().to_string(); + copy.name = format!("{} (copy)", copy.name); + copy.last_used = None; + s.upsert(copy); + 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() { + let _ = w.get_sessions(); + } + }); + } + + // Move a session to another group (#41). + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_move_session(move |id: SharedString, group: SharedString| { + { + let mut s = store.borrow_mut(); + if let Some(orig) = s.get(&id.to_string()).cloned() { + let mut moved = orig; + // "default" is the display label for ungrouped → store empty. + moved.group = if group.as_str() == "default" { + String::new() + } else { + group.to_string() + }; + s.upsert(moved); + 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() { + let _ = w.get_sessions(); + } + }); + } + + // Collapse / expand a group in the welcome list (#41). Toggling flips the + // `collapsed` flag on every row of that group in place — no full re-sync — + // so the open/closed state stays put until the list is actually rebuilt. + { + let weak = window.as_weak(); + let sessions_model = sessions_model.clone(); + window.on_toggle_group(move |group: SharedString| { + use slint::Model as _; + let target = group.to_string(); + let n = sessions_model.row_count(); + // New state = the opposite of the group's first row. + let mut new_state = false; + for i in 0..n { + if let Some(row) = sessions_model.row_data(i) { + if row.group.as_str() == target { + new_state = !row.collapsed; + break; + } + } + } + for i in 0..n { + if let Some(mut row) = sessions_model.row_data(i) { + if row.group.as_str() == target { + row.collapsed = new_state; + sessions_model.set_row_data(i, row); + } + } + } + if let Some(w) = weak.upgrade() { + let _ = w.get_sessions(); + } + }); + } + + // Group create / rename (#41). + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_submit_group(move |orig: SharedString, name: SharedString| { + { + let mut s = store.borrow_mut(); + if orig.is_empty() { + s.add_group(name.to_string()); + } else { + s.rename_group(&orig.to_string(), name.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() { + let _ = w.get_sessions(); + } + }); + } + // Group delete (#41) — UI only offers this on empty groups. + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_delete_group(move |name: SharedString| { + { + let mut s = store.borrow_mut(); + s.remove_group(&name.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() { + let _ = w.get_sessions(); + } + }); + } + // Dialog submit -> persist + (optionally) connect. { let weak = window.as_weak(); let store = store.clone(); let sessions_model = sessions_model.clone(); + let edit_forwards = edit_forwards.clone(); window.on_session_dialog_submit(move |draft: SessionDraft| { + let id = draft.id.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 draft.password.is_empty() { + store + .borrow() + .get(&id) + .map(|s| s.password.clone()) + .unwrap_or_default() + } else { + Secret::new(draft.password.to_string()) + }; + let kind = crate::config::SessionKind::from_str(&draft.kind.to_string()); + // Auto-name: serial → port label; otherwise user@host, or just the + // host when no username was given (#110). + let auto_name = match kind { + crate::config::SessionKind::Serial => { + format!("{} @{}", draft.serial_port, draft.baud_rate) + } + _ if draft.user.trim().is_empty() => draft.host.to_string(), + _ => 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: draft.id.to_string(), + id, name: if draft.name.is_empty() { - format!("{}@{}", draft.user, draft.host) + auto_name } else { draft.name.to_string() }, host: draft.host.to_string(), - port: if draft.port <= 0 { 22 } else { draft.port as u16 }, + port: if draft.port <= 0 { + default_port + } else { + draft.port as u16 + }, user: draft.user.to_string(), auth: AuthMethod::from_str(&draft.auth.to_string()), - password: draft.password.to_string(), - private_key_path: draft.private_key_path.to_string(), + password, + // 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, + group: draft.group.to_string(), + 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(), + forwards: edit_forwards.borrow().clone(), }; { let mut s = store.borrow_mut(); @@ -639,6 +1686,78 @@ fn wire_session_callbacks( }); } + // 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()); + } + } + }); + } + + // Add a port forward to the session being edited (#56). + { + let weak = window.as_weak(); + let ef = edit_forwards.clone(); + window.on_add_forward( + move |name: SharedString, + kind: SharedString, + bind_addr: SharedString, + bind_port: i32, + host: SharedString, + host_port: i32| { + let kind = kind.to_string(); + // Local/remote need a target host; dynamic doesn't. + if bind_port <= 0 || bind_port > 65535 { + return; + } + if kind != "dynamic" && (host.trim().is_empty() || host_port <= 0) { + return; + } + ef.borrow_mut().push(crate::config::PortForward { + kind, + name: name.trim().to_string(), + bind_addr: bind_addr.trim().to_string(), + bind_port: bind_port as u16, + host: host.trim().to_string(), + host_port: host_port.max(0) as u16, + }); + if let Some(w) = weak.upgrade() { + w.set_dialog_forwards(forward_model(&ef.borrow())); + } + }, + ); + } + // Delete a port forward by index (#56). + { + let weak = window.as_weak(); + let ef = edit_forwards.clone(); + window.on_delete_forward(move |index: i32| { + let i = index as usize; + { + let mut v = ef.borrow_mut(); + if i < v.len() { + v.remove(i); + } + } + if let Some(w) = weak.upgrade() { + w.set_dialog_forwards(forward_model(&ef.borrow())); + } + }); + } + // Connect session -> open a new terminal tab. { let weak = window.as_weak(); @@ -650,10 +1769,11 @@ fn wire_session_callbacks( 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 sftp_last_cwd = sftp_last_cwd.clone(); let tab_statuses = tab_statuses.clone(); let local_snap = local_snap.clone(); let local_net_hist = local_net_hist.clone(); + let sftp_follow_cd = sftp_follow_cd.clone(); window.on_connect_session(move |id: SharedString| { let id = id.to_string(); let session = match store.borrow().get(&id).cloned() { @@ -663,13 +1783,25 @@ fn wire_session_callbacks( 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; + // 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: format!("{}@{}", session.user, session.host), + host: conn_label.clone(), + session_id: id.clone(), state: 0, ..Default::default() }, @@ -684,11 +1816,13 @@ fn wire_session_callbacks( }); terminals_model.push(TerminalState { id: tab_id.clone().into(), - status: "连接中...".into(), + status: t("连接中...", "Connecting...").into(), spans: ModelRc::from(std::rc::Rc::new(VecModel::::default())), cursor_row: 0, cursor_col: 0, rows_used: 0, + scroll_max: 0, + scroll_offset: 0, is_alt_screen: false, find_matches: ModelRc::from(std::rc::Rc::new(VecModel::::default())), selection: ModelRc::from(std::rc::Rc::new(VecModel::::default())), @@ -696,21 +1830,29 @@ fn wire_session_callbacks( sftp_entries: ModelRc::from( std::rc::Rc::new(VecModel::::default()), ), - sftp_status: "SFTP 连接中...".into(), - sftp_loading: true, + 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()), ), + sftp_selected_count: 0, }); // 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(), - sel: None, + is_dark: is_dark_now, + sel_anchor: None, + sel_focus: None, history: Vec::new(), prev: Vec::new(), view_offset: 0, @@ -718,154 +1860,219 @@ fn wire_session_callbacks( csi_state: CsiState::Normal, }, ); - // Start in cd-auto-follow mode (flag = false → follow cd). - sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), false); + // No followed-cwd yet: the first OSC 7 always triggers a follow. + sftp_last_cwd.lock().unwrap().remove(&tab_id); 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) = spawn_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. - let sftp_evt_tx = { - // We need the sender half of the channel that rx drains from. - // spawn_session doesn't expose it, so we rebuild: spawn_sftp - // gets its own sender that injects events into the same stream - // by accepting a clone of the existing UnboundedSender. - // Actually we pass a *new* sender that was set up alongside rx. - // Re-examine: spawn_session returns (handle, rx) where rx came - // from mpsc::unbounded_channel inside spawn_session; we have no - // access to tx. So create a second channel just for SFTP events - // and merge them in the pump thread below. - let (sftp_tx, sftp_rx) = tokio::sync::mpsc::unbounded_channel::(); - let sftp_handle = spawn_sftp(runtime.handle(), session, sftp_tx); - sftp_handles.lock().unwrap().insert(tab_id.clone(), sftp_handle); - sftp_rx + // Spawn the shell (+ SFTP) workers and their event-pump threads. + // Shared with in-place reconnect (#79) via start_session_in_tab. + let ctx = ConnectCtx { + weak: weak.clone(), + runtime: runtime.clone(), + handles: handles.clone(), + sftp_handles: sftp_handles.clone(), + sftp_last_cwd: sftp_last_cwd.clone(), + bufs: bufs.clone(), + tab_statuses: tab_statuses.clone(), + local_snap: local_snap.clone(), + local_net_hist: local_net_hist.clone(), + last_term_size: last_term_size.clone(), + sftp_follow_cd: sftp_follow_cd.clone(), }; + start_session_in_tab(&tab_id, session, &ctx); + }); + } +} - // --- 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 local_pump = local_snap.clone(); - let net_pump = local_net_hist.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); - } - } - })); - } +type NetHist = Arc>>; + +/// Shared connection dependencies for `start_session_in_tab`. All fields are +/// cheap clones (Arc / Weak / Rc), so connect and in-place reconnect can both +/// build one and spawn workers for a tab (#79). +struct ConnectCtx { + weak: slint::Weak, + runtime: Arc, + handles: Rc>>, + sftp_handles: SftpHandles, + sftp_last_cwd: SftpLastCwd, + bufs: TermBuffers, + tab_statuses: TabStatuses, + local_snap: LocalSnap, + local_net_hist: NetHist, + last_term_size: Arc>, + /// Interface setting: SFTP panel follows the terminal's cd (OSC 7). + sftp_follow_cd: Arc, +} + +/// Spawn the shell (+ SFTP) workers and their event-pump threads for an +/// already-registered tab. Used by the initial connect and by in-place +/// reconnect (#79); the tab/terminal/parser must already exist. +fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { + let has_sftp = session.kind == SessionKind::Ssh; + let (initial_cols, initial_rows) = *ctx.last_term_size.lock().unwrap(); + let (handle, rx) = match session.kind { + SessionKind::Ssh => spawn_session( + ctx.runtime.handle(), + tab_id.to_string(), + session.clone(), + initial_cols, + initial_rows, + ), + SessionKind::Serial => crate::serial::spawn_serial_session( + ctx.runtime.handle(), + tab_id.to_string(), + session.clone(), + ), + SessionKind::Telnet => crate::telnet::spawn_telnet_session( + ctx.runtime.handle(), + tab_id.to_string(), + session.clone(), + initial_cols, + initial_rows, + ), + }; + ctx.handles.borrow_mut().insert(tab_id.to_string(), handle); + + // Separate SFTP connection for the same session (SSH only). + let sftp_evt_tx = if has_sftp { + let (sftp_tx, sftp_rx) = tokio::sync::mpsc::unbounded_channel::(); + let sftp_handle = spawn_sftp(ctx.runtime.handle(), session, sftp_tx); + ctx.sftp_handles + .lock() + .unwrap() + .insert(tab_id.to_string(), sftp_handle); + Some(sftp_rx) + } else { + None + }; + + // --- Shell event pump (dedicated thread) --- + { + let weak_inner = ctx.weak.clone(); + let bufs_thread = ctx.bufs.clone(); + let sftp_handles_pump = ctx.sftp_handles.clone(); + let sftp_last_cwd_pump = ctx.sftp_last_cwd.clone(); + let rt_pump = ctx.runtime.clone(); + let tab_id_pump = tab_id.to_string(); + let statuses_pump = ctx.tab_statuses.clone(); + let local_pump = ctx.local_snap.clone(); + let net_pump = ctx.local_net_hist.clone(); + let follow_cd_pump = ctx.sftp_follow_cd.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 { + // Shared map (not a thread-local) so manual SFTP + // navigation can clear the entry — then the very + // next OSC 7, same directory or not, snaps the + // panel back to the shell's cwd. Unchanged repeats + // (every prompt re-emits OSC 7) are ignored (#59). + let changed = match sftp_last_cwd_pump.lock() { + Ok(mut m) => { + m.insert(tab_id_pump.clone(), cwd.clone()) + .as_deref() + != Some(cwd.as_str()) } - 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 lc_evt = local_pump.clone(); - let nh_evt = net_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, &lc_evt, &nh_evt, - ); - } - }); + Err(_) => false, + }; + // Swallow the event entirely when follow-cd is off: + // forwarding it would set sftp_loading without any + // ListDir to clear it (the #59 stuck-"loading" trap). + if !changed + || !follow_cd_pump + .load(std::sync::atomic::Ordering::Relaxed) + { + continue; } + 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 lc_evt = local_pump.clone(); + let nh_evt = net_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, &lc_evt, &nh_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. - { - let weak_sftp = weak.clone(); - let bufs_sftp = bufs.clone(); - let tab_id_sftp = tab_id.clone(); - let statuses_sftp = tab_statuses.clone(); - let local_sftp = local_snap.clone(); - let net_sftp = local_net_hist.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 lc_s = local_sftp.clone(); - let nh_s = net_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, &lc_s, &nh_s, - ); - } - }); + // --- SFTP event pump (separate thread, SSH only) --- + if let Some(sftp_evt_tx) = sftp_evt_tx { + let weak_sftp = ctx.weak.clone(); + let bufs_sftp = ctx.bufs.clone(); + let tab_id_sftp = tab_id.to_string(); + let statuses_sftp = ctx.tab_statuses.clone(); + let local_sftp = ctx.local_snap.clone(); + let net_sftp = ctx.local_net_hist.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 lc_s = local_sftp.clone(); + let nh_s = net_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, &lc_s, &nh_s, + ); } - } + }); } - }); + } } }); } } -type NetHist = Arc>>; +/// Map of tab-id → the SFTP panel's current path, read from the terminals +/// model. Used as the per-session fallback dir for session-sync uploads. +fn terminal_sftp_paths(w: &AppWindow) -> HashMap { + use slint::Model as _; + let mut out = HashMap::new(); + let model = w.get_terminals(); + if let Some(terminals) = model.as_any().downcast_ref::>() { + for i in 0..terminals.row_count() { + if let Some(row) = terminals.row_data(i) { + out.insert(row.id.to_string(), row.sftp_path.to_string()); + } + } + } + out +} /// Push a value into a fixed-length ring buffer (newest at the end). fn push_ring(buf: &mut Vec, val: f32) { @@ -905,7 +2112,251 @@ fn disk_model(disks: &[(String, u64, u64)]) -> ModelRc { ModelRc::from(Rc::new(VecModel::from(rows))) } -/// Find every (case-insensitive) occurrence of `query` across the currently +/// Build the process-monitor model for the popup (#23). `cpu`/`mem` are +/// pre-formatted to one decimal; `cpu_frac` (0..1) drives the row's load bar. +fn proc_rows(procs: &[ProcInfo]) -> Vec { + procs + .iter() + .map(|p| ProcRow { + pid: p.pid.to_string().into(), + user: p.user.clone().into(), + cpu: format!("{:.1}", p.cpu).into(), + mem: format!("{:.1}", p.mem).into(), + command: p.command.clone().into(), + cpu_frac: (p.cpu / 100.0).clamp(0.0, 1.0), + }) + .collect() +} + +/// Mirror the main window's theme/scale/UI-font onto the detached process +/// window. Theme is a per-window Slint global, so a detached window keeps its +/// compile-time (dark) defaults until we copy these across (#23). +fn sync_proc_theme(main: &AppWindow, proc: &ProcWindow) { + proc.set_dark_mode(main.get_dark_mode()); + proc.set_ui_scale(main.get_ui_scale()); + proc.set_ui_font_family(main.get_ui_font_family()); +} + +/// Persist the current panel docking layout (both panels' edge + size) and the +/// window size, so the next launch restores the user's arrangement. Called on +/// every exit path (#dock). +fn save_layout(win: &AppWindow, store: &Rc>) { + let scale = win.window().scale_factor().max(0.01); + let size = win.window().size(); + let w = size.width as f32 / scale; + let h = size.height as f32 / scale; + let mut s = store.borrow_mut(); + s.set_sidebar_width(win.get_sidebar_width()); + s.set_sidebar_height(win.get_sidebar_height()); + s.set_sidebar_dock(win.get_sidebar_dock().to_string()); + s.set_sftp_panel_width(win.get_sftp_panel_width()); + s.set_sftp_panel_height(win.get_sftp_panel_height()); + s.set_sftp_dock(win.get_sftp_dock().to_string()); + // A maximized size isn't a useful "preferred" size to restore to, so only + // remember the windowed size. + if !win.get_window_maximized() && w > 200.0 && h > 200.0 { + s.set_window_size(w, h); + } + let _ = s.save(); +} + +/// Every quick-command group name (used to start with all groups collapsed, #55): +/// "default" when any ungrouped command exists, plus explicit quick-groups and any +/// group referenced by a command. +fn all_quick_group_names(store: &ConfigStore) -> std::collections::HashSet { + let cmds = store.quick_commands(); + let mut set: std::collections::HashSet = std::collections::HashSet::new(); + if cmds.iter().any(|c| c.group.trim().is_empty()) { + set.insert("default".to_string()); + } + for g in store.quick_groups() { + set.insert(g.clone()); + } + for c in cmds { + let g = c.group.trim(); + if !g.is_empty() { + set.insert(g.to_string()); + } + } + set +} + +/// Build the quick-command model for the command bar + manage dialog (#55). +/// +/// Grouped like the welcome session list: the implicit "default" group (entries +/// with an empty group) comes first, then named groups alphabetically. Within a +/// group, entries keep their saved order. `group_header` is set on the first row +/// of each group; `collapsed` reflects `collapsed_groups` (runtime-only state); +/// `orig_index` points back into the stored vec so deletes target the right entry +/// even though the display order differs. +fn quick_cmd_model( + store: &ConfigStore, + collapsed_groups: &std::collections::HashSet, +) -> ModelRc { + let cmds = store.quick_commands(); + + let has_default = cmds.iter().any(|c| c.group.trim().is_empty()); + // Named groups = explicit quick-groups ∪ groups referenced by commands. + let mut named: Vec = store + .quick_groups() + .iter() + .cloned() + .chain( + cmds.iter() + .map(|c| c.group.trim().to_string()) + .filter(|g| !g.is_empty()), + ) + .collect(); + named.sort_by_key(|g| g.to_lowercase()); + named.dedup(); + + let mut groups: Vec = Vec::new(); + if has_default { + groups.push("default".to_string()); + } + groups.extend(named); + + let mut rows: Vec = Vec::new(); + for group in &groups { + let is_collapsed = collapsed_groups.contains(group); + let members: Vec<(usize, &crate::config::QuickCommand)> = cmds + .iter() + .enumerate() + .filter(|(_, c)| { + let g = c.group.trim(); + if group == "default" { + g.is_empty() + } else { + g == group + } + }) + .collect(); + if members.is_empty() { + // Header-only placeholder for an empty group (orig_index -1) so it can + // still be renamed / deleted, matching empty session folders. + rows.push(QuickCmd { + name: "".into(), + command: "".into(), + group: group.clone().into(), + group_header: group.clone().into(), + collapsed: is_collapsed, + orig_index: -1, + }); + } else { + for (i, (orig_idx, c)) in members.iter().enumerate() { + rows.push(QuickCmd { + name: c.name.clone().into(), + command: c.command.clone().into(), + group: group.clone().into(), + group_header: if i == 0 { group.clone().into() } else { "".into() }, + collapsed: is_collapsed, + orig_index: *orig_idx as i32, + }); + } + } + } + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +/// Build the port-forward list model for the session dialog (#56). Each row is +/// a one-line human summary (`-L 127.0.0.1:8080 → host:80`). +fn forward_model(forwards: &[crate::config::PortForward]) -> ModelRc { + let rows: Vec = forwards + .iter() + .map(|f| { + let bind = if f.bind_addr.trim().is_empty() { + "127.0.0.1" + } else { + f.bind_addr.trim() + }; + let summary = match f.kind.as_str() { + "local" => format!("-L {}:{} → {}:{}", bind, f.bind_port, f.host, f.host_port), + "remote" => format!("-R {}:{} → {}:{}", bind, f.bind_port, f.host, f.host_port), + "dynamic" => format!("-D {}:{} (SOCKS5)", bind, f.bind_port), + _ => String::new(), + }; + PortFwd { + kind: f.kind.clone().into(), + name: f.name.clone().into(), + summary: summary.into(), + } + }) + .collect(); + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +/// Collect the full paths of the checked SFTP entries for a tab (#100). +fn collect_sftp_selected(terminals: &VecModel, tab_id: &str) -> Vec { + let mut paths = Vec::new(); + for ti in 0..terminals.row_count() { + let Some(row) = terminals.row_data(ti) else { continue }; + if row.id.as_str() != tab_id { + continue; + } + if let Some(em) = row.sftp_entries.as_any().downcast_ref::>() { + for ei in 0..em.row_count() { + if let Some(e) = em.row_data(ei) { + if e.selected { + paths.push(e.full_path.to_string()); + } + } + } + } + break; + } + paths +} + +/// Uncheck every SFTP entry for a tab and reset its selected-count (#100). +fn clear_sftp_selection(terminals: &VecModel, tab_id: &str) { + for ti in 0..terminals.row_count() { + let Some(row) = terminals.row_data(ti) else { continue }; + if row.id.as_str() != tab_id { + continue; + } + if let Some(em) = row.sftp_entries.as_any().downcast_ref::>() { + for ei in 0..em.row_count() { + if let Some(mut e) = em.row_data(ei) { + if e.selected { + e.selected = false; + em.set_row_data(ei, e); + } + } + } + } + let mut r = row.clone(); + r.sftp_selected_count = 0; + terminals.set_row_data(ti, r); + break; + } +} + +/// Build the command-history model in storage order (oldest first, newest +/// last). The dropdown shows the most-recently-used command at the bottom +/// (nearest the input) and ↑ recalls it first (#55, #113). +fn history_model(store: &ConfigStore) -> ModelRc { + let rows: Vec = store + .command_history() + .iter() + .map(|s| s.clone().into()) + .collect(); + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +/// Build the filtered history-view model for the dropdown: case-insensitive +/// substring matches of `query`, in the same order as the full history (#101). +fn history_view_model(store: &ConfigStore, query: &str) -> ModelRc { + let q = query.trim().to_lowercase(); + let rows: Vec = store + .command_history() + .iter() + .filter(|c| q.is_empty() || c.to_lowercase().contains(&q)) + .map(|s| s.clone().into()) + .collect(); + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +/// 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(); @@ -935,66 +2386,6 @@ fn compute_find_matches(rows: &[String], query: &str) -> Vec { out } -/// Order a selection so start ≤ end (by row, then column). -fn norm_sel(sr: u16, sc: u16, er: u16, ec: u16) -> (u16, u16, u16, u16) { - if (sr, sc) <= (er, ec) { - (sr, sc, er, ec) - } else { - (er, ec, sr, sc) - } -} - -/// Highlight rectangles for a linear (line-wrapping) selection. -fn selection_rects(sr: u16, sc: u16, er: u16, ec: u16, cols: u16) -> Vec { - let (sr, sc, er, ec) = norm_sel(sr, sc, er, ec); - let mut out = Vec::new(); - if sr == er { - let lo = sc.min(ec); - let hi = sc.max(ec); - out.push(TermMatch { row: sr as i32, col: lo as i32, len: (hi - lo + 1) as i32 }); - } else { - out.push(TermMatch { row: sr as i32, col: sc as i32, len: (cols - sc) as i32 }); - for r in (sr + 1)..er { - out.push(TermMatch { row: r as i32, col: 0, len: cols as i32 }); - } - out.push(TermMatch { row: er as i32, col: 0, len: (ec + 1) as i32 }); - } - out -} - -/// Extract the selected text from the displayed rows (trailing spaces trimmed). -fn extract_selection(rows: &[String], sr: u16, sc: u16, er: u16, ec: u16) -> String { - let (sr, sc, er, ec) = norm_sel(sr, sc, er, ec); - let mut out = String::new(); - for r in sr..=er { - let chars: Vec = rows - .get(r as usize) - .map(|l| l.chars().collect()) - .unwrap_or_default(); - let (lo, hi) = if sr == er { - (sc.min(ec), sc.max(ec)) - } else if r == sr { - (sc, u16::MAX) - } else if r == er { - (0, ec) - } else { - (0, u16::MAX) - }; - let lo = (lo as usize).min(chars.len()); - let hi = ((hi as usize).saturating_add(1)).min(chars.len()); // exclusive - let seg: String = if lo < hi { - chars[lo..hi].iter().collect() - } else { - String::new() - }; - out.push_str(seg.trim_end()); - if r != er { - out.push('\n'); - } - } - 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). @@ -1005,10 +2396,7 @@ fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { 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 = match buf.sel { - Some((sr, sc, er, ec)) => selection_rects(sr, sc, er, ec, cols), - None => Vec::new(), - }; + let sel = buf.selection_rects_visible(cols); (b, matches, sel) }; let (b, matches, sel) = data; @@ -1016,6 +2404,7 @@ fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { 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); + let (smax, soff) = (b.scroll_max, b.scroll_offset); set_terminal_row(win, tab_id, move |row| { row.spans = spans.clone(); row.cursor_row = cr; @@ -1024,6 +2413,8 @@ fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { row.is_alt_screen = alt; row.find_matches = fm.clone(); row.selection = sm.clone(); + row.scroll_max = smax; + row.scroll_offset = soff; }); } @@ -1074,12 +2465,12 @@ fn refresh_sidebar( win.set_disks(disk_model(&snap.disks)); }; let show_local_res = |win: &AppWindow| { - win.set_resource_title("本机资源".into()); + win.set_resource_title(t("本机资源", "Local resources").into()); win.set_cpu_percent(snap.cpu_percent); win.set_mem_percent(snap.mem_percent); win.set_swap_percent(snap.swap_percent); - win.set_mem_detail(format!("{}/{}M", snap.mem_used_mib, snap.mem_total_mib).into()); - win.set_swap_detail(format!("{}/{}M", snap.swap_used_mib, snap.swap_total_mib).into()); + win.set_mem_detail(format_mem(snap.mem_used_mib, snap.mem_total_mib).into()); + win.set_swap_detail(format_mem(snap.swap_used_mib, snap.swap_total_mib).into()); }; let clear_stats = |win: &AppWindow| { win.set_cpu_percent(0.0); @@ -1089,6 +2480,23 @@ fn refresh_sidebar( win.set_swap_detail("".into()); }; + // Process monitor (#23) lives in a shared model (the AppWindow and the + // detachable ProcWindow point at the same VecModel), so mutate it in place + // instead of replacing it — replacing would break the sharing. Only a live + // remote session has process data; default to empty and let the connected + // branch below fill it in. + let set_procs = |win: &AppWindow, procs: &[ProcInfo]| { + if let Some(vm) = win + .get_proc_list() + .as_any() + .downcast_ref::>() + { + vm.set_vec(proc_rows(procs)); + } + }; + win.set_proc_available(false); + set_procs(win, &[]); + let active = win.get_active_tab_id().to_string(); let status = if active == "welcome" { None @@ -1101,15 +2509,15 @@ fn refresh_sidebar( Some(st) if st.state == 1 => { win.set_conn_state(1); win.set_connection_state(st.host.clone().into()); - win.set_resource_title("服务器资源".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_mem_detail( - format!("{}/{}M", st.mem_used_kib / 1024, st.mem_total_kib / 1024).into(), + format_mem(st.mem_used_kib / 1024, st.mem_total_kib / 1024).into(), ); win.set_swap_detail( - format!("{}/{}M", st.swap_used_kib / 1024, st.swap_total_kib / 1024).into(), + format_mem(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()); @@ -1121,27 +2529,29 @@ fn refresh_sidebar( 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_proc_available(true); + set_procs(win, &st.procs); } // Disconnected / timed-out session. Some(st) if st.state == 2 => { win.set_conn_state(2); - win.set_connection_state(format!("{} 已断开", st.host).into()); - win.set_resource_title("服务器资源".into()); + win.set_connection_state(format!("{} {}", st.host, t("已断开", "disconnected")).into()); + win.set_resource_title(t("服务器资源", "Server resources").into()); clear_stats(win); set_top_local(win); } // Still connecting. Some(st) => { win.set_conn_state(0); - win.set_connection_state(format!("连接中 {}", st.host).into()); - win.set_resource_title("服务器资源".into()); + win.set_connection_state(format!("{} {}", t("连接中", "Connecting"), st.host).into()); + win.set_resource_title(t("服务器资源", "Server resources").into()); clear_stats(win); set_top_local(win); } // Welcome tab (or unknown) → local machine top + bottom. None => { win.set_conn_state(0); - win.set_connection_state("未连接".into()); + win.set_connection_state(t("未连接", "Not connected").into()); show_local_res(win); set_top_local(win); } @@ -1213,10 +2623,7 @@ fn apply_session_event_to_window( 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 = match buf.sel { - Some((sr, sc, er, ec)) => selection_rects(sr, sc, er, ec, cols), - None => Vec::new(), - }; + let sel = buf.selection_rects_visible(cols); Some((b, matches, sel)) } else { None @@ -1231,6 +2638,7 @@ fn apply_session_event_to_window( 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); + let (smax, soff) = (b.scroll_max, b.scroll_offset); update_terminal(&|t| { t.spans = spans_model.clone(); t.cursor_row = cur_row; @@ -1239,12 +2647,14 @@ fn apply_session_event_to_window( t.is_alt_screen = is_alt; t.find_matches = matches_model.clone(); t.selection = sel_model.clone(); + t.scroll_max = smax; + t.scroll_offset = soff; }); } } SessionEvent::Connected => { update_tab(&|t| t.connected = true); - update_terminal(&|t| t.status = "已连接".into()); + update_terminal(&|t| t.status = crate::i18n::t("已连接", "Connected").into()); if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) { st.state = 1; } @@ -1253,8 +2663,25 @@ fn apply_session_event_to_window( } } SessionEvent::Closed(reason) => { + // Print the hint into the terminal itself (FinalShell-style), via a + // synthetic Output event so it reuses the normal render path (#79). + apply_session_event_to_window( + win, + tab_id, + SessionEvent::Output(format!( + "\r\n\x1b[31m{}\x1b[0m\r\n", + crate::i18n::t( + "连接已断开,按 Enter 重新连接", + "Disconnected — press Enter to reconnect" + ) + )), + bufs, + statuses, + local, + local_net_hist, + ); update_tab(&|t| t.connected = false); - update_terminal(&|t| t.status = format!("已断开 — {reason}").into()); + 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; } @@ -1270,6 +2697,7 @@ fn apply_session_event_to_window( swap_total_kib, net, disks, + procs, } => { if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) { st.cpu = cpu_percent; @@ -1279,6 +2707,7 @@ fn apply_session_event_to_window( st.swap_total_kib = swap_total_kib; st.net = net; st.disks = disks; + st.procs = procs; // A sample means the channel is alive → treat as connected. if st.state != 1 { st.state = 1; @@ -1314,6 +2743,8 @@ fn apply_session_event_to_window( format_size(e.size).into() }, modified: format_mtime(e.modified).into(), + mode: (e.mode & 0o7777) as i32, + selected: false, }) .collect(); let model = ModelRc::from( @@ -1328,6 +2759,51 @@ fn apply_session_event_to_window( SessionEvent::SftpStatus(msg) => { update_terminal(&|t| t.sftp_status = msg.clone().into()); } + SessionEvent::SftpError(msg) => { + // Show the reason and stop the spinner; leave the current listing in + // place so a failed navigation doesn't blank the panel (#112). + update_terminal(&|t| { + t.sftp_status = msg.clone().into(); + t.sftp_loading = false; + }); + } + SessionEvent::SftpFileText { + path, + name, + content, + edit, + error, + } => { + if error.is_empty() { + // Open the built-in viewer/editor (#70). + win.set_editor_line_numbers(line_numbers_for(&content).into()); + win.set_editor_path(path.into()); + win.set_editor_name(name.into()); + win.set_editor_content(content.into()); + win.set_editor_readonly(!edit); + win.set_editor_dirty(false); + win.set_editor_open(true); + } else { + // Couldn't open as text. The SFTP status line alone is easy to + // miss (looks like "nothing happened"), so also print the reason + // into the terminal via a synthetic Output event (#70). + apply_session_event_to_window( + win, + tab_id, + SessionEvent::Output(format!( + "\r\n[meatshell] {} {}: {}\r\n", + crate::i18n::t("无法打开", "Cannot open"), + name, + error + )), + bufs, + statuses, + local, + local_net_hist, + ); + update_terminal(&|t| t.sftp_status = error.clone().into()); + } + } SessionEvent::SftpTreeUpdate(nodes) => { let slint_nodes: Vec = nodes .iter() @@ -1349,11 +2825,16 @@ fn apply_session_event_to_window( transferred, total, state, - msg: _, + msg, } => { let detail = match state { - 2 => "失败".to_string(), - 1 => "已完成".to_string(), + // On error, show the actual message when we have one. + 2 => if msg.is_empty() { t("失败", "Failed").to_string() } else { msg }, + 1 => t("已完成", "Done").to_string(), + // Remote-side prep (e.g. tar packing) before bytes start flowing (#100). + 3 => t("文件准备中", "Preparing...").to_string(), + // User-cancelled transfer (#100). + 4 => t("已取消", "Cancelled").to_string(), _ => { if total > 0 { format!("{}/{}", format_size(transferred), format_size(total)) @@ -1397,9 +2878,329 @@ fn apply_session_event_to_window( } } } + SessionEvent::HostKeyPrompt { + host, + port, + key_type, + fingerprint, + changed, + responder, + } => { + enqueue_hostkey_prompt(win, host, port, key_type, fingerprint, changed, responder); + } + SessionEvent::CredentialPrompt { + session_id, + host, + user, + need_user, + need_password, + responder, + } => { + enqueue_cred_prompt(win, session_id, host, user, need_user, need_password, responder); + } + SessionEvent::CommandRan(cmd) => { + // A command typed directly in the terminal, captured via the shell + // hook (#113). Record it in the same command-box history, reusing the + // de-dup/move-to-end logic, and refresh the model. + HISTORY_STORE.with(|s| { + if let Some(store) = s.borrow().as_ref() { + { + let mut st = store.borrow_mut(); + st.push_command_history(cmd); + let _ = st.save(); + } + win.set_command_history(history_model(&store.borrow())); + } + }); + } } } +thread_local! { + /// The config store, made reachable from the Slint-thread event handler so + /// terminal-captured commands (#113) can be appended to history. Set once at + /// startup; only touched on the Slint event-loop thread. + static HISTORY_STORE: RefCell>>> = const { RefCell::new(None) }; +} + +// --------------------------------------------------------------------------- +// Host-key confirmation (#109-5) +// --------------------------------------------------------------------------- + +/// One queued host-key prompt. Multiple connections to the *same* host:port +/// (e.g. the shell and its SFTP channel racing on first connect) collapse into +/// a single dialog whose answer fans out to every waiting `responder`. +struct PendingHostKey { + host: String, + port: u16, + changed: bool, + title: String, + message: String, + detail: String, + confirm_label: String, + responders: Vec, +} + +thread_local! { + /// Prompts awaiting a decision; the front one is shown. Lives on the Slint + /// event-loop thread (all access is from there). + static HOSTKEY_QUEUE: RefCell> = RefCell::new(VecDeque::new()); + /// host:port → decision, remembered for this run so a duplicate prompt + /// (second connection to the same host) is answered without a new dialog. + static HOSTKEY_DECIDED: RefCell> = RefCell::new(HashMap::new()); +} + +/// Localized title / message / detail / confirm-label for the host-key dialog. +fn hostkey_dialog_text( + host: &str, + port: u16, + key_type: &str, + fingerprint: &str, + changed: bool, +) -> (String, String, String, String) { + let detail = format!("{host}:{port} ({key_type})\n{fingerprint}"); + if changed { + ( + crate::i18n::t("⚠ 主机密钥已改变", "⚠ Host key changed").to_string(), + crate::i18n::t( + "该主机的密钥与之前记录的不一致,可能存在中间人攻击。仅当你确知服务器密钥已更换时才继续。", + "This host's key differs from the one stored earlier — this could be a man-in-the-middle attack. Only continue if you know the server's key really changed.", + ) + .to_string(), + detail, + crate::i18n::t("仍然信任", "Trust anyway").to_string(), + ) + } else { + ( + crate::i18n::t("未知主机", "Unknown host").to_string(), + crate::i18n::t( + "首次连接该主机。请核对下面的密钥指纹,确认无误后再信任并连接。", + "First time connecting to this host. Verify the key fingerprint below before you trust and connect.", + ) + .to_string(), + detail, + crate::i18n::t("信任并连接", "Trust & connect").to_string(), + ) + } +} + +/// Queue a host-key prompt: answer immediately if already decided this run, +/// merge into an existing pending entry for the same host, otherwise enqueue +/// (and show it now if nothing else is up). +fn enqueue_hostkey_prompt( + win: &AppWindow, + host: String, + port: u16, + key_type: String, + fingerprint: String, + changed: bool, + responder: crate::ssh::HostKeyResponder, +) { + let id = format!("{host}:{port}"); + if let Some(ans) = HOSTKEY_DECIDED.with(|d| d.borrow().get(&id).copied()) { + responder.respond(ans); + return; + } + let show_now = HOSTKEY_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if let Some(p) = q.iter_mut().find(|p| p.host == host && p.port == port) { + p.responders.push(responder); + return false; + } + let was_empty = q.is_empty(); + let (title, message, detail, confirm_label) = + hostkey_dialog_text(&host, port, &key_type, &fingerprint, changed); + q.push_back(PendingHostKey { + host, + port, + changed, + title, + message, + detail, + confirm_label, + responders: vec![responder], + }); + was_empty + }); + if show_now { + show_front_hostkey(win); + } +} + +/// Push the front pending prompt's details into the window and open the dialog. +fn show_front_hostkey(win: &AppWindow) { + HOSTKEY_QUEUE.with(|q| { + if let Some(p) = q.borrow().front() { + win.set_hostkey_changed(p.changed); + win.set_hostkey_title(p.title.clone().into()); + win.set_hostkey_message(p.message.clone().into()); + win.set_hostkey_detail(p.detail.clone().into()); + win.set_hostkey_confirm_label(p.confirm_label.clone().into()); + win.set_hostkey_prompt_open(true); + } + }); +} + +/// Apply the user's decision to the front prompt, then show the next one (or +/// close the dialog if the queue is now empty). +fn resolve_front_hostkey(win: &AppWindow, accept: bool) { + let has_next = HOSTKEY_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if let Some(p) = q.pop_front() { + HOSTKEY_DECIDED.with(|d| { + d.borrow_mut().insert(format!("{}:{}", p.host, p.port), accept); + }); + for r in &p.responders { + r.respond(accept); + } + } + !q.is_empty() + }); + if has_next { + show_front_hostkey(win); + } else { + win.set_hostkey_prompt_open(false); + } +} + +// --------------------------------------------------------------------------- +// Connect-time credential prompt (#110) +// --------------------------------------------------------------------------- + +/// One queued credential prompt. Connections to the same session (shell + its +/// SFTP channel) collapse into a single dialog whose answer fans out to each +/// waiting responder. +struct PendingCred { + session_id: String, + host: String, + user: String, + need_user: bool, + need_password: bool, + responders: Vec, +} + +thread_local! { + static CRED_QUEUE: RefCell> = RefCell::new(VecDeque::new()); + /// session id → the answer given this run (`None` = cancelled), so a second + /// connection for the same session is answered without re-prompting. + static CRED_DECIDED: RefCell>> = + RefCell::new(HashMap::new()); +} + +/// Queue a credential prompt: answer immediately if already decided this run, +/// merge into an existing pending entry for the same session, otherwise enqueue +/// (and show it now if nothing else is up). +fn enqueue_cred_prompt( + win: &AppWindow, + session_id: String, + host: String, + user: String, + need_user: bool, + need_password: bool, + responder: crate::ssh::CredentialResponder, +) { + if let Some(reply) = CRED_DECIDED.with(|d| d.borrow().get(&session_id).cloned()) { + responder.respond(reply); + return; + } + let show_now = CRED_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if let Some(p) = q.iter_mut().find(|p| p.session_id == session_id) { + p.responders.push(responder); + return false; + } + let was_empty = q.is_empty(); + q.push_back(PendingCred { + session_id, + host, + user, + need_user, + need_password, + responders: vec![responder], + }); + was_empty + }); + if show_now { + show_front_cred(win); + } +} + +/// Populate the credential dialog from the front prompt and open it. +fn show_front_cred(win: &AppWindow) { + CRED_QUEUE.with(|q| { + if let Some(p) = q.borrow().front() { + win.set_cred_host(p.host.clone().into()); + win.set_cred_need_user(p.need_user); + win.set_cred_need_password(p.need_password); + win.set_cred_user(p.user.clone().into()); + win.set_cred_password("".into()); + win.set_cred_remember(false); + win.set_cred_prompt_open(true); + } + }); +} + +/// Apply the user's answer to the front credential prompt (or cancel), persist +/// it when "remember" is checked, then show the next prompt or close. +fn resolve_front_cred(win: &AppWindow, accept: bool) { + let reply: Option = if accept { + Some(( + win.get_cred_user().to_string(), + win.get_cred_password().to_string(), + win.get_cred_remember(), + )) + } else { + None + }; + let has_next = CRED_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if let Some(p) = q.pop_front() { + CRED_DECIDED.with(|d| { + d.borrow_mut().insert(p.session_id.clone(), reply.clone()); + }); + if let Some((ref u, ref pw, true)) = reply { + persist_credentials(&p.session_id, u, pw, p.need_user, p.need_password); + } + for r in &p.responders { + r.respond(reply.clone()); + } + } + !q.is_empty() + }); + // Don't leave the typed password lingering in the UI property. + win.set_cred_password("".into()); + if has_next { + show_front_cred(win); + } else { + win.set_cred_prompt_open(false); + } +} + +/// Persist newly-entered credentials onto the saved session (#110, "remember"). +fn persist_credentials( + session_id: &str, + user: &str, + password: &str, + set_user: bool, + set_password: bool, +) { + HISTORY_STORE.with(|s| { + if let Some(store) = s.borrow().as_ref() { + let mut st = store.borrow_mut(); + if let Some(mut sess) = st.get(session_id).cloned() { + if set_user && !user.trim().is_empty() { + sess.user = user.trim().to_string(); + } + if set_password { + sess.password = crate::config::Secret::new(password.to_string()); + } + st.upsert(sess); + let _ = st.save(); + } + } + }); +} + // --------------------------------------------------------------------------- // Tab callbacks // --------------------------------------------------------------------------- @@ -1411,7 +3212,7 @@ fn wire_tab_callbacks( handles: Rc>>, bufs: TermBuffers, sftp_handles: SftpHandles, - sftp_manual_nav: SftpManualNav, + sftp_last_cwd: SftpLastCwd, ) { // Selecting a tab is already applied inside the Slint callback; we just // need to keep the C++/Rust state in sync if needed. @@ -1428,7 +3229,7 @@ fn wire_tab_callbacks( let handles = handles.clone(); let bufs = bufs.clone(); let sftp_handles = sftp_handles.clone(); - let sftp_manual_nav = sftp_manual_nav.clone(); + let sftp_last_cwd = sftp_last_cwd.clone(); window.on_tab_closed(move |id: SharedString| { let id = id.to_string(); if id == "welcome" { @@ -1440,7 +3241,7 @@ fn wire_tab_callbacks( if let Some(sftp) = sftp_handles.lock().unwrap().remove(&id) { sftp.close(); } - sftp_manual_nav.lock().unwrap().remove(&id); + sftp_last_cwd.lock().unwrap().remove(&id); bufs.lock().unwrap().remove(&id); // Remove from tabs + terminals models. @@ -1499,16 +3300,18 @@ fn wire_tab_callbacks( fn wire_sftp_callbacks( window: &AppWindow, sftp_handles: SftpHandles, - sftp_manual_nav: SftpManualNav, + sftp_last_cwd: SftpLastCwd, ) { // 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 sftp_last_cwd = sftp_last_cwd.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() == ".." { + // A pasted path may carry trailing whitespace / newline (#54). + let path = path.trim(); + let resolved = if path == ".." { let current = weak.upgrade().and_then(|w| { let terminals_rc = w.get_terminals(); let terminals = terminals_rc @@ -1527,8 +3330,10 @@ fn wire_sftp_callbacks( } else { path.to_string() }; - // Any manual navigation stops cd auto-follow. - sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), true); + // Forget the followed cwd so the next OSC 7 — even at an unchanged + // directory — snaps the panel back to the shell's cwd; manual + // navigation never permanently disables cd-follow. + sftp_last_cwd.lock().unwrap().remove(&tab_id); if let Ok(handles) = sftp_handles.lock() { if let Some(h) = handles.get(&tab_id) { h.list_dir(resolved); @@ -1545,27 +3350,80 @@ fn wire_sftp_callbacks( 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 + // If the user has checked 2+ entries, ANY download (right-click, + // row button or the toolbar) packs the whole checked set into one + // archive (#100) — this matches "download these together". A single + // checked item (or none) downloads the clicked file as-is. + let (arc_dir, arc_names) = weak + .upgrade() + .and_then(|w| { + let terminals = w.get_terminals(); + let tm = terminals + .as_any() + .downcast_ref::>()?; + let paths = collect_sftp_selected(tm, &tab_id); + if paths.len() >= 2 { + let dir = active_sftp_path(&w, &tab_id); + let names: Vec = paths + .iter() + .map(|p| { + p.trim_end_matches('/') + .rsplit(['/', '\\']) + .next() + .unwrap_or(p) + .to_string() + }) + .collect(); + clear_sftp_selection(tm, &tab_id); + Some((dir, names)) + } else { + None + } + }) + .map(|(d, n)| (Some(d), n)) + .unwrap_or((None, Vec::new())); + // "Always ask" (#87) forces the folder picker, ignoring the preset. + let (preset, always_ask) = weak .upgrade() - .map(|w| w.get_download_dir().to_string()) + .map(|w| { + ( + w.get_download_dir().to_string(), + w.get_download_always_ask(), + ) + }) .unwrap_or_default(); - if !preset.is_empty() { + if !always_ask && !preset.is_empty() { if let Ok(handles) = sftp_handles.lock() { if let Some(h) = handles.get(&tab_id) { - h.download(remote_path, preset); + if let Some(ref dir) = arc_dir { + h.download_archive(dir.clone(), arc_names.clone(), preset); + } else { + h.download(remote_path, preset); + } + // Pop the transfers panel so progress is visible (user + // request: any download opens the download popup). + if let Some(w) = weak.upgrade() { + w.set_download_open(true); + } } } return; } let sftp_handles = sftp_handles.clone(); + let weak = weak.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); + if let Some(ref rdir) = arc_dir { + h.download_archive(rdir.clone(), arc_names.clone(), local_dir); + } else { + h.download(remote_path, local_dir); + } } } + let _ = weak.upgrade_in_event_loop(|w| w.set_download_open(true)); } }); }); @@ -1574,17 +3432,68 @@ fn wire_sftp_callbacks( // Upload a local file into the current remote directory. { let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); window.on_sftp_upload_clicked( - move |tab_id: SharedString, remote_dir: SharedString| { + move |tab_id: SharedString, remote_dir: SharedString, folder: bool| { let tab_id = tab_id.to_string(); let remote_dir = remote_dir.to_string(); let sftp_handles = sftp_handles.clone(); + // Session-sync upload (#sync): when both the sync toggle and the + // "sync upload" setting are on, mirror the upload to every other + // online session — each into *that session's own* current SFTP + // directory (paths differ between sessions, e.g. /home/jeff vs + // /home/root, so the active session's path can't be reused). + // Gather targets on the UI thread (Slint models aren't Send). + let sync_targets: Vec<(String, String)> = weak + .upgrade() + .filter(|w| w.get_sync_input() && w.get_sync_upload_enabled()) + .map(|w| { + let paths = terminal_sftp_paths(&w); + let handles = sftp_handles.lock().ok(); + handles + .iter() + .flat_map(|h| h.keys()) + .filter(|id| *id != &tab_id) + .filter_map(|id| paths.get(id).map(|dir| (id.clone(), dir.clone()))) + .filter(|(_, dir)| !dir.is_empty()) + .collect() + }) + .unwrap_or_default(); 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); + // The remote SFTP upload handles a file or a whole directory; + // only the local picker differs (#85). Folder uploads one dir; + // file mode allows selecting several at once. + let locals: Vec = if folder { + rfd::FileDialog::new() + .pick_folder() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + } else { + rfd::FileDialog::new() + .pick_files() + .map(|v| { + v.into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default() + }; + if locals.is_empty() { + return; + } + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + for local in &locals { + h.upload(local.clone(), remote_dir.clone()); + } + } + // Mirror to the other online sessions, each into its own + // current SFTP directory. + for (id, dir) in &sync_targets { + if let Some(h) = handles.get(id) { + for local in &locals { + h.upload(local.clone(), dir.clone()); + } } } } @@ -1610,12 +3519,13 @@ fn wire_sftp_callbacks( // Toggle tree node expand/collapse and navigate to that directory. { let sftp_handles = sftp_handles.clone(); - let sftp_manual_nav = sftp_manual_nav.clone(); + let sftp_last_cwd = sftp_last_cwd.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); + // Forget the followed cwd (see on_sftp_navigate): tree navigation + // must never permanently disable cd-follow. + sftp_last_cwd.lock().unwrap().remove(&tab_id); if let Ok(handles) = sftp_handles.lock() { if let Some(h) = handles.get(&tab_id) { h.toggle_tree_node(path.clone()); @@ -1625,7 +3535,9 @@ fn wire_sftp_callbacks( }); } - // Context menu → 删除 a remote file. + // 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| { @@ -1637,10 +3549,158 @@ fn wire_sftp_callbacks( }); } - // Context menu → 查看 (open read-only) / 编辑 (open + auto-reupload). + // SFTP multi-select: toggle a row's checkbox + recount (#100). + { + let weak = window.as_weak(); + window.on_sftp_toggle_select(move |tab_id: SharedString, idx: i32| { + let Some(w) = weak.upgrade() else { return }; + let terminals = w.get_terminals(); + let Some(tm) = terminals.as_any().downcast_ref::>() else { + return; + }; + for ti in 0..tm.row_count() { + let Some(row) = tm.row_data(ti) else { continue }; + if row.id.as_str() != tab_id.as_str() { + continue; + } + if let Some(em) = row.sftp_entries.as_any().downcast_ref::>() { + let i = idx as usize; + if let Some(mut e) = em.row_data(i) { + e.selected = !e.selected; + em.set_row_data(i, e); + } + let mut n = 0; + for ei in 0..em.row_count() { + if em.row_data(ei).map(|x| x.selected).unwrap_or(false) { + n += 1; + } + } + let mut r = row.clone(); + r.sftp_selected_count = n; + tm.set_row_data(ti, r); + } + break; + } + }); + } + // SFTP multi-select: download all checked entries into one folder (#100). + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_sftp_download_selected(move |tab_id: SharedString| { + let Some(w) = weak.upgrade() else { return }; + let terminals = w.get_terminals(); + let Some(tm) = terminals.as_any().downcast_ref::>() else { + return; + }; + let paths = collect_sftp_selected(tm, tab_id.as_str()); + if paths.is_empty() { + return; + } + // Single selection downloads as a plain file (no compression, #100.3); + // multiple selections are tar-packed into one archive on the remote + // (#100.2) — this also avoids the concurrent-transfer races (#100.1). + let single = paths.len() == 1; + let remote_dir = active_sftp_path(&w, tab_id.as_str()); + let names: Vec = paths + .iter() + .map(|p| { + p.trim_end_matches('/') + .rsplit(['/', '\\']) + .next() + .unwrap_or(p) + .to_string() + }) + .collect(); + let preset = w.get_download_dir().to_string(); + let always_ask = w.get_download_always_ask(); + if !always_ask && !preset.is_empty() { + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + if single { + h.download(paths[0].clone(), preset.clone()); + } else { + h.download_archive(remote_dir.clone(), names.clone(), preset.clone()); + } + } + } + w.set_download_open(true); + } else { + let sftp_handles = sftp_handles.clone(); + let weak2 = weak.clone(); + let tab = tab_id.to_string(); + std::thread::spawn(move || { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + let dir = dir.to_string_lossy().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab) { + if single { + h.download(paths[0].clone(), dir.clone()); + } else { + h.download_archive(remote_dir.clone(), names.clone(), dir.clone()); + } + } + } + let _ = weak2.upgrade_in_event_loop(|w| w.set_download_open(true)); + } + }); + } + clear_sftp_selection(tm, tab_id.as_str()); + }); + } + // SFTP multi-select: delete all checked entries (confirmed in the UI) (#100). + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_sftp_delete_selected(move |tab_id: SharedString| { + let Some(w) = weak.upgrade() else { return }; + let terminals = w.get_terminals(); + let Some(tm) = terminals.as_any().downcast_ref::>() else { + return; + }; + let paths = collect_sftp_selected(tm, tab_id.as_str()); + if paths.is_empty() { + return; + } + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + for p in &paths { + h.delete(p.clone()); + } + } + } + clear_sftp_selection(tm, tab_id.as_str()); + }); + } + + // Context menu → 查看 (read-only) / 编辑 (editable). Both load the file's + // text into the built-in editor instead of an external app (#70). { let sftp_handles = sftp_handles.clone(); window.on_sftp_view(move |tab_id: SharedString, path: SharedString| { + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + h.read_text(path.to_string(), false); + } + } + }); + } + { + 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.read_text(path.to_string(), true); + } + } + }); + } + // Open / edit with an external program (#81): download to a temp file and + // hand it to the OS default app. Edit mode watches the temp copy and + // re-uploads on every change. + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_open_external(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(), false); @@ -1649,37 +3709,549 @@ fn wire_sftp_callbacks( }); } { - 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); - } + let sftp_handles = sftp_handles.clone(); + window.on_sftp_edit_external(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); + } + } + }); + } + + // Context-menu extensions (#69): one prompt dialog covers rename / chmod / + // mkdir / touch; copy-path goes straight to the system clipboard. + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_prompt_submit( + move |tab_id: SharedString, + kind: SharedString, + target: SharedString, + value: SharedString| { + let value = value.to_string(); + let value = value.trim(); + if value.is_empty() { + return; + } + let target = target.to_string(); + let handles = match sftp_handles.lock() { + Ok(h) => h, + Err(_) => return, + }; + let Some(h) = handles.get(tab_id.as_str()) else { + return; + }; + match kind.as_str() { + "rename" => { + let to = format!( + "{}/{}", + parent_path(&target).trim_end_matches('/'), + value + ); + h.rename(target, to); + } + "mkdir" => { + h.mkdir(format!("{}/{}", target.trim_end_matches('/'), value)); + } + "touch" => { + h.touch(format!("{}/{}", target.trim_end_matches('/'), value)); + } + _ => {} + } + }, + ); + } + { + window.on_sftp_copy_path(move |path: SharedString| { + clipboard_set_text(path.to_string()); + }); + } + + // Visual chmod dialog (#84): decompose the current mode into nine bools on + // open, recompose on apply (Slint has no bitwise ops). + { + let weak = window.as_weak(); + window.on_sftp_chmod_open( + move |tab: SharedString, path: SharedString, name: SharedString, mode: i32| { + let Some(w) = weak.upgrade() else { return }; + let m = mode as u32; + w.set_chmod_tab(tab); + w.set_chmod_path(path); + w.set_chmod_name(name); + w.set_chmod_or(m & 0o400 != 0); + w.set_chmod_ow(m & 0o200 != 0); + w.set_chmod_ox(m & 0o100 != 0); + w.set_chmod_gr(m & 0o040 != 0); + w.set_chmod_gw(m & 0o020 != 0); + w.set_chmod_gx(m & 0o010 != 0); + w.set_chmod_tr(m & 0o004 != 0); + w.set_chmod_tw(m & 0o002 != 0); + w.set_chmod_tx(m & 0o001 != 0); + w.set_chmod_open(true); + }, + ); + } + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_sftp_chmod_apply(move || { + let Some(w) = weak.upgrade() else { return }; + let mode = (w.get_chmod_or() as u32) << 8 + | (w.get_chmod_ow() as u32) << 7 + | (w.get_chmod_ox() as u32) << 6 + | (w.get_chmod_gr() as u32) << 5 + | (w.get_chmod_gw() as u32) << 4 + | (w.get_chmod_gx() as u32) << 3 + | (w.get_chmod_tr() as u32) << 2 + | (w.get_chmod_tw() as u32) << 1 + | (w.get_chmod_tx() as u32); + let path = w.get_chmod_path().to_string(); + let tab = w.get_chmod_tab().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab) { + h.chmod(path, mode); + } + } + }); + } + + // Rebuild the editor's line-number gutter after each edit (#81). The text + // comes straight from the TextInput so we don't re-read the property. + { + let weak = window.as_weak(); + window.on_editor_recount(move |text: SharedString| { + if let Some(w) = weak.upgrade() { + w.set_editor_line_numbers(line_numbers_for(text.as_str()).into()); + } + }); + } + + // Built-in editor: save (Ctrl+S / button) writes the text back to the + // remote file (#70). Read-only (view) sessions never save. + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_save_file(move || { + let Some(w) = weak.upgrade() else { return }; + if w.get_editor_readonly() { + return; + } + let path = w.get_editor_path().to_string(); + let content = w.get_editor_content().to_string(); + let tab_id = w.get_active_tab_id().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.write_text(path, content); + } + } + w.set_editor_dirty(false); + }); + } + // Close the editor; in edit mode upload first if there are unsaved edits. + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_close_editor(move || { + let Some(w) = weak.upgrade() else { return }; + if !w.get_editor_readonly() && w.get_editor_dirty() { + let path = w.get_editor_path().to_string(); + let content = w.get_editor_content().to_string(); + let tab_id = w.get_active_tab_id().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.write_text(path, content); + } + } + } + w.set_editor_open(false); + w.set_editor_dirty(false); + }); + } +} + +// --------------------------------------------------------------------------- +// Raw keystroke forwarding and PTY resize +// --------------------------------------------------------------------------- + +fn wire_key_input( + window: &AppWindow, + handles: Rc>>, + bufs: TermBuffers, + last_term_size: Arc>, + store: Rc>, + ctx: ConnectCtx, +) { + // --- Command bar (#55): run command + quick-command management --------- + { + let handles_rc = handles.clone(); + let store_rc = store.clone(); + let weak = window.as_weak(); + window.on_run_command(move |tab_id: SharedString, cmd: SharedString, to_all: bool| { + let line = cmd.trim_end().to_string(); + if line.is_empty() { + return; + } + let mut bytes = line.clone().into_bytes(); + bytes.push(b'\n'); + { + let h = handles_rc.borrow(); + if to_all { + for handle in h.values() { + handle.send_raw(bytes.clone()); + } + } else if let Some(handle) = h.get(tab_id.as_str()) { + handle.send_raw(bytes); + } + } + { + let mut s = store_rc.borrow_mut(); + s.push_command_history(line); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_command_history(history_model(&store_rc.borrow())); + } + }); + } + // Copy a history command to the clipboard (#96). + { + window.on_copy_text(move |text: SharedString| { + let t = text.to_string(); + std::thread::spawn(move || clipboard_set_text(t)); + }); + } + // Delete a history entry (#96). The model is in storage order now (#113), + // so the row index maps straight through. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + window.on_delete_history(move |i: i32| { + { + let mut s = store_rc.borrow_mut(); + let idx = i as usize; + if idx < s.command_history().len() { + s.remove_command_history(idx); + let _ = s.save(); + } + } + if let Some(w) = weak.upgrade() { + w.set_command_history(history_model(&store_rc.borrow())); + } + }); + } + // History search (#101): filter the dropdown by a case-insensitive substring. + // The current query is shared so a delete from a filtered view re-filters. + let hist_query: Rc> = Rc::new(RefCell::new(String::new())); + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let hist_query = hist_query.clone(); + window.on_search_history(move |query: SharedString| { + *hist_query.borrow_mut() = query.to_string(); + if let Some(w) = weak.upgrade() { + w.set_history_view(history_view_model(&store_rc.borrow(), &query)); + } + }); + } + // Delete a history entry by its command text (#101) — index-free so it works + // from the filtered dropdown view. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let hist_query = hist_query.clone(); + window.on_delete_history_cmd(move |cmd: SharedString| { + { + let mut s = store_rc.borrow_mut(); + if let Some(idx) = s.command_history().iter().position(|c| c == cmd.as_str()) { + s.remove_command_history(idx); + let _ = s.save(); + } + } + if let Some(w) = weak.upgrade() { + let s = store_rc.borrow(); + w.set_command_history(history_model(&s)); + w.set_history_view(history_view_model(&s, &hist_query.borrow())); + } + }); + } + // Runtime-only collapse state for quick-command groups (#55) — like the + // welcome session groups, this is not persisted across restarts. Starts with + // every group collapsed (default-collapsed view). + let collapsed_quick_groups: Rc>> = + Rc::new(RefCell::new(all_quick_group_names(&store.borrow()))); + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_add_quick_command( + move |name: SharedString, command: SharedString, group: SharedString| { + let name = name.trim().to_string(); + let command = command.to_string(); + let group = group.trim().to_string(); + if name.is_empty() || command.trim().is_empty() { + return; + } + { + let mut s = store_rc.borrow_mut(); + let mut v = s.quick_commands().to_vec(); + v.push(crate::config::QuickCommand { + name, + command, + group, + }); + s.set_quick_commands(v); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }, + ); + } + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_delete_quick_command(move |index: i32| { + { + let mut s = store_rc.borrow_mut(); + let mut v = s.quick_commands().to_vec(); + let i = index as usize; + if i < v.len() { + v.remove(i); + } + s.set_quick_commands(v); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }); + } + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_toggle_quick_group(move |group: SharedString| { + let g = group.to_string(); + { + let mut set = collapsed.borrow_mut(); + if !set.remove(&g) { + set.insert(g); + } + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }); + } + // Edit (#55): load the entry into the manage form in edit mode. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + window.on_edit_quick_command(move |index: i32| { + let i = index as usize; + let cmd = store_rc.borrow().quick_commands().get(i).cloned(); + if let (Some(c), Some(w)) = (cmd, weak.upgrade()) { + w.set_qcm_name(c.name.into()); + w.set_qcm_command(c.command.into()); + w.set_qcm_group(c.group.into()); + w.set_qcm_edit_index(index); + w.set_quick_cmd_manage_open(true); + } + }); + } + // Save an edited entry (#55). + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_save_quick_command( + move |index: i32, name: SharedString, command: SharedString, group: SharedString| { + let name = name.trim().to_string(); + let command = command.to_string(); + let group = group.trim().to_string(); + if name.is_empty() || command.trim().is_empty() { + return; + } + { + let mut s = store_rc.borrow_mut(); + s.update_quick_command( + index as usize, + crate::config::QuickCommand { + name, + command, + group, + }, + ); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }, + ); + } + // Duplicate (#55): clone the entry as a starting point. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_duplicate_quick_command(move |index: i32| { + { + let mut s = store_rc.borrow_mut(); + let mut v = s.quick_commands().to_vec(); + if let Some(c) = v.get(index as usize).cloned() { + let dup = crate::config::QuickCommand { + name: format!("{} (copy)", c.name), + command: c.command, + group: c.group, + }; + v.insert(index as usize + 1, dup); + s.set_quick_commands(v); + let _ = s.save(); + } + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }); + } + // Move to a group (#55): "default" maps to the empty (ungrouped) group. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_move_quick_command(move |index: i32, group: SharedString| { + let target = group.to_string(); + let target = if target == "default" { String::new() } else { target }; + { + let mut s = store_rc.borrow_mut(); + let mut v = s.quick_commands().to_vec(); + if let Some(c) = v.get_mut(index as usize) { + c.group = target; + } + s.set_quick_commands(v); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }); + } + // Quick-group create / rename (#55). + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_submit_quick_group(move |orig: SharedString, name: SharedString| { + { + let mut s = store_rc.borrow_mut(); + if orig.is_empty() { + s.add_quick_group(name.to_string()); + } else { + s.rename_quick_group(&orig.to_string(), name.to_string()); + } + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); + } + }); + } + // Quick-group delete (#55) — UI only offers this on empty groups. + { + let store_rc = store.clone(); + let weak = window.as_weak(); + let collapsed = collapsed_quick_groups.clone(); + window.on_delete_quick_group(move |name: SharedString| { + { + let mut s = store_rc.borrow_mut(); + s.remove_quick_group(&name.to_string()); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_quick_commands(quick_cmd_model(&store_rc.borrow(), &collapsed.borrow())); } }); } -} -// --------------------------------------------------------------------------- -// Raw keystroke forwarding and PTY resize -// --------------------------------------------------------------------------- + // Session sync / broadcast input: when on, a keystroke in any terminal is + // mirrored to every online session (Xshell-style; #78 pt.4). Read on the hot + // keystroke path, so use an AtomicBool rather than a window-property lookup. + let sync_input = Arc::new(std::sync::atomic::AtomicBool::new(false)); + { + let flag = sync_input.clone(); + window.on_set_sync_input(move |on| { + flag.store(on, std::sync::atomic::Ordering::Relaxed); + }); + } -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(); + let sync_input = sync_input.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| { + // ── Enter on a disconnected tab → reconnect in place (#79) ────── + // FinalShell-style: the tab shows "连接已断开,按 Enter 重新连接"; + // pressing Enter re-spawns the shell + SFTP workers in the SAME tab + // with a fresh screen instead of forcing the user to open a new one. + if key.as_str() == "\n" && !ctrl && !alt { + let dead_session = { + let statuses = ctx.tab_statuses.lock().unwrap(); + statuses + .get(tab_id.as_str()) + .filter(|st| st.state == 2) + .map(|st| st.session_id.clone()) + }; + if let Some(session_id) = dead_session { + let Some(session) = store.borrow().get(&session_id).cloned() else { + return; + }; + // Drop the dead shell/SFTP handles for this tab. + ctx.handles.borrow_mut().remove(tab_id.as_str()); + if let Some(h) = + ctx.sftp_handles.lock().unwrap().remove(tab_id.as_str()) + { + h.close(); + } + // Fresh screen: new parser, cleared history/selection. + { + let mut map = ctx.bufs.lock().unwrap(); + if let Some(b) = map.get_mut(tab_id.as_str()) { + let (rows, cols) = b.parser.screen().size(); + b.parser = vt100::Parser::new(rows, cols, 5000); + b.history.clear(); + b.prev.clear(); + b.displayed_text.clear(); + b.view_offset = 0; + b.sel_anchor = None; + b.sel_focus = None; + } + } + if let Some(st) = + ctx.tab_statuses.lock().unwrap().get_mut(tab_id.as_str()) + { + st.state = 0; + } + // Fresh session: the first OSC 7 after reconnect follows. + ctx.sftp_last_cwd.lock().unwrap().remove(tab_id.as_str()); + if let Some(w) = ctx.weak.upgrade() { + set_terminal_row(&w, tab_id.as_str(), |t| { + t.status = + crate::i18n::t("重连中...", "Reconnecting...").into(); + }); + } + start_session_in_tab(tab_id.as_str(), session, &ctx); + return; + } + } // 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. @@ -1695,20 +4267,22 @@ fn wire_key_input( 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, key.as_str(), ctrl, alt, shift, app_cursor + "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}" { - let codepoints: Vec = if key.as_str().is_empty() { - vec!["(empty)".to_string()] - } else { - key.as_str().chars().map(|c| format!("U+{:04X}", c as u32)).collect() - }; + // 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() @@ -1716,7 +4290,7 @@ fn wire_key_input( .unwrap_or_else(|| "never".to_string()); tracing::info!( "[KEY_DIAG] key={} shift={} ctrl={} alt={} | last_shift={}", - codepoints.join(","), shift, ctrl, alt, elapsed_ms + codepoints, shift, ctrl, alt, elapsed_ms ); } @@ -1873,13 +4447,21 @@ fn wire_key_input( } 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 bytes={:02x?} handle_exists={}", - bytes, + "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()) { + let h = handles.borrow(); + if sync_input.load(std::sync::atomic::Ordering::Relaxed) { + // Broadcast the same bytes to every online session (#78 pt.4). + for handle in h.values() { + handle.send_raw(bytes.clone()); + } + } else if let Some(handle) = h.get(tab_id.as_str()) { handle.send_raw(bytes); } } @@ -1919,7 +4501,44 @@ fn wire_key_input( handle.resize(cols, rows); } if let Some(buf) = bufs_resize.lock().unwrap().get_mut(tab_id.as_str()) { - buf.parser.set_size(rows as u16, cols as u16); + 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(); } }); } @@ -1934,11 +4553,11 @@ fn wire_key_input( Some(buf) => { // Copy the drag-selection when there is one, else the // whole displayed screen. - match buf.sel { - Some((sr, sc, er, ec)) if (sr, sc) != (er, ec) => { - extract_selection(&buf.displayed_text, sr, sc, er, ec) - } - _ => buf.displayed_text.join("\n"), + let sel = buf.extract_selection_text(); + if sel.is_empty() { + buf.displayed_text.join("\n") + } else { + sel } } None => String::new(), @@ -1948,12 +4567,7 @@ fn wire_key_input( // 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), - } - }); + std::thread::spawn(move || clipboard_set_text(text)); }); } @@ -1973,7 +4587,11 @@ fn wire_key_input( 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())); + // Normalise line endings to a single CR so multi-line and + // backslash-continued commands paste correctly (see the + // function doc for the failure mode this prevents). + let bytes = normalize_pasted_newlines(&text).into_bytes(); + let _ = sender.send(SessionCommand::RawInput(bytes)); } Err(e) => tracing::warn!("paste_from_clipboard: clipboard error: {}", e), } @@ -1996,7 +4614,8 @@ fn wire_key_input( buf.history = Vec::new(); // recycle the session scrollback buf.prev = Vec::new(); buf.view_offset = 0; - buf.sel = None; + buf.sel_anchor = None; + buf.sel_focus = None; buf.displayed_text = Vec::new(); } if let Some(win) = weak.upgrade() { @@ -2010,6 +4629,8 @@ fn wire_key_input( row.cursor_row = 0; row.cursor_col = 0; row.rows_used = 0; + row.scroll_max = 0; + row.scroll_offset = 0; }); } if let Some(h) = handles_clear.borrow().get(&tid) { @@ -2064,6 +4685,24 @@ fn wire_key_input( }); } + // Scrollbar drag → jump to an absolute scrollback offset (#103). + { + let bufs_scroll = bufs.clone(); + let weak = window.as_weak(); + window.on_terminal_scroll_to(move |tab_id: SharedString, offset: i32| { + let tid = tab_id.to_string(); + { + let mut map = bufs_scroll.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + let max_off = buf.history.len() as i64; + buf.view_offset = (offset 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(); @@ -2076,7 +4715,10 @@ fn wire_key_input( 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; - buf.sel = Some((r, c, r, c)); + // 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); @@ -2094,8 +4736,9 @@ fn wire_key_input( 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 let Some((sr, sc, _, _)) = buf.sel { - buf.sel = Some((sr, sc, r, c)); + 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() { @@ -2113,23 +4756,20 @@ fn wire_key_input( let text = { let mut map = bufs_sel.lock().unwrap(); let Some(buf) = map.get_mut(&tid) else { return }; - match buf.sel { - Some((sr, sc, er, ec)) if (sr, sc) != (er, ec) => { - Some(extract_selection(&buf.displayed_text, sr, sc, er, ec)) - } - _ => { - buf.sel = None; // treat as click → clear selection - None - } + 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)); - }); + std::thread::spawn(move || clipboard_set_text(t)); } _ => {} } @@ -2138,10 +4778,10 @@ fn wire_key_input( } }); } - // Auto-scroll while drag-selecting past the visible top/bottom edge. We - // move the scrollback view by a couple of lines per tick and shift the - // selection anchor by the same amount so it stays pinned to its content - // while the end is parked at the edge row. + // 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(); @@ -2154,32 +4794,36 @@ fn wire_key_input( if buf.parser.screen().alternate_screen() { 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; - let Some((sr, sc, _er, ec)) = buf.sel else { return }; - if dir < 0 { + // 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); - let delta = new_off - buf.view_offset; - if delta == 0 { + if new_off == buf.view_offset { return; // already at the oldest line } buf.view_offset = new_off; - let nsr = ((sr as usize) + delta).min(last as usize) as u16; - buf.sel = Some((nsr, sc, 0, ec)); + 0u16 } else if dir > 0 { // Mouse below the bottom → move toward the live tail. let new_off = buf.view_offset.saturating_sub(step); - let delta = buf.view_offset - new_off; - if delta == 0 { + if new_off == buf.view_offset { return; // already at the live bottom } buf.view_offset = new_off; - let nsr = (sr as i32 - delta as i32).max(0) as u16; - buf.sel = Some((nsr, sc, last, ec)); - } + 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); @@ -2212,10 +4856,231 @@ fn set_terminal_row(win: &AppWindow, tab_id: &str, mutator: impl Fn(&mut Termina /// 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. +/// Build the editor's line-number gutter text: "1\n2\n…\nN", one number per line +/// of `content`, matching its (newline-separated) line count (#81). +fn line_numbers_for(content: &str) -> String { + use std::fmt::Write; + let lines = content.split('\n').count().max(1); + let mut s = String::with_capacity(lines * 4); + for i in 1..=lines { + if i > 1 { + s.push('\n'); + } + let _ = write!(s, "{i}"); + } + s +} + +/// Write `text` to the system clipboard. Call from a dedicated thread, never the +/// UI thread (arboard pumps the Win32 message loop / blocks). +/// +/// On Linux the clipboard selection only persists while the owning client stays +/// alive, so we use arboard's `set().wait()`, which blocks this thread until +/// another app takes ownership — otherwise the copied text vanishes the moment +/// the `Clipboard` handle is dropped. Combined with the `wayland-data-control` +/// feature this is also what makes copy work on Wayland sessions (issue #47). +fn clipboard_set_text(text: String) { + #[cfg(target_os = "linux")] + let result = { + use arboard::SetExtLinux as _; + arboard::Clipboard::new().and_then(|mut cb| cb.set().wait().text(text)) + }; + #[cfg(not(target_os = "linux"))] + let result = arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)); + if let Err(e) = result { + tracing::warn!("clipboard set_text error: {}", e); + } +} + +/// Enumerate installed monospace font families for the Interface font picker. +/// Terminals want fixed-width fonts, so non-monospace families are filtered out. +/// Choose a UI font family that fontdb can actually resolve, falling back to the +/// embedded "Meatshell Mono" when the system font database is empty/unreadable. +/// +/// macOS 26 (Tahoe) shipped a system where fontdb couldn't register the named +/// CJK font ("PingFang SC"), so hard-coding that name made the whole UI render +/// blank (#129). This probes the loaded faces and picks the first CJK-capable +/// family that exists; if none do, it returns the embedded font so the window is +/// still visible (Latin text shows; CJK may tofu — far better than a blank UI). +/// +/// Emits a one-line WARN summary (faces loaded + chosen font) so the choice lands +/// in `error.log` for diagnostics without needing RUST_LOG. +fn resolve_ui_font_family() -> slint::SharedString { + use fontdb::{Database, Family, Query, Stretch, Style, Weight}; + + // Diagnostic / escape hatch (#129): force a specific UI font without a rebuild. + // e.g. MEATSHELL_UI_FONT="Meatshell Mono" to test whether the embedded font + // renders when system fonts don't. Empty value is ignored. + if let Some(f) = std::env::var_os("MEATSHELL_UI_FONT") { + let f = f.to_string_lossy().into_owned(); + if !f.trim().is_empty() { + tracing::debug!(font = %f, "ui-font: overridden via MEATSHELL_UI_FONT"); + return f.into(); + } + } + + let mut db = Database::new(); + db.load_system_fonts(); + let face_count = db.faces().count(); + + // CJK-capable system families, most-preferred first, per platform. The UI + // default font must cover CJK because TextInput doesn't glyph-fallback (#54). + // + // macOS note (#129): the modern system CJK fonts (PingFang SC, Hiragino) fail + // to rasterize under femtovg on some macOS 26 machines — fontdb finds them but + // every glyph comes out blank. The older Heiti/Songti faces render fine and + // ship on every macOS, so we prefer them and keep PingFang only as a late + // fallback. (Verified on an M2/macOS 26: Heiti SC/STHeiti/Songti SC render, + // PingFang/Hiragino don't.) Power users can still force one via + // MEATSHELL_UI_FONT. Heiti SC is a clean sans-serif (better for UI than the + // serif Songti), so it leads. + #[cfg(target_os = "macos")] + let candidates: &[&str] = &[ + "Heiti SC", "STHeiti", "Songti SC", "PingFang SC", "Hiragino Sans GB", + ]; + #[cfg(target_os = "windows")] + let candidates: &[&str] = &["Microsoft YaHei UI", "Microsoft YaHei", "SimHei", "SimSun"]; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let candidates: &[&str] = &[ + "Noto Sans CJK SC", "Noto Sans CJK", "Source Han Sans SC", + "WenQuanYi Micro Hei", "Droid Sans Fallback", + ]; + + for name in candidates { + let q = Query { + families: &[Family::Name(name)], + weight: Weight::NORMAL, + stretch: Stretch::Normal, + style: Style::Normal, + }; + if db.query(&q).is_some() { + tracing::debug!(faces = face_count, font = name, "ui-font: using system CJK font"); + return (*name).into(); + } + } + + // No preferred family resolved. List what *is* available (if anything) so the + // log shows whether enumeration is empty or just missing our candidates (#129). + if face_count > 0 { + let mut fams: Vec = db + .faces() + .filter_map(|f| f.families.first().map(|(n, _)| n.clone())) + .collect(); + fams.sort(); + fams.dedup(); + let sample: Vec = fams.into_iter().take(40).collect(); + tracing::warn!(faces = face_count, available = ?sample, + "ui-font: no preferred CJK font resolved; listing available families"); + } + tracing::warn!(faces = face_count, + "ui-font: falling back to embedded 'Meatshell Mono' (system fonts unusable, #129)"); + "Meatshell Mono".into() +} + +fn system_monospace_fonts() -> Vec { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let mut names: Vec = db + .faces() + .filter(|f| f.monospaced) + .filter_map(|f| f.families.first().map(|(n, _)| n.clone())) + .collect(); + names.sort(); + names.dedup(); + // Surface the built-in glyph-complete font first so it's selectable and the + // default selection is shown — it isn't a system face so fontdb won't list it + // (#114). + names.retain(|n| n != "Meatshell Mono"); + let mut out = vec![slint::SharedString::from("Meatshell Mono")]; + out.extend(names.into_iter().map(slint::SharedString::from)); + out +} + +/// Split a stored proxy URL into `(type, host:port)` for the session dialog. +/// +/// `""` → `("none", "")`. Recognises `socks5`/`socks5h`/`socks` and +/// `http`/`https` scheme prefixes. A value without a (recognised) scheme is +/// treated as SOCKS5, matching proxy.rs's parse default, so older configs that +/// stored a bare `host:port` keep working. +/// Parse a "vX.Y.Z" / "X.Y.Z" tag into a comparable tuple, or None if it isn't +/// a three-part numeric version. A pre-release suffix on the patch (e.g. +/// "3-rc1") is tolerated by taking its leading digits (#48). +fn parse_version(s: &str) -> Option<(u32, u32, u32)> { + let s = s.trim().trim_start_matches('v'); + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it + .next()? + .split(|c: char| !c.is_ascii_digit()) + .next()? + .parse() + .ok()?; + Some((major, minor, patch)) +} + +fn split_proxy(url: &str) -> (String, String) { + let s = url.trim(); + if s.is_empty() { + return ("none".to_string(), String::new()); + } + let lower = s.to_ascii_lowercase(); + for p in ["http://", "https://"] { + if lower.starts_with(p) { + return ("http".to_string(), s[p.len()..].trim_end_matches('/').to_string()); + } + } + for p in ["socks5h://", "socks5://", "socks://"] { + if lower.starts_with(p) { + return ("socks5".to_string(), s[p.len()..].trim_end_matches('/').to_string()); + } + } + ("socks5".to_string(), s.trim_end_matches('/').to_string()) +} + +/// Normalise pasted text's line endings to a single CR (0x0d) — what a terminal +/// expects for Enter. +/// +/// The clipboard may hold CRLF (Windows) or LF line breaks. Sending those to the +/// PTY verbatim makes the remote shell see *two* line breaks per line (CR then +/// LF), which prematurely ends a `\`-continued line: pasting +/// `sudo apt install \ docker-ce` would run `sudo apt install` with no +/// package and drop the rest. Collapsing every CRLF/LF to one CR fixes it. +fn normalize_pasted_newlines(text: &str) -> String { + text.replace("\r\n", "\r").replace('\n', "\r") +} + 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. @@ -2268,6 +5133,30 @@ fn key_to_pty_bytes(key: &str, ctrl: bool, alt: bool, app_cursor: bool) -> Vec (String, vt100::Color, vt100::Color, bool) { +) -> (String, vt100::Color, vt100::Color, bool, bool) { match screen.cell(r, c) { Some(cell) => { let (mut fg, mut bg) = (cell.fgcolor(), cell.bgcolor()); @@ -2409,14 +5305,26 @@ fn cell_attrs( std::mem::swap(&mut fg, &mut bg); } let s = cell.contents(); - let s = if s.is_empty() { " ".to_string() } else { s }; - (s, fg, bg, cell.bold()) + // A CJK / wide glyph spans two cells; vt100 reports the 2nd as a + // blank continuation. Emit nothing for it — the wide glyph already + // covers both cells, so substituting a space would push the rest of + // the line (and the cursor) out of alignment (#60). Genuinely empty + // cells still become a space. + let s = if cell.is_wide_continuation() { + String::new() + } else if s.is_empty() { + " ".to_string() + } else { + s + }; + (s, fg, bg, cell.bold(), cell.is_wide()) } None => ( " ".to_string(), vt100::Color::Default, vt100::Color::Default, false, + false, ), } } @@ -2426,17 +5334,36 @@ fn build_row(screen: &vt100::Screen, r: u16, cols: u16) -> Line { let mut runs: Vec = Vec::new(); let mut c = 0u16; while c < cols { - let (s, fg, bg, bold) = cell_attrs(screen, r, c); - // Group consecutive cells that share fg + bg + bold into one run. Unlike - // before we keep blank cells *inside* a run (so a coloured bar made of - // spaces still gets a background fill) and break only on attribute change. + let (s, fg, bg, bold, wide) = cell_attrs(screen, r, c); + // A wide (CJK) glyph gets its OWN span occupying exactly its two grid + // cells, so the UI can box + centre + clip it on the monospace grid. + // Otherwise a run of CJK rendered with a proportional CJK font drifts off + // the grid — the trailing `/`, `$` or cursor overlaps or gaps the glyph + // (CJK advance != 2×the Latin cell width). + if wide { + plain.push_str(&s); + runs.push(HistSpan { + text: s, + fg, + bg, + bold, + col: c as i32, + cells: 2, + }); + c += 2; // skip the wide-continuation cell + continue; + } + // Group consecutive *narrow* cells that share fg + bg + bold into one run. + // We keep blank cells *inside* a run (so a coloured bar made of spaces + // still gets a background fill) and break on attribute change or a wide + // cell (which starts its own span above). let start_col = c; let mut text = s.clone(); plain.push_str(&s); c += 1; while c < cols { - let (cs, cfg, cbg, cbold) = cell_attrs(screen, r, c); - if cfg != fg || cbg != bg || cbold != bold { + let (cs, cfg, cbg, cbold, cwide) = cell_attrs(screen, r, c); + if cwide || cfg != fg || cbg != bg || cbold != bold { break; } plain.push_str(&cs); @@ -2452,8 +5379,8 @@ fn build_row(screen: &vt100::Screen, r: u16, cols: u16) -> Line { } runs.push(HistSpan { text, - fg: vt_color_to_slint(fg, bold), - bg: vt_bg_to_slint(bg), + fg, // raw vt100::Color — converted at render time with the live palette + bg, bold, col: start_col as i32, cells, @@ -2482,6 +5409,139 @@ fn detect_scroll(prev: &[Line], curr: &[Line]) -> usize { } 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. + + /// 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 { + // Include the screen's full row count (trailing blanks too) so this + // mapping matches render()'s scroll window — keeping the live and + // scrolled views continuous after a shrink/grow (#119-followup). + let combined_len = hist_len + rows; + 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 @@ -2628,9 +5688,10 @@ impl TermBuffer { } for hs in runs { spans.push(TermSpan { + cjk: contains_cjk(&hs.text), text: hs.text.into(), - fg: hs.fg, - bg: hs.bg, + 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, row: r as i32, col: hs.col, @@ -2647,6 +5708,8 @@ impl TermBuffer { cursor_col: cur_col as i32, rows_used, is_alt, + scroll_max: if is_alt { 0 } else { self.history.len() as i32 }, + scroll_offset: 0, }; } @@ -2655,13 +5718,14 @@ impl TermBuffer { 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; + // Include the screen's trailing blank rows in the scroll range so this + // scrolled view stays continuous with the live view (view_offset 0). + // Trimming to only the used rows made the two views misalign after a + // shrink-then-grow (dragging the SFTP panel over the terminal and back), + // so scrolling back jumped at the bottom instead of moving line-by-line + // (#119-followup). + let combined_len = hist_len + live.len(); let win = rows as usize; let start = combined_len.saturating_sub(win + self.view_offset); let end = (start + win).min(combined_len); @@ -2677,12 +5741,13 @@ impl TermBuffer { for hs in &line.1 { spans.push(TermSpan { text: hs.text.clone().into(), - fg: hs.fg, - bg: hs.bg, + 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, row: d as i32, col: hs.col, cells: hs.cells, + cjk: contains_cjk(&hs.text), }); } displayed.push(line.0.trim_end().to_string()); @@ -2697,23 +5762,45 @@ impl TermBuffer { cursor_col: 0, rows_used: win as i32, is_alt: false, + scroll_max: self.history.len() as i32, + scroll_offset: self.view_offset as i32, } } } -/// Standard 16-colour ANSI palette (VS Code "Dark+" values — reads well on the -/// dark terminal background). -const ANSI16: [(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 - (0x66, 0x66, 0x66), // 8 bright black - (0xf1, 0x4c, 0x4c), // 9 bright red +/// True if a terminal span contains any CJK character — ideograph, kana, or +/// (crucially) CJK punctuation like 、。,. The mono terminal font has no CJK +/// glyphs and Slint's per-script fallback tofu's *isolated* CJK punctuation +/// (it renders fine only when adjacent to a Han char), so these spans are drawn +/// with the CJK-capable UI font instead (#54). Box-drawing / powerline glyphs +/// are deliberately excluded so they keep the aligned monospace font. +fn contains_cjk(s: &str) -> bool { + s.chars().any(|c| { + matches!(c as u32, + 0x2E80..=0x2EFF // CJK radicals + | 0x3000..=0x303F // CJK symbols & punctuation (、。「」…) + | 0x3040..=0x30FF // hiragana + katakana + | 0x3100..=0x312F // bopomofo + | 0x3400..=0x4DBF // CJK ext A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF00..=0xFFEF // fullwidth / halfwidth forms (,!?:;) + | 0x20000..=0x2FA1F) // CJK ext B–F + compat supplement + }) +} + +/// 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 @@ -2722,46 +5809,184 @@ const ANSI16: [(u8, u8, u8); 16] = [ (0xff, 0xff, 0xff), // 15 bright white ]; -/// Convert a vt100 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` (e.g. bold-green executables, bold-blue directories). -fn vt_color_to_slint(color: vt100::Color, bold: bool) -> slint::Color { +/// 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 => (0xd4, 0xd4, 0xd4), // Theme.term-fg - vt100::Color::Idx(i) => idx_to_rgb(i, bold), - vt100::Color::Rgb(r, g, b) => (r, g, b), + 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) } -/// 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 (and -/// can cheaply skip drawing it). Non-default backgrounds (btop/htop bars, -/// selected rows, meter fills) become opaque colours. -fn vt_bg_to_slint(color: vt100::Color) -> slint::Color { +/// 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(i, false); + 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) => 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) -> (u8, u8, u8) { +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 => ANSI16[i as usize], + 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 - } + if v == 0 { 0 } else { 55 + v * 40 } }; (to(n / 36), to((n % 36) / 6), to(n % 6)) } @@ -2772,6 +5997,16 @@ fn idx_to_rgb(i: u8, bold: bool) -> (u8, u8, u8) { } } +/// 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 { @@ -2785,3 +6020,174 @@ fn parent_path(path: &str) -> String { None => "/".to_string(), } } + +#[cfg(test)] +mod key_tests { + use super::*; + + #[test] + fn bare_alt_is_not_forwarded() { + // Slint sends Alt-alone as key=0x12 with alt=true. It must produce no + // bytes — otherwise it becomes ESC+0x12 and clears the input (issue #43). + assert_eq!(key_to_pty_bytes("\u{0012}", false, true, false), Vec::::new()); + } + + #[test] + fn bare_modifier_codes_are_dropped() { + // Shift..MetaR (0x10..=0x18) pressed alone (ctrl=false) → nothing sent. + for cp in 0x10u32..=0x18 { + let s = char::from_u32(cp).unwrap().to_string(); + assert_eq!( + key_to_pty_bytes(&s, false, false, false), + Vec::::new(), + "code point {:#04x} should be dropped", + cp + ); + } + } + + #[test] + fn ctrl_letter_c0_still_passes() { + // A real Ctrl+R encoded as the C0 byte 0x12 with ctrl=true must still be + // forwarded — the !ctrl guard keeps the #43 fix from breaking it. + assert_eq!(key_to_pty_bytes("\u{0012}", true, false, false), vec![0x12]); + // Ctrl+X as C0 0x18. + assert_eq!(key_to_pty_bytes("\u{0018}", true, false, false), vec![0x18]); + } + + #[test] + fn alt_letter_still_sends_esc_prefix() { + // Alt+a (a real Meta combo) must still send ESC + 'a'. + assert_eq!(key_to_pty_bytes("a", false, true, false), vec![0x1b, b'a']); + } + + #[test] + fn split_proxy_recognises_schemes() { + assert_eq!(split_proxy(""), ("none".into(), "".into())); + assert_eq!( + split_proxy("http://10.0.0.1:1022"), + ("http".into(), "10.0.0.1:1022".into()) + ); + assert_eq!( + split_proxy("socks5://127.0.0.1:1080"), + ("socks5".into(), "127.0.0.1:1080".into()) + ); + // user:pass survive in the host:port part. + assert_eq!( + split_proxy("http://u:p@host:8080"), + ("http".into(), "u:p@host:8080".into()) + ); + // bare host:port (legacy) → treated as socks5. + assert_eq!( + split_proxy("127.0.0.1:1080"), + ("socks5".into(), "127.0.0.1:1080".into()) + ); + } + + #[test] + fn paste_normalizes_newlines_to_cr() { + // CRLF (Windows clipboard) and LF both collapse to a single CR so a + // backslash-continued multi-line command pastes intact. + assert_eq!( + normalize_pasted_newlines("sudo apt install \\\r\n docker-ce"), + "sudo apt install \\\r docker-ce" + ); + assert_eq!(normalize_pasted_newlines("a\nb\nc"), "a\rb\rc"); + // A lone CR is left as-is; no doubling. + assert_eq!(normalize_pasted_newlines("a\rb"), "a\rb"); + // No newlines → unchanged. + assert_eq!(normalize_pasted_newlines("echo hi"), "echo hi"); + } +} + +#[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 index 19c4a7bc..30d9ed51 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,141 @@ //! Session / application configuration. //! //! Persists a simple JSON file under the platform's standard config dir -//! (e.g. `%APPDATA%/meatshell/sessions.json` on Windows). +//! (e.g. `%APPDATA%/meatshell/sessions.json` on Windows, +//! `~/.config/meatshell/sessions.json` on Linux/macOS). //! -//! The password field is stored in plain text for v0.1; a proper OS keychain -//! integration is tracked for a later iteration. +//! ## Password encryption +//! +//! Passwords are **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::PathBuf; +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_sidebar_width() -> f32 { + 220.0 +} +fn default_sidebar_height() -> f32 { + 240.0 +} +fn default_sftp_width() -> f32 { + 380.0 +} +fn default_sftp_height() -> f32 { + 220.0 +} +fn default_flow() -> String { + "none".to_string() +} /// How a session authenticates. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -48,11 +171,66 @@ pub struct Session { pub user: String, pub auth: AuthMethod, #[serde(default)] - pub password: String, + pub password: Secret, #[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, + /// Optional folder/group name to organize sessions in the list (#41). + /// Empty = ungrouped. Sessions are grouped by this in Quick Connect. + #[serde(default)] + pub group: String, + + // --- 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, + + // --- SSH port forwarding / tunnels (#56) -------------------------------- + /// Tunnels established automatically when this SSH session connects. + #[serde(default)] + pub forwards: Vec, +} + +/// One SSH tunnel (#56). `kind` is "local" (-L), "remote" (-R) or +/// "dynamic" (-D / SOCKS5). For local/remote, `host:host_port` is the target; +/// for dynamic it is ignored (the SOCKS client picks the destination). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PortForward { + pub kind: String, + /// Optional label to tell rules apart (#100). Empty = unnamed. + #[serde(default)] + pub name: String, + /// Listener bind address (local side for L/D, remote side for R). + /// Empty → 127.0.0.1. + #[serde(default)] + pub bind_addr: String, + pub bind_port: u16, + #[serde(default)] + pub host: String, + #[serde(default)] + pub host_port: u16, } impl Session { @@ -64,13 +242,34 @@ impl Session { port: 22, user: "root".into(), auth: AuthMethod::Password, - password: String::new(), + password: Secret::default(), private_key_path: String::new(), + proxy: String::new(), last_used: None, + group: String::new(), + 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(), + forwards: Vec::new(), } } } +/// A saved quick command (#55): a named snippet the user clicks to send to the +/// active terminal. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct QuickCommand { + pub name: String, + pub command: String, + /// Optional group/folder name. Empty = the implicit "default" group (#55). + #[serde(default)] + pub group: String, +} + /// On-disk layout. Keep additive to ease forward-compat. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigFile { @@ -79,30 +278,232 @@ pub struct ConfigFile { /// 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, + /// Terminal font family. Empty = the built-in default ("Meatshell Mono"). + #[serde(default)] + pub font_family: String, + /// Terminal font size in px. 0 = the built-in default. + #[serde(default)] + pub font_size: u32, + /// Global UI scale in percent (#100). 0 = default (100%). + #[serde(default)] + pub ui_scale: u32, + /// Explicit session groups/folders (#41), including empty ones so a folder + /// can exist before any session is moved into it. "default" is implicit and + /// not stored here. + #[serde(default)] + pub groups: Vec, + /// Stored inverted ("don't follow") so both serde and the Default derive + /// yield `false` = the feature defaults to ON: the SFTP panel follows the + /// terminal's cd (OSC 7) unless the user opts out in Interface settings. + #[serde(default)] + pub sftp_no_follow_cd: bool, + /// Always prompt for the save location on each download instead of using the + /// preset download dir. Defaults to false (#87). + #[serde(default)] + pub download_always_ask: bool, + /// Saved quick commands (#55). + #[serde(default)] + pub quick_commands: Vec, + /// Explicit quick-command group names — mirrors `groups` for sessions so that + /// empty quick-command groups survive and can be renamed/deleted (#55). + #[serde(default)] + pub quick_groups: Vec, + /// Recent commands sent from the command box, oldest first, capped (#55). + #[serde(default)] + pub command_history: Vec, + /// Collapse the left resource sidebar on startup (#78). + #[serde(default)] + pub collapse_sidebar_default: bool, + /// User-adjustable width of the left resource sidebar, in logical pixels. + /// Persisted across restarts so the drag-resized width sticks. + #[serde(default = "default_sidebar_width")] + pub sidebar_width: f32, + /// Resource-panel docking: size when docked top/bottom, and which edge it is + /// docked to (left|right|top|bottom). Persisted so the layout sticks (#dock). + #[serde(default = "default_sidebar_height")] + pub sidebar_height: f32, + #[serde(default)] + pub sidebar_dock: String, + /// SFTP-panel docking: extents (px) and docked edge, persisted (#dock). + #[serde(default = "default_sftp_width")] + pub sftp_panel_width: f32, + #[serde(default = "default_sftp_height")] + pub sftp_panel_height: f32, + #[serde(default)] + pub sftp_dock: String, + /// Last window size in logical px (0 = unset → use the built-in default). + /// Lets users keep their preferred window size across restarts. + #[serde(default)] + pub window_width: f32, + #[serde(default)] + pub window_height: f32, + /// Collapse the bottom SFTP panel on startup (#78). + #[serde(default)] + pub collapse_sftp_default: bool, + /// When session-sync is on, also mirror SFTP uploads to the other online + /// sessions (same path, falling back to each panel's current dir). + #[serde(default)] + pub sync_upload: bool, +} + +/// Portable export file (issue #46): sessions with everything in plaintext +/// **except** the password, which is encrypted with a fixed key baked into the +/// binary so the file opens on *any* machine running meatshell. +/// +/// Security note: a built-in key in open-source code is **obfuscation, not real +/// security** — anyone with the source can derive it. It only stops a casual +/// over-the-shoulder read of the file, same level as FinalShell's export. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ExportFile { + /// Format marker / version so the schema can evolve later. + meatshell_export: u32, + sessions: 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], +} + +/// Remove duplicate entries in place, keeping the *last* (most recent) +/// occurrence of each and preserving relative order (#113). The list is capped +/// at 200, so the quadratic scan is trivial. +fn dedup_keep_last(items: &mut Vec) { + let mut i = 0; + while i < items.len() { + if items[i + 1..].contains(&items[i]) { + items.remove(i); + } else { + i += 1; + } + } } impl ConfigStore { + /// The prefix that marks an encrypted password blob in sessions.json. + const ENC_PREFIX: &'static str = "enc:v1:"; + + /// Marks a password encrypted with the **portable export key** (issue #46). + const EXPORT_PREFIX: &'static str = "enc:exp:v1:"; + + /// Fixed 32-byte key for portable exports. Baked into the binary so an + /// exported file decrypts on any machine. Obfuscation only — see `ExportFile`. + const EXPORT_KEY: [u8; 32] = *b"meatshell.export.portable.key.01"; + + // ── 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() + } + + // ── 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"); + + 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); + 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()) + })?; + } + tracing::info!("generated new encryption key at {}", key_path.display()); + Ok(key) + } + + // ── 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()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("failed to create config dir {}", parent.display()) - })?; - } + 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(cfg) => cfg, + Ok(mut cfg) => { + // Decrypt any encrypted passwords; leave legacy plaintext + // values untouched (they will be encrypted on next save). + for session in &mut cfg.sessions { + if let Some(plain) = + Self::try_decrypt(&key, session.password.as_str()) + { + session.password = Secret::new(plain); + } + } + // Clean up any duplicate history accumulated before #113, + // keeping the last (most recent) occurrence of each command. + dedup_keep_last(&mut cfg.command_history); + cfg + } Err(err) => { let backup = path.with_extension("json.broken"); let _ = fs::rename(&path, &backup); @@ -117,7 +518,7 @@ impl ConfigStore { ConfigFile::default() }; - Ok(Self { path, cache }) + Ok(Self { path, cache, key }) } fn config_path() -> Result { @@ -164,15 +565,484 @@ impl ConfigStore { 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; + } + + /// Terminal font family ("" = built-in default). + pub fn font_family(&self) -> &str { + &self.cache.font_family + } + + pub fn set_font_family(&mut self, family: String) { + self.cache.font_family = family; + } + + /// Terminal font size in px (falls back to 13 when unset). + pub fn font_size(&self) -> u32 { + if self.cache.font_size == 0 { + 13 + } else { + self.cache.font_size + } + } + + pub fn set_font_size(&mut self, size: u32) { + self.cache.font_size = size.clamp(8, 32); + } + + /// Global UI scale in percent (#100). Defaults to 100. + pub fn ui_scale(&self) -> u32 { + if self.cache.ui_scale == 0 { + 100 + } else { + self.cache.ui_scale + } + } + + pub fn set_ui_scale(&mut self, percent: u32) { + self.cache.ui_scale = percent.clamp(80, 200); + } + + /// Whether the SFTP panel follows the terminal's cd (default true). + pub fn sftp_follow_cd(&self) -> bool { + !self.cache.sftp_no_follow_cd + } + + pub fn set_sftp_follow_cd(&mut self, follow: bool) { + self.cache.sftp_no_follow_cd = !follow; + } + + /// Saved quick commands (#55). + pub fn quick_commands(&self) -> &[QuickCommand] { + &self.cache.quick_commands + } + + pub fn set_quick_commands(&mut self, cmds: Vec) { + self.cache.quick_commands = cmds; + } + + /// Explicit quick-command groups (#55) — parallels [`groups`](Self::groups). + pub fn quick_groups(&self) -> &[String] { + &self.cache.quick_groups + } + + /// Create an empty quick-command group. Ignores blank, "default", duplicates. + pub fn add_quick_group(&mut self, name: String) { + let n = name.trim().to_string(); + if n.is_empty() || n.eq_ignore_ascii_case("default") { + return; + } + if !self.cache.quick_groups.iter().any(|g| g == &n) { + self.cache.quick_groups.push(n); + } + } + + /// Delete a quick-command group; any command still in it falls back to + /// ungrouped (the UI only offers delete on empty groups, but clear defensively). + pub fn remove_quick_group(&mut self, name: &str) { + self.cache.quick_groups.retain(|g| g != name); + for c in &mut self.cache.quick_commands { + if c.group == name { + c.group.clear(); + } + } + } + + /// Rename a quick-command group, moving its commands along. No-op for + /// blank / "default". + pub fn rename_quick_group(&mut self, old: &str, new: String) { + let n = new.trim().to_string(); + if n.is_empty() || n.eq_ignore_ascii_case("default") || n == old { + return; + } + for g in &mut self.cache.quick_groups { + if g == old { + *g = n.clone(); + } + } + for c in &mut self.cache.quick_commands { + if c.group == old { + c.group = n.clone(); + } + } + self.cache.quick_groups.sort(); + self.cache.quick_groups.dedup(); + } + + /// Update one quick command in place by index (#55). + pub fn update_quick_command(&mut self, index: usize, cmd: QuickCommand) { + if let Some(slot) = self.cache.quick_commands.get_mut(index) { + *slot = cmd; + } + } + + /// Recent command-box history, oldest first (#55). + pub fn command_history(&self) -> &[String] { + &self.cache.command_history + } + + /// Append a command to the history: skips blanks, de-duplicates globally so + /// each command appears once, and re-appends at the end so the most-recently + /// used command is always last. Capped so it can't grow without bound (#113). + pub fn push_command_history(&mut self, cmd: String) { + if cmd.trim().is_empty() { + return; + } + // Drop any earlier occurrence, then push → no duplicates and "last used" + // moves to the end (bash `HISTCONTROL=erasedups` semantics). + self.cache.command_history.retain(|c| c != &cmd); + const CAP: usize = 200; + self.cache.command_history.push(cmd); + let len = self.cache.command_history.len(); + if len > CAP { + self.cache.command_history.drain(0..len - CAP); + } + } + + /// Remove a single command-history entry by storage index (#96). + pub fn remove_command_history(&mut self, index: usize) { + if index < self.cache.command_history.len() { + self.cache.command_history.remove(index); + } + } + + /// Collapse the resource sidebar on startup (default false) (#78). + pub fn collapse_sidebar_default(&self) -> bool { + self.cache.collapse_sidebar_default + } + + pub fn set_collapse_sidebar_default(&mut self, v: bool) { + self.cache.collapse_sidebar_default = v; + } + + /// Persisted sidebar width in logical px. Falls back to the default when the + /// stored value is unset/zero (e.g. a config created via `Default`). + pub fn sidebar_width(&self) -> f32 { + let w = self.cache.sidebar_width; + if w <= 0.0 { + default_sidebar_width() + } else { + w + } + } + + pub fn set_sidebar_width(&mut self, v: f32) { + self.cache.sidebar_width = v; + } + + /// Resource / SFTP panel docking geometry, persisted across restarts (#dock). + /// Sizes fall back to their defaults when unset/zero; docks fall back to a + /// sensible edge when the stored string is empty. + pub fn sidebar_height(&self) -> f32 { + let h = self.cache.sidebar_height; + if h <= 0.0 { default_sidebar_height() } else { h } + } + pub fn set_sidebar_height(&mut self, v: f32) { + self.cache.sidebar_height = v; + } + pub fn sidebar_dock(&self) -> String { + let d = self.cache.sidebar_dock.trim(); + if d.is_empty() { "left".into() } else { d.to_string() } + } + pub fn set_sidebar_dock(&mut self, v: String) { + self.cache.sidebar_dock = v; + } + pub fn sftp_panel_width(&self) -> f32 { + let w = self.cache.sftp_panel_width; + if w <= 0.0 { default_sftp_width() } else { w } + } + pub fn set_sftp_panel_width(&mut self, v: f32) { + self.cache.sftp_panel_width = v; + } + pub fn sftp_panel_height(&self) -> f32 { + let h = self.cache.sftp_panel_height; + if h <= 0.0 { default_sftp_height() } else { h } + } + pub fn set_sftp_panel_height(&mut self, v: f32) { + self.cache.sftp_panel_height = v; + } + pub fn sftp_dock(&self) -> String { + let d = self.cache.sftp_dock.trim(); + if d.is_empty() { "bottom".into() } else { d.to_string() } + } + pub fn set_sftp_dock(&mut self, v: String) { + self.cache.sftp_dock = v; + } + /// Last window size in logical px; `(0,0)` means unset (use the default). + pub fn window_size(&self) -> (f32, f32) { + (self.cache.window_width, self.cache.window_height) + } + pub fn set_window_size(&mut self, w: f32, h: f32) { + self.cache.window_width = w; + self.cache.window_height = h; + } + + /// Collapse the SFTP panel on startup (default false) (#78). + pub fn collapse_sftp_default(&self) -> bool { + self.cache.collapse_sftp_default + } + + pub fn set_collapse_sftp_default(&mut self, v: bool) { + self.cache.collapse_sftp_default = v; + } + + /// Mirror SFTP uploads to other sessions while session-sync is on (default + /// false). Only has effect when the session-sync toggle is on. + pub fn sync_upload(&self) -> bool { + self.cache.sync_upload + } + + pub fn set_sync_upload(&mut self, v: bool) { + self.cache.sync_upload = v; + } + + /// Whether each download prompts for a save location (default false) (#87). + pub fn download_always_ask(&self) -> bool { + self.cache.download_always_ask + } + + pub fn set_download_always_ask(&mut self, ask: bool) { + self.cache.download_always_ask = ask; + } + + // ── Session groups / folders (#41) ──────────────────────────────────── + + /// Explicit groups (empty folders included). "default" is implicit. + pub fn groups(&self) -> &[String] { + &self.cache.groups + } + + /// Create an empty group. Ignores blank names, the reserved "default", and + /// duplicates. + pub fn add_group(&mut self, name: String) { + let n = name.trim().to_string(); + if n.is_empty() || n.eq_ignore_ascii_case("default") { + return; + } + if !self.cache.groups.iter().any(|g| g == &n) { + self.cache.groups.push(n); + } + } + + /// Delete a group. Any session still in it falls back to ungrouped — the UI + /// only offers delete on empty groups, but we clear sessions defensively. + pub fn remove_group(&mut self, name: &str) { + self.cache.groups.retain(|g| g != name); + for s in &mut self.cache.sessions { + if s.group == name { + s.group.clear(); + } + } + } + + /// Rename a group, moving its sessions along. No-op for blank / "default". + pub fn rename_group(&mut self, old: &str, new: String) { + let n = new.trim().to_string(); + if n.is_empty() || n.eq_ignore_ascii_case("default") || n == old { + return; + } + for g in &mut self.cache.groups { + if g == old { + *g = n.clone(); + } + } + for s in &mut self.cache.sessions { + if s.group == old { + s.group = n.clone(); + } + } + self.cache.groups.sort(); + self.cache.groups.dedup(); + } + pub fn save(&self) -> Result<()> { - let raw = serde_json::to_string_pretty(&self.cache)?; - // Write to a sibling temp file then rename — cheap atomicity on most - // platforms. Good enough for a config file. + // Build a disk copy where every non-empty password is encrypted. + 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 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) + fs::write(&tmp, &raw) .with_context(|| format!("failed to write {}", tmp.display()))?; + // Restrict to owner-only before publishing (#34): sessions.json holds + // (encrypted) credentials, so it shouldn't be world-readable. Set 0600 + // on the temp file so the permission is already in place at rename. + // Windows %APPDATA% is owner-restricted by default ACLs — no-op there. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to set permissions on {}", tmp.display()))?; + } fs::rename(&tmp, &self.path) .with_context(|| format!("failed to finalise {}", self.path.display()))?; Ok(()) } + + // ── Portable export / import (issue #46) ────────────────────────────── + + /// Encrypt a password with the portable export key → `"enc:exp:v1:"`. + fn encrypt_export(plaintext: &str) -> Result { + let cipher = ChaCha20Poly1305::new((&Self::EXPORT_KEY).into()); + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|e| anyhow::anyhow!("export encrypt error: {e}"))?; + let mut blob = nonce.to_vec(); + blob.extend_from_slice(&ciphertext); + Ok(format!("{}{}", Self::EXPORT_PREFIX, URL_SAFE_NO_PAD.encode(&blob))) + } + + /// Decrypt a value produced by [`Self::encrypt_export`]; `None` if it isn't one. + fn decrypt_export(s: &str) -> Option { + let b64 = s.strip_prefix(Self::EXPORT_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((&Self::EXPORT_KEY).into()); + let nonce = chacha20poly1305::Nonce::from_slice(nonce_bytes); + let plain = cipher.decrypt(nonce, ciphertext).ok()?; + String::from_utf8(plain).ok() + } + + /// Export all sessions to a portable JSON file. Passwords are re-encrypted + /// with the built-in export key; everything else stays plaintext so the + /// file is human-readable and editable. Returns the number of sessions. + pub fn export_to(&self, path: &Path) -> Result { + let mut out = ExportFile { + meatshell_export: 1, + sessions: self.cache.sessions.clone(), + }; + for s in &mut out.sessions { + // `cache` holds plaintext passwords; obfuscate with the export key. + if !s.password.is_empty() { + let enc = Self::encrypt_export(s.password.as_str())?; + s.password = Secret::new(enc); + } + // `last_used` is machine-local noise — don't carry it across. + s.last_used = None; + } + let raw = serde_json::to_string_pretty(&out)?; + fs::write(path, raw).with_context(|| format!("failed to write {}", path.display()))?; + Ok(out.sessions.len()) + } + + /// Import sessions from a file produced by [`Self::export_to`]. New sessions + /// get fresh ids; duplicates (same host+user+port+kind) are skipped. + /// Returns `(added, skipped)`. The store is saved if anything was added. + pub fn import_from(&mut self, path: &Path) -> Result<(usize, usize)> { + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let file: ExportFile = serde_json::from_str(&raw) + .context("not a valid meatshell export file")?; + + let mut added = 0usize; + let mut skipped = 0usize; + for mut s in file.sessions { + // Recover the plaintext password (cache stores plaintext). Accept an + // export blob, our local enc:v1 blob, or a legacy plaintext value. + if let Some(plain) = Self::decrypt_export(s.password.as_str()) { + s.password = Secret::new(plain); + } else if let Some(plain) = Self::try_decrypt(&self.key, s.password.as_str()) { + s.password = Secret::new(plain); + } + let dup = self.cache.sessions.iter().any(|x| { + x.host == s.host && x.user == s.user && x.port == s.port && x.kind == s.kind + }); + if dup { + skipped += 1; + continue; + } + s.id = Uuid::new_v4().to_string(); + self.cache.sessions.push(s); + added += 1; + } + if added > 0 { + self.save()?; + } + Ok((added, skipped)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store() -> ConfigStore { + let path = std::env::temp_dir().join(format!("ms-test-{}.json", Uuid::new_v4())); + ConfigStore { + path, + cache: ConfigFile::default(), + key: [7u8; 32], + } + } + + #[test] + fn export_import_roundtrip_preserves_password() { + let mut a = temp_store(); + a.cache.sessions.push(Session { + name: "pve".into(), + host: "192.168.100.2".into(), + port: 22, + user: "root".into(), + password: Secret::new("s3cr3t"), + ..Session::new_empty() + }); + + let export_path = + std::env::temp_dir().join(format!("ms-exp-{}.json", Uuid::new_v4())); + assert_eq!(a.export_to(&export_path).unwrap(), 1); + + // The file keeps host/user plaintext but the password is obfuscated. + let raw = std::fs::read_to_string(&export_path).unwrap(); + assert!(raw.contains("192.168.100.2")); + assert!(raw.contains(ConfigStore::EXPORT_PREFIX)); + assert!(!raw.contains("s3cr3t")); + + // Importing into a fresh store recovers the plaintext password. + let mut b = temp_store(); + assert_eq!(b.import_from(&export_path).unwrap(), (1, 0)); + assert_eq!(b.cache.sessions.len(), 1); + assert_eq!(b.cache.sessions[0].password.as_str(), "s3cr3t"); + assert_eq!(b.cache.sessions[0].host, "192.168.100.2"); + + // Re-importing the same file skips the duplicate. + assert_eq!(b.import_from(&export_path).unwrap(), (0, 1)); + + let _ = std::fs::remove_file(&export_path); + let _ = std::fs::remove_file(&a.path); + let _ = std::fs::remove_file(&b.path); + } } diff --git a/src/errlog.rs b/src/errlog.rs new file mode 100644 index 00000000..8ea43a7b --- /dev/null +++ b/src/errlog.rs @@ -0,0 +1,85 @@ +//! A single, size-capped diagnostic log file (#86). +//! +//! Writes go to `/error.log`. The file is capped at a fixed size: +//! when the next write would exceed the cap it is truncated to empty and writing +//! restarts from the top — so there is always exactly one file, at most `cap` +//! bytes, that auto-overwrites its old content. This lets users (e.g. behind a +//! bastion) send their disconnect reason without setting RUST_LOG. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// `/error.log`, next to `sessions.json`. +pub fn path() -> Option { + let dirs = directories::ProjectDirs::from("dev", "meatshell", "meatshell")?; + let dir = dirs.config_dir().to_path_buf(); + let _ = std::fs::create_dir_all(&dir); + Some(dir.join("error.log")) +} + +/// One log file capped at `cap` bytes (truncate-and-restart when full). +pub struct CappedFile { + path: PathBuf, + file: File, + written: u64, + cap: u64, +} + +impl CappedFile { + pub fn open(path: PathBuf, cap: u64) -> io::Result { + let file = OpenOptions::new().create(true).append(true).open(&path)?; + let written = file.metadata().map(|m| m.len()).unwrap_or(0); + Ok(Self { + path, + file, + written, + cap, + }) + } +} + +impl Write for CappedFile { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.written.saturating_add(buf.len() as u64) > self.cap { + // Truncate to empty and start over so we never exceed the cap. + self.file = File::create(&self.path)?; + self.written = 0; + } + let n = self.file.write(buf)?; + self.written += n as u64; + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} + +/// `MakeWriter` over a shared [`CappedFile`] so a tracing fmt layer can use it. +#[derive(Clone)] +pub struct CappedWriter(Arc>); + +impl CappedWriter { + pub fn new(cf: CappedFile) -> Self { + Self(Arc::new(Mutex::new(cf))) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CappedWriter { + type Writer = Guard<'a>; + fn make_writer(&'a self) -> Self::Writer { + Guard(self.0.lock().unwrap_or_else(|e| e.into_inner())) + } +} + +pub struct Guard<'a>(std::sync::MutexGuard<'a, CappedFile>); + +impl Write for Guard<'_> { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} diff --git a/src/forward.rs b/src/forward.rs new file mode 100644 index 00000000..4dc9a1aa --- /dev/null +++ b/src/forward.rs @@ -0,0 +1,209 @@ +//! SSH port forwarding / tunnels (#56). +//! +//! Local (-L) and dynamic (-D / SOCKS5) forwards run client-side: we listen on +//! a local TCP port and, per inbound connection, open a `direct-tcpip` channel +//! on the SSH session, then splice the two streams together. Remote (-R) +//! forwards are requested with `tcpip_forward` and serviced in the session +//! handler when the server opens channels back (see `ssh.rs`). + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; + +use russh::client::{Handle, Msg}; +use russh::Channel; +use tokio::io::{copy_bidirectional, AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc::UnboundedSender; +use tokio::task::JoinHandle; + +use crate::ssh::{ClientHandler, SessionEvent}; + +/// Emit a one-line notice into the terminal output stream. +fn notice(events: &UnboundedSender, msg: String) { + let _ = events.send(SessionEvent::Output(format!("\r\n[meatshell] {msg}\r\n"))); +} + +fn bind_target(bind_addr: &str, bind_port: u16) -> String { + let addr = if bind_addr.trim().is_empty() { + "127.0.0.1" + } else { + bind_addr.trim() + }; + // IPv6 literals must be bracketed for TcpListener::bind ("[::1]:8080"); + // an already-bracketed address is left as-is (#109). + if addr.contains(':') && !addr.starts_with('[') { + format!("[{addr}]:{bind_port}") + } else { + format!("{addr}:{bind_port}") + } +} + +/// Open a `direct-tcpip` channel to `host:port`, recording the originating peer +/// (some servers log / ACL on it). +async fn open_direct( + handle: &Arc>, + host: &str, + port: u16, + peer: SocketAddr, +) -> Result, russh::Error> { + handle + .channel_open_direct_tcpip( + host.to_string(), + port as u32, + peer.ip().to_string(), + peer.port() as u32, + ) + .await +} + +/// Local forward (-L): listen locally and tunnel each connection to +/// `target_host:target_port` reached from the SSH server's side. +pub fn spawn_local( + handle: Arc>, + bind_addr: String, + bind_port: u16, + target_host: String, + target_port: u16, + events: UnboundedSender, +) -> JoinHandle<()> { + let bind = bind_target(&bind_addr, bind_port); + tokio::spawn(async move { + let listener = match TcpListener::bind(&bind).await { + Ok(l) => l, + Err(e) => { + notice(&events, format!("-L {bind} 监听失败 / bind failed: {e}")); + return; + } + }; + notice(&events, format!("-L {bind} → {target_host}:{target_port}")); + loop { + let (mut inbound, peer) = match listener.accept().await { + Ok(v) => v, + Err(_) => break, + }; + let handle = handle.clone(); + let host = target_host.clone(); + let ev = events.clone(); + tokio::spawn(async move { + match open_direct(&handle, &host, target_port, peer).await { + Ok(ch) => { + let mut stream = ch.into_stream(); + let _ = copy_bidirectional(&mut inbound, &mut stream).await; + } + Err(e) => notice(&ev, format!("-L {host}:{target_port} 连接失败 / open failed: {e}")), + } + }); + } + }) +} + +/// Dynamic forward (-D): a minimal SOCKS5 proxy. Each accepted connection +/// negotiates SOCKS5 (no auth, CONNECT only), then we open a `direct-tcpip` +/// channel to the requested destination and splice. +pub fn spawn_dynamic( + handle: Arc>, + bind_addr: String, + bind_port: u16, + events: UnboundedSender, +) -> JoinHandle<()> { + let bind = bind_target(&bind_addr, bind_port); + tokio::spawn(async move { + let listener = match TcpListener::bind(&bind).await { + Ok(l) => l, + Err(e) => { + notice(&events, format!("-D {bind} 监听失败 / bind failed: {e}")); + return; + } + }; + notice(&events, format!("-D {bind} (SOCKS5)")); + loop { + let (inbound, peer) = match listener.accept().await { + Ok(v) => v, + Err(_) => break, + }; + let handle = handle.clone(); + let ev = events.clone(); + tokio::spawn(async move { + if let Err(e) = socks5_serve(&handle, inbound, peer).await { + tracing::debug!("socks5 conn ended: {e}"); + let _ = ev; // notices for SOCKS are too noisy; keep to trace + } + }); + } + }) +} + +/// Handle one SOCKS5 client connection end-to-end. +async fn socks5_serve( + handle: &Arc>, + mut inbound: TcpStream, + peer: SocketAddr, +) -> std::io::Result<()> { + // Greeting: VER, NMETHODS, METHODS[NMETHODS]. + let mut head = [0u8; 2]; + inbound.read_exact(&mut head).await?; + if head[0] != 0x05 { + return Ok(()); // not SOCKS5 + } + let nmethods = head[1] as usize; + let mut methods = vec![0u8; nmethods]; + inbound.read_exact(&mut methods).await?; + // Reply: VER=5, METHOD=0 (no authentication). + inbound.write_all(&[0x05, 0x00]).await?; + + // Request: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT. + let mut req = [0u8; 4]; + inbound.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Ok(()); + } + if req[1] != 0x01 { + // Only CONNECT is supported → reply "command not supported". + let _ = inbound.write_all(&socks_reply(0x07)).await; + return Ok(()); + } + let host = match req[3] { + 0x01 => { + let mut a = [0u8; 4]; + inbound.read_exact(&mut a).await?; + Ipv4Addr::from(a).to_string() + } + 0x04 => { + let mut a = [0u8; 16]; + inbound.read_exact(&mut a).await?; + Ipv6Addr::from(a).to_string() + } + 0x03 => { + let mut len = [0u8; 1]; + inbound.read_exact(&mut len).await?; + let mut d = vec![0u8; len[0] as usize]; + inbound.read_exact(&mut d).await?; + String::from_utf8_lossy(&d).into_owned() + } + _ => { + let _ = inbound.write_all(&socks_reply(0x08)).await; // addr type unsupported + return Ok(()); + } + }; + let mut port = [0u8; 2]; + inbound.read_exact(&mut port).await?; + let port = u16::from_be_bytes(port); + + match open_direct(handle, &host, port, peer).await { + Ok(ch) => { + inbound.write_all(&socks_reply(0x00)).await?; // succeeded + let mut stream = ch.into_stream(); + let _ = copy_bidirectional(&mut inbound, &mut stream).await; + } + Err(_) => { + let _ = inbound.write_all(&socks_reply(0x05)).await; // connection refused + } + } + Ok(()) +} + +/// A SOCKS5 reply with the given reply code and a zeroed bound address +/// (`0.0.0.0:0`) — clients don't need the real bound address for CONNECT. +fn socks_reply(code: u8) -> [u8; 10] { + [0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0] +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 00000000..9c67344d --- /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/known_hosts.rs b/src/known_hosts.rs new file mode 100644 index 00000000..f6e60137 --- /dev/null +++ b/src/known_hosts.rs @@ -0,0 +1,124 @@ +//! Host-key verification store (#109-5 / #105). +//! +//! Replaces the old "accept any server key" behaviour with a TOFU-style +//! known_hosts file plus a first-connect confirmation dialog: +//! • unknown host → prompt the user with the key fingerprint; on accept the +//! key is remembered here. +//! • known + match → connect silently. +//! • known + differ → flagged as *changed* (possible MITM); the user must +//! re-confirm before the new key replaces the stored one. +//! +//! The file lives next to `sessions.json` (one entry per line): +//! `host:port ssh-ed25519 AAAA...` +//! i.e. the `host:port` id followed by the key in its OpenSSH one-line form. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use ssh_key::{HashAlg, PublicKey}; + +/// Result of checking a server key against the store. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKeyStatus { + /// No entry for this host:port — first time we've seen it. + Unknown, + /// Stored key matches the presented one — trusted. + Match, + /// A key is stored for this host:port but it differs (possible MITM). + Changed, +} + +/// `host:port` lookup key. +fn id(host: &str, port: u16) -> String { + format!("{host}:{port}") +} + +/// Path to the known_hosts file (alongside sessions.json). `None` if the user +/// config directory can't be determined. +fn path() -> Option { + let dirs = ProjectDirs::from("dev", "meatshell", "meatshell")?; + Some(dirs.config_dir().join("known_hosts")) +} + +/// The presented key in its canonical OpenSSH one-line form (`type base64`, +/// no comment), used for exact comparison and for storage. +fn openssh_line(key: &PublicKey) -> String { + // `to_openssh` only fails on an unsupported/!encodable key, which russh + // would not have negotiated; fall back to the SHA256 fingerprint so a + // freak case still stores *something* stable rather than panicking. + key.to_openssh() + .unwrap_or_else(|_| fingerprint(key)) +} + +/// Human-readable SHA256 fingerprint (`SHA256:base64…`) shown in the dialog. +pub fn fingerprint(key: &PublicKey) -> String { + key.fingerprint(HashAlg::Sha256).to_string() +} + +/// Parse the file into `(id, openssh_key)` entries. Missing file → empty. +/// Malformed / comment (`#`) lines are skipped. +fn load() -> Vec<(String, String)> { + let Some(p) = path() else { return Vec::new() }; + let Ok(text) = std::fs::read_to_string(&p) else { + return Vec::new(); + }; + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (id, key) = line.split_once(char::is_whitespace)?; + Some((id.to_string(), key.trim().to_string())) + }) + .collect() +} + +/// Check a presented server key against the store. +pub fn verify(host: &str, port: u16, key: &PublicKey) -> HostKeyStatus { + let want = openssh_line(key); + let id = id(host, port); + let mut seen_host = false; + for (entry_id, entry_key) in load() { + if entry_id != id { + continue; + } + seen_host = true; + if entry_key == want { + return HostKeyStatus::Match; + } + } + if seen_host { + HostKeyStatus::Changed + } else { + HostKeyStatus::Unknown + } +} + +/// Remember (or replace) the key for `host:port`. Rewrites the file with any +/// stale entry for the same id removed, then appends the new one. +pub fn remember(host: &str, port: u16, key: &PublicKey) -> Result<()> { + let p = path().context("could not determine config directory")?; + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).context("create config dir")?; + } + let id = id(host, port); + let line = openssh_line(key); + let mut out = String::new(); + for (entry_id, entry_key) in load() { + if entry_id == id { + continue; // drop the old key for this host:port + } + out.push_str(&entry_id); + out.push(' '); + out.push_str(&entry_key); + out.push('\n'); + } + out.push_str(&id); + out.push(' '); + out.push_str(&line); + out.push('\n'); + std::fs::write(&p, out).with_context(|| format!("write {}", p.display()))?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 8674052c..2c0661db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,15 +5,37 @@ mod app; mod config; +mod errlog; +mod forward; +mod i18n; +mod known_hosts; +mod proxy; +mod serial; mod sftp; mod ssh; +mod ssh_config; mod system; +mod telnet; +mod zmodem; fn main() -> anyhow::Result<()> { - // Initialise tracing — honour RUST_LOG but default to info. - let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - tracing_subscriber::fmt().with_env_filter(filter).init(); + // macOS renderer is left at Slint's default (femtovg) and is NOT forced. + // + // History: 0.4.10 force-set SLINT_BACKEND=winit-skia to work around femtovg's + // CoreText font lookup failing on macOS 26 / Tahoe (all text vanished, #108). + // That fix shipped without on-device verification and turned out to *break* a + // different set of Macs (Apple Silicon M5 / 26.5): Skia couldn't resolve the + // "PingFang SC" UI font and all text vanished there instead (#129). Icons + // survived in both cases because Material Icons is an embedded font. + // + // Neither renderer works for every macOS machine, so we no longer pick for the + // user: femtovg is the known-good default for the majority. Users for whom + // femtovg fails to render text (e.g. #108) can opt into Skia at launch with + // SLINT_BACKEND=winit-skia + // The renderer-skia feature is still compiled in on macOS (see Cargo.toml) so + // that override is available without a rebuild. + + init_tracing(); // ── IME policy ─────────────────────────────────────────────────────────── // NOTE: We deliberately DO **NOT** call `ImmDisableIME` here. @@ -32,3 +54,51 @@ fn main() -> anyhow::Result<()> { app::run() } + +/// Set up tracing: stderr (honours RUST_LOG, default info) **plus** a capped +/// `error.log` file at WARN and above so users can send diagnostics — e.g. a +/// bastion disconnect reason — without setting RUST_LOG (#86). +fn init_tracing() { + use tracing_subscriber::prelude::*; + use tracing_subscriber::{fmt, EnvFilter}; + + // Third-party noise routed through `log` → tracing: ICU4X data-error warnings + // (icu_provider dependency) and fontdb's "malformed font" warning for fonts it + // can't parse but harmlessly skips (e.g. Windows' mstmc.ttf). Silence on every + // layer; keep fontdb at `error` so genuine failures still surface. + fn quiet_noise(mut f: EnvFilter) -> EnvFilter { + for d in [ + "icu_provider=off", + "icu_segmenter=off", + "icu_normalizer=off", + "fontdb=error", + ] { + if let Ok(dir) = d.parse() { + f = f.add_directive(dir); + } + } + f + } + + let env_filter = quiet_noise( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ); + let stderr_layer = fmt::layer() + .with_writer(std::io::stderr) + .with_filter(env_filter); + + // One file, capped at 5 MiB, auto-overwriting when full. + let file_layer = errlog::path() + .and_then(|p| errlog::CappedFile::open(p, 5 * 1024 * 1024).ok()) + .map(|cf| { + fmt::layer() + .with_ansi(false) + .with_writer(errlog::CappedWriter::new(cf)) + .with_filter(quiet_noise(EnvFilter::new("warn"))) + }); + + tracing_subscriber::registry() + .with(stderr_layer) + .with(file_layer) + .init(); +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 00000000..bd74cb89 --- /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 00000000..2f78b97e --- /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 index 3ab163d6..c0475c34 100644 --- a/src/sftp.rs +++ b/src/sftp.rs @@ -9,24 +9,29 @@ //! via the shared `UnboundedSender` that already exists for the //! terminal tab. +use std::collections::HashMap; use std::path::Path; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; 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::SftpSession; -use ssh_key::PublicKey; +use ssh_key::{HashAlg, PublicKey}; +use russh_sftp::client::error::Error as SftpError; +use russh_sftp::client::{RawSftpSession, SftpSession}; +use russh_sftp::protocol::{FileAttributes, OpenFlags, StatusCode}; +use futures::stream::{FuturesUnordered, StreamExt}; 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}; // --------------------------------------------------------------------------- @@ -42,13 +47,36 @@ pub enum SftpCommand { ToggleTreeNode(String), /// Download a remote file to a local directory. Download { remote: String, local_dir: String }, + /// Multi-select download (#100): tar the named entries under `remote_dir` + /// into one archive on the remote, download it, then delete the temp. + DownloadArchive { + remote_dir: String, + names: Vec, + local_dir: String, + }, + /// Cancel an in-progress transfer by its id (#100). The partial local file + /// (and any remote temp archive) are cleaned up. + CancelTransfer(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), - /// 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. + /// Download a file to a temp dir and open it with the OS default app + /// ("Open/Edit externally", #81). When `edit` is set, watch the temp copy + /// and re-upload on every change. OpenTemp { remote: String, edit: bool }, + /// Rename / move a remote file or directory (#69). + Rename { from: String, to: String }, + /// Change a remote path's permission bits (POSIX mode, e.g. 0o755) (#69). + Chmod { path: String, mode: u32 }, + /// Create an empty remote directory (#69). + MkDir(String), + /// Create an empty remote file (#69). + TouchFile(String), + /// Read a remote file's text for the built-in viewer/editor (#70). + ReadText { remote: String, edit: bool }, + /// Overwrite a remote file with text from the built-in editor (#70). + WriteText { remote: String, content: String }, /// Gracefully shut down the SFTP worker. Close, } @@ -65,10 +93,24 @@ impl SftpHandle { 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 }); + let _ = self + .commands + .send(SftpCommand::Download { remote, local_dir }); + } + pub fn download_archive(&self, remote_dir: String, names: Vec, local_dir: String) { + let _ = self.commands.send(SftpCommand::DownloadArchive { + remote_dir, + names, + local_dir, + }); + } + pub fn cancel_transfer(&self, id: String) { + let _ = self.commands.send(SftpCommand::CancelTransfer(id)); } pub fn upload(&self, local: String, remote_dir: String) { - let _ = self.commands.send(SftpCommand::Upload { local, remote_dir }); + let _ = self + .commands + .send(SftpCommand::Upload { local, remote_dir }); } pub fn toggle_tree_node(&self, path: String) { let _ = self.commands.send(SftpCommand::ToggleTreeNode(path)); @@ -79,6 +121,24 @@ impl SftpHandle { pub fn open_temp(&self, remote: String, edit: bool) { let _ = self.commands.send(SftpCommand::OpenTemp { remote, edit }); } + pub fn rename(&self, from: String, to: String) { + let _ = self.commands.send(SftpCommand::Rename { from, to }); + } + pub fn chmod(&self, path: String, mode: u32) { + let _ = self.commands.send(SftpCommand::Chmod { path, mode }); + } + pub fn mkdir(&self, path: String) { + let _ = self.commands.send(SftpCommand::MkDir(path)); + } + pub fn touch(&self, path: String) { + let _ = self.commands.send(SftpCommand::TouchFile(path)); + } + pub fn read_text(&self, remote: String, edit: bool) { + let _ = self.commands.send(SftpCommand::ReadText { remote, edit }); + } + pub fn write_text(&self, remote: String, content: String) { + let _ = self.commands.send(SftpCommand::WriteText { remote, content }); + } pub fn close(&self) { let _ = self.commands.send(SftpCommand::Close); } @@ -104,12 +164,13 @@ pub fn spawn_sftp( let events_err = events.clone(); let join = runtime.spawn(async move { if let Err(err) = run_sftp(session, cmd_rx, self_tx, events).await { - let _ = events_err.send(SessionEvent::SftpStatus( - format!("SFTP 错误: {err:#}"), - )); + let _ = events_err.send(SessionEvent::SftpStatus(format!("{}: {err:#}", t("SFTP 错误", "SFTP error")))); } }); - SftpHandle { commands: cmd_tx, join } + SftpHandle { + commands: cmd_tx, + join, + } } // --------------------------------------------------------------------------- @@ -136,7 +197,13 @@ fn build_tree_nodes( 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 }); + 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 { @@ -156,7 +223,7 @@ async fn run_sftp( self_tx: UnboundedSender, events: UnboundedSender, ) -> Result<()> { - let _ = events.send(SessionEvent::SftpStatus("SFTP 连接中...".into())); + let _ = events.send(SessionEvent::SftpStatus(t("SFTP 连接中...", "SFTP connecting...").into())); // Open a dedicated SSH connection for SFTP. let config = Arc::new(client::Config { @@ -165,36 +232,68 @@ async fn run_sftp( }); let addr = format!("{}:{}", session.host, session.port); - let mut handle = client::connect(config, addr.as_str(), SftpClientHandler) - .await - .with_context(|| format!("sftp connect {} failed", addr))?; + // Tunnel through the same proxy as the shell session, if configured. + 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, sftp_handler(&session, &events)) + .await + .with_context(|| format!("sftp connect {} failed", addr))? + } + None => client::connect(config, addr.as_str(), sftp_handler(&session, &events)) + .await + .with_context(|| format!("sftp connect {} failed", addr))?, + }; + + // Resolve missing username/password (shares the shell's prompt; the UI + // de-dupes by session id so SFTP doesn't prompt a second time) (#110). + let (user, password) = match crate::ssh::resolve_credentials(&session, &events).await { + Some(c) => c, + None => return Err(anyhow!(t("已取消登录", "login cancelled"))), + }; // --- Authenticate (same method as the shell session) ------------------- let authed = match session.auth { AuthMethod::Password => handle - .authenticate_password(&session.user, &session.password) + .authenticate_password(&user, password.as_str()) .await - .context("sftp password auth failed")?, + .context("sftp password auth failed")? + .success(), AuthMethod::Key => { - if session.private_key_path.trim().is_empty() { - return Err(anyhow!("私钥路径为空")); - } - let keypair = - load_secret_key(Path::new(&session.private_key_path), None) - .with_context(|| { - format!("failed to load key {}", session.private_key_path) - })?; - let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), None) - .context("invalid private 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); + // An encrypted private key needs its passphrase; reuse the session's + // password field for it (empty = unencrypted), exactly like the shell + // session does — otherwise a passphrase-protected key authenticates the + // shell but fails SFTP with "the key is encrypted" (#133). + let pass = password.as_str(); + let keypair = load_secret_key( + Path::new(&key_path), + if pass.is_empty() { None } else { Some(pass) }, + ) + .with_context(|| format!("failed to load key {key_path}"))?; + // RSA keys need an explicit SHA-2 hash; other key types don't. + let hash = keypair.algorithm().is_rsa().then_some(HashAlg::Sha256); + let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash); handle - .authenticate_publickey(&session.user, key_with_hash) + .authenticate_publickey(&user, key_with_hash) .await .context("sftp publickey auth failed")? + .success() } }; if !authed { - return Err(anyhow!("SFTP 认证失败")); + return Err(anyhow!(t("SFTP 认证失败", "SFTP authentication failed"))); } // --- Open the sftp subsystem channel ----------------------------------- @@ -209,10 +308,23 @@ async fn run_sftp( let sftp = SftpSession::new(channel.into_stream()) .await .context("sftp handshake")?; + // Share the session + connection so transfers can run on their own task, + // leaving the command loop free to list/switch directories meanwhile (#116-2). + let sftp = std::sync::Arc::new(sftp); + let handle = std::sync::Arc::new(handle); + + // Per-transfer cancel flags, keyed by transfer id. A download task registers + // its flag here; a CancelTransfer command flips it; the task removes it on + // exit (#100 cancel download). + let cancels: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); // 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!("SFTP 加载 {}...", home))); + 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 { @@ -222,7 +334,7 @@ async fn run_sftp( let _ = events.send(SessionEvent::SftpStatus(home.clone())); } Err(e) => { - let _ = events.send(SessionEvent::SftpStatus(format!("SFTP 错误: {e}"))); + let _ = events.send(SessionEvent::SftpError(list_error_msg(&home, &e))); } } @@ -231,8 +343,7 @@ async fn run_sftp( // 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(); + 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(); @@ -243,13 +354,18 @@ async fn run_sftp( if home != "/" { let mut current = "/".to_string(); for segment in home.trim_start_matches('/').split('/') { - if segment.is_empty() { continue; } + 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) + let found = tree_dirs + .get(¤t) .map(|c| c.iter().any(|(_, p)| p == &child)) .unwrap_or(false); - if !found { break; } + 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()); @@ -268,7 +384,7 @@ async fn run_sftp( SftpCommand::Close => break, SftpCommand::ListDir(path) => { - let _ = events.send(SessionEvent::SftpStatus(format!("加载 {}...", path))); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("加载", "Loading"), path))); match list_dir_impl(&sftp, &path).await { Ok(entries) => { let _ = events.send(SessionEvent::SftpEntries { @@ -278,8 +394,7 @@ async fn run_sftp( let _ = events.send(SessionEvent::SftpStatus(path)); } Err(e) => { - let _ = - events.send(SessionEvent::SftpStatus(format!("列目录失败: {e}"))); + let _ = events.send(SessionEvent::SftpError(list_error_msg(&path, &e))); } } } @@ -303,66 +418,269 @@ async fn run_sftp( } SftpCommand::Download { remote, local_dir } => { - let 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!("下载 {}...", filename))); - match download_impl(&sftp, &remote, &local_path, &filename, &id, &events) + // Run on its own task so the command loop stays free to list / + // switch directories during the transfer (#116-2). + let sftp = sftp.clone(); + let handle = handle.clone(); + let events = events.clone(); + // Register a cancel flag up-front under the file id, so a + // CancelTransfer arriving mid-download can flip it (#100). + let file_id = Uuid::new_v4().to_string(); + let cancel = Arc::new(AtomicBool::new(false)); + cancels + .lock() + .unwrap() + .insert(file_id.clone(), cancel.clone()); + let cancels_done = cancels.clone(); + tokio::spawn(async move { + // A directory target → recursively mirror the whole tree (#50). + let is_dir = sftp + .metadata(&remote) .await - { - Ok(_) => { + .ok() + .map(|m| (m.permissions.unwrap_or(0) & 0o170_000) == 0o040_000) + .unwrap_or(false); + if is_dir { + let dirname = base_name(&remote); + // #100.3: an empty folder downloads nothing — just say so + // rather than silently creating an empty local directory. + let empty = list_dir_impl(&sftp, &remote) + .await + .map(|e| e.is_empty()) + .unwrap_or(false); + if empty { let _ = events.send(SessionEvent::SftpStatus(format!( - "下载完成: {}", - filename + "{}: {}", t("空文件夹", "Empty folder"), dirname ))); + return; } - Err(e) => { - emit_transfer(&events, &id, &filename, false, 0, 0, 2, &e.to_string()); - let _ = events - .send(SessionEvent::SftpStatus(format!("下载失败: {e}"))); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{} {}/...", t("下载文件夹", "Downloading folder"), dirname + ))); + match download_dir(&sftp, &handle, &remote, &local_dir, &events).await { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", t("下载完成", "Downloaded"), dirname + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", t("下载失败", "Download failed") + ))); + } + } + } else { + // 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 = file_id.clone(); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("下载", "Downloading"), filename))); + match download_impl(&handle, &remote, &local_path, &filename, &id, &events, &cancel).await { + Ok(true) => { + let _ = events + .send(SessionEvent::SftpStatus(format!("{}: {}", t("下载完成", "Downloaded"), filename))); + } + Ok(false) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {}", t("已取消", "Cancelled"), 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")))); + } } } + cancels_done.lock().unwrap().remove(&file_id); + }); } - SftpCommand::Upload { local, remote_dir } => { - let filename = base_name(&local); - let remote_path = - format!("{}/{}", remote_dir.trim_end_matches('/'), filename); + SftpCommand::DownloadArchive { + remote_dir, + names, + local_dir, + } => { + // #100: multi-select download. Instead of N concurrent transfers + // (which raced and dropped files), tar everything into ONE archive + // on the remote, pull that single file, then delete the temp. + let sftp = sftp.clone(); + let handle = handle.clone(); + let events = events.clone(); + // Register a cancel flag up-front so CancelTransfer can flip it (#100). let id = Uuid::new_v4().to_string(); - let _ = - events.send(SessionEvent::SftpStatus(format!("上传 {}...", filename))); - match upload_impl(&sftp, &local, &remote_path, &filename, &id, &events).await - { - Ok(_) => { - if let Ok(entries) = list_dir_impl(&sftp, &remote_dir).await { - let _ = events.send(SessionEvent::SftpEntries { - path: remote_dir.clone(), - entries, - }); + let cancel = Arc::new(AtomicBool::new(false)); + cancels.lock().unwrap().insert(id.clone(), cancel.clone()); + let cancels_done = cancels.clone(); + tokio::spawn(async move { + let n = names.len(); + let tmp = format!("/tmp/meatshell-{}.tar", Uuid::new_v4()); + // Name the archive after the first item's stem, per the user: + // 11.txt → "11等文件.tar". Sanitize since names come from the server. + let first = names.first().map(|s| s.as_str()).unwrap_or("download"); + let stem = first + .rsplit_once('.') + .map(|(a, _)| a) + .filter(|a| !a.is_empty()) + .unwrap_or(first); + let arc_name = + sanitize_filename(&format!("{}{}.tar", stem, t("等文件", "-and-more"))); + let local_path = + format!("{}/{}", local_dir.trim_end_matches('/'), arc_name); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{} {} {}...", t("打包下载", "Archiving"), n, t("项", "items") + ))); + // Show a "preparing" row in the transfer panel right away so a + // big selection isn't a silent wait while tar runs (#100). The + // download then reuses this same id, so the row turns into the + // live progress bar once bytes start flowing. + emit_transfer(&events, &id, &arc_name, false, 0, 0, 3, ""); + // Plain tar (no gzip): the user prefers speed over a smaller file. + // Server-supplied names are untrusted → quote every argument. + let mut cmd = + format!("tar -cf {} -C {}", sh_quote(&tmp), sh_quote(&remote_dir)); + for nm in &names { + cmd.push(' '); + cmd.push_str(&sh_quote(nm)); + } + let _ = &sftp; // listing session kept alive; transfer uses `handle` + let res: Result = async { + let st = exec_remote(&handle, &cmd).await.context("tar on remote")?; + if st != 0 { + return Err(anyhow!(t("远端 tar 打包失败", "remote tar failed"))); } - let _ = events.send(SessionEvent::SftpStatus(format!( - "上传完成: {}", - filename - ))); + download_impl(&handle, &tmp, &local_path, &arc_name, &id, &events, &cancel) + .await } - Err(e) => { - emit_transfer(&events, &id, &filename, true, 0, 0, 2, &e.to_string()); - let _ = events - .send(SessionEvent::SftpStatus(format!("上传失败: {e}"))); + .await; + // Best-effort cleanup of the remote temp tar — success, failure + // or cancel all reach here, so no junk is left on the server (#100). + let _ = exec_remote(&handle, &format!("rm -f {}", sh_quote(&tmp))).await; + match res { + Ok(true) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", t("下载完成", "Downloaded"), arc_name + ))); + } + Ok(false) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", t("已取消", "Cancelled"), arc_name + ))); + } + Err(e) => { + emit_transfer(&events, &id, &arc_name, false, 0, 0, 2, &e.to_string()); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", t("下载失败", "Download failed") + ))); + } } + cancels_done.lock().unwrap().remove(&id); + }); + } + + SftpCommand::CancelTransfer(id) => { + if let Some(flag) = cancels.lock().unwrap().get(&id) { + flag.store(true, Ordering::Relaxed); } } + SftpCommand::Upload { local, remote_dir } => { + // Run on its own task so the command loop stays free to list / + // switch directories during the transfer (#116-2). + let sftp = sftp.clone(); + let handle = handle.clone(); + let events = events.clone(); + // Register a cancel flag up-front under the file id so a + // CancelTransfer arriving mid-upload can flip it (#100). + let up_id = Uuid::new_v4().to_string(); + let cancel = Arc::new(AtomicBool::new(false)); + cancels.lock().unwrap().insert(up_id.clone(), cancel.clone()); + let cancels_done = cancels.clone(); + tokio::spawn(async move { + // A directory source → recursively upload the whole tree (#50). + let is_dir = tokio::fs::metadata(&local) + .await + .map(|m| m.is_dir()) + .unwrap_or(false); + if is_dir { + let dirname = base_name(&local); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{} {}/...", t("上传文件夹", "Uploading folder"), dirname + ))); + let res = upload_dir(&handle, &sftp, &local, &remote_dir, &events).await; + if let Ok(entries) = list_dir_impl(&sftp, &remote_dir).await { + let _ = events.send(SessionEvent::SftpEntries { + path: remote_dir.clone(), + entries, + }); + } + match res { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", t("上传完成", "Uploaded"), dirname + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", t("上传失败", "Upload failed") + ))); + } + } + } else { + let filename = base_name(&local); + let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), filename); + let id = up_id.clone(); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("上传", "Uploading"), filename))); + match upload_pipelined(&handle, &local, &remote_path, &filename, &id, &events, &cancel).await { + Ok(true) => { + if let Ok(entries) = list_dir_impl(&sftp, &remote_dir).await { + let _ = events.send(SessionEvent::SftpEntries { + path: remote_dir.clone(), + entries, + }); + } + let _ = events + .send(SessionEvent::SftpStatus(format!("{}: {}", t("上传完成", "Uploaded"), filename))); + } + Ok(false) => { + // Refresh the listing so the removed partial file disappears. + if let Ok(entries) = list_dir_impl(&sftp, &remote_dir).await { + let _ = events.send(SessionEvent::SftpEntries { + path: remote_dir.clone(), + entries, + }); + } + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {}", t("已取消", "Cancelled"), 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")))); + } + } + } + cancels_done.lock().unwrap().remove(&up_id); + }); + } + SftpCommand::Delete(path) => { let filename = base_name(&path); - let _ = - events.send(SessionEvent::SftpStatus(format!("删除 {}...", 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(|_| ()), + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("删除", "Deleting"), filename))); + // Directories are removed recursively (a plain remove_dir only + // works on an empty dir, so an uploaded folder couldn't be + // deleted); files via remove_file. + let is_dir = sftp + .metadata(&path) + .await + .ok() + .map(|m| (m.permissions.unwrap_or(0) & 0o170_000) == 0o040_000) + .unwrap_or(false); + let res: Result<()> = if is_dir { + remove_dir_recursive(&sftp, &path).await + } else { + sftp.remove_file(&path) + .await + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("{e}")) }; match res { Ok(_) => { @@ -373,33 +691,135 @@ async fn run_sftp( entries, }); } + let _ = + events.send(SessionEvent::SftpStatus(format!("{}: {}", t("已删除", "Deleted"), filename))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("删除失败", "Delete failed")))); + } + } + } + + SftpCommand::Rename { from, to } => { + let refresh = parent_dir(&from); + match sftp.rename(&from, &to).await { + Ok(_) => { let _ = events.send(SessionEvent::SftpStatus(format!( - "已删除: {}", - filename + "{}: {}", + t("已重命名", "Renamed"), + base_name(&to) + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", + t("重命名失败", "Rename failed") + ))); + } + } + if let Ok(entries) = list_dir_impl(&sftp, &refresh).await { + let _ = events.send(SessionEvent::SftpEntries { path: refresh, entries }); + } + } + + SftpCommand::Chmod { path, mode } => { + let refresh = parent_dir(&path); + let attrs = FileAttributes { + permissions: Some(mode), + ..Default::default() + }; + match sftp.set_metadata(&path, attrs).await { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {} → {:o}", + t("已修改权限", "Permissions changed"), + base_name(&path), + mode + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", + t("修改权限失败", "chmod failed") + ))); + } + } + if let Ok(entries) = list_dir_impl(&sftp, &refresh).await { + let _ = events.send(SessionEvent::SftpEntries { path: refresh, entries }); + } + } + + SftpCommand::MkDir(path) => { + let refresh = parent_dir(&path); + match sftp.create_dir(&path).await { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + t("已新建文件夹", "Folder created"), + base_name(&path) ))); } Err(e) => { - let _ = events - .send(SessionEvent::SftpStatus(format!("删除失败: {e}"))); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", + t("新建文件夹失败", "Create folder failed") + ))); } } + if let Ok(entries) = list_dir_impl(&sftp, &refresh).await { + let _ = events.send(SessionEvent::SftpEntries { path: refresh, entries }); + } + } + + SftpCommand::TouchFile(path) => { + let refresh = parent_dir(&path); + // create() truncates if the file exists, so refuse to clobber. + let exists = sftp.metadata(&path).await.is_ok(); + if exists { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + t("文件已存在", "File already exists"), + base_name(&path) + ))); + } else { + match sftp.create(&path).await { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + t("已新建文件", "File created"), + base_name(&path) + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e}", + t("新建文件失败", "Create file failed") + ))); + } + } + } + if let Ok(entries) = list_dir_impl(&sftp, &refresh).await { + let _ = events.send(SessionEvent::SftpEntries { path: refresh, entries }); + } } SftpCommand::OpenTemp { remote, edit } => { - let filename = base_name(&remote); + // 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!("打开 {}...", filename))); + 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 { + let no_cancel = Arc::new(AtomicBool::new(false)); + match download_impl(&handle, &remote, &local_str, &filename, &xid, &events, &no_cancel).await { Ok(_) => { open_with_os(&local_str); let _ = events.send(SessionEvent::SftpStatus(format!( - "已{}: {}", - if edit { "打开编辑" } else { "打开" }, + "{}: {}", + if edit { t("已打开编辑", "Opened for editing") } else { t("已打开", "Opened") }, filename ))); if edit { @@ -413,15 +833,112 @@ async fn run_sftp( } } Err(e) => { - let _ = events - .send(SessionEvent::SftpStatus(format!("打开失败: {e}"))); + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("打开失败", "Open failed")))); + } + } + } + SftpCommand::ReadText { remote, edit } => { + let name = base_name(&remote); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{} {}...", + t("打开", "Opening"), + name + ))); + let (content, error) = match read_text_guarded(&sftp, &remote).await { + Ok(text) => (text, String::new()), + Err(msg) => (String::new(), msg), + }; + let _ = events.send(SessionEvent::SftpFileText { + path: remote, + name, + content, + edit, + error, + }); + } + SftpCommand::WriteText { remote, content } => { + let name = base_name(&remote); + match write_text_file(&sftp, &remote, &content).await { + Ok(_) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + t("已保存", "Saved"), + name + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {e:#}", + t("保存失败", "Save failed") + ))); } } } } } - let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await; + let _ = handle + .disconnect(Disconnect::ByApplication, "bye", "") + .await; + Ok(()) +} + +/// Read a remote file as UTF-8 text for the built-in editor, rejecting files +/// that are too large, binary, or not valid UTF-8 (#70). Returns the text on +/// success or a human-readable error message on failure. +async fn read_text_guarded(sftp: &SftpSession, remote: &str) -> std::result::Result { + use tokio::io::AsyncReadExt; + const MAX_EDIT_BYTES: u64 = 2 * 1024 * 1024; // 2 MiB + let size = sftp + .metadata(remote) + .await + .ok() + .and_then(|m| m.size) + .unwrap_or(0); + if size > MAX_EDIT_BYTES { + return Err(t( + "文件过大,无法在内置编辑器中打开(上限 2 MB),请下载查看", + "Too large for the built-in editor (2 MB limit); download it instead", + ) + .into()); + } + let mut f = sftp + .open(remote) + .await + .map_err(|e| format!("{}: {e}", t("打开失败", "Open failed")))?; + let mut bytes = Vec::new(); + f.read_to_end(&mut bytes) + .await + .map_err(|e| format!("{}: {e}", t("读取失败", "Read failed")))?; + // Control characters (beyond tab/newline/CR) have no glyph — they render as + // tofu boxes — and round-tripping them through the editor risks corrupting + // the file (e.g. .viminfo). Treat such files as binary (#70). + if bytes + .iter() + .any(|&b| (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r') || b == 0x7f) + { + return Err(t( + "包含控制字符(疑似二进制),无法以文本打开,请下载查看", + "Contains control characters (likely binary); download it instead", + ) + .into()); + } + String::from_utf8(bytes) + .map_err(|_| t("非 UTF-8 文本,无法打开", "Not UTF-8 text; cannot open").into()) +} + +/// Overwrite a remote file with the given text (CREATE | WRITE | TRUNCATE). +async fn write_text_file(sftp: &SftpSession, remote: &str, content: &str) -> Result<()> { + use tokio::io::AsyncWriteExt; + let mut f = sftp + .create(remote) + .await + .with_context(|| format!("create remote {remote}"))?; + f.write_all(content.as_bytes()) + .await + .context("write remote file")?; + f.flush().await.context("flush remote file")?; + let _ = f.shutdown().await; Ok(()) } @@ -437,6 +954,34 @@ fn base_name(path: &str) -> String { .to_string() } +/// Single-quote a string for safe interpolation into a remote `/bin/sh` +/// command. Remote names come from the *server's* listing and are therefore +/// untrusted — without quoting, a crafted name like `; rm -rf ~` would run. +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Run a one-shot command on the remote over its own exec channel and return +/// the exit status. Stdout/stderr are drained and discarded. +async fn exec_remote(handle: &client::Handle, cmd: &str) -> Result { + let mut ch = handle + .channel_open_session() + .await + .context("open exec channel")?; + ch.exec(true, cmd.as_bytes()) + .await + .context("exec remote command")?; + let mut status = 0u32; + while let Some(msg) = ch.wait().await { + match msg { + russh::ChannelMsg::ExitStatus { exit_status } => status = exit_status, + russh::ChannelMsg::Close => break, + _ => {} + } + } + Ok(status) +} + /// Parent directory of a remote path ("/a/b" → "/a", "/a" → "/"). fn parent_dir(path: &str) -> String { let p = path.trim_end_matches('/'); @@ -447,16 +992,88 @@ fn parent_dir(path: &str) -> 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) { - #[cfg(windows)] - { - let _ = std::process::Command::new("cmd") - .args(["/C", "start", "", path]) - .spawn(); + 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; } - #[cfg(not(windows))] - { - let _ = std::process::Command::new("xdg-open").arg(path).spawn(); + 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() } } @@ -473,9 +1090,7 @@ fn spawn_edit_watcher( ) { 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 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 { @@ -491,7 +1106,8 @@ fn spawn_edit_watcher( remote_dir: remote_dir.clone(), }); let _ = events.send(SessionEvent::SftpStatus(format!( - "已上传修改: {}", + "{}: {}", + t("已上传修改", "Re-uploaded changes"), filename ))); } @@ -503,6 +1119,18 @@ fn spawn_edit_watcher( // SFTP helpers // --------------------------------------------------------------------------- +/// A friendlier message for a failed directory listing, calling out the common +/// permission-denied case explicitly rather than dumping the raw error (#112). +fn list_error_msg(path: &str, e: &impl std::fmt::Display) -> String { + let raw = e.to_string(); + let low = raw.to_lowercase(); + if low.contains("permission") || low.contains("denied") { + format!("{}: {}", t("权限不足,无法访问", "Permission denied"), path) + } else { + format!("{} {}: {}", t("无法访问", "Cannot open"), path, raw) + } +} + async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result> { let raw = sftp .read_dir(path) @@ -524,7 +1152,14 @@ async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result, remote: &str, local: &str, name: &str, id: &str, events: &UnboundedSender, -) -> Result<()> { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let total = sftp - .metadata(remote) + cancel: &Arc, +) -> Result { + use tokio::io::{AsyncSeekExt, AsyncWriteExt}; + + const CHUNK: usize = 32 * 1024; + const MAX_INFLIGHT: usize = 32; // ~1 MB outstanding hides the RTT + + let channel = handle + .channel_open_session() + .await + .context("open sftp download 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 download handshake")?; + + let total = raw + .stat(remote) .await .ok() - .and_then(|m| m.size) + .and_then(|a| a.attrs.size) .unwrap_or(0); - let mut remote_file = sftp - .open(remote) + let fhandle = raw + .open(remote, OpenFlags::READ, FileAttributes::default()) .await - .with_context(|| format!("open remote {remote}"))?; + .with_context(|| format!("open remote {remote}"))? + .handle; 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; + let mut err: Option = None; + let mut cancelled = false; + + if total > 0 { + let mut next_off = 0u64; + let mut inflight = FuturesUnordered::new(); + loop { + if cancel.load(Ordering::Relaxed) { + cancelled = true; + } + // Top up the pipeline with fresh READ requests. + while !cancelled && err.is_none() && next_off < total && inflight.len() < MAX_INFLIGHT { + let off = next_off; + let want = ((total - off) as usize).min(CHUNK); + next_off += want as u64; + let raw2 = raw.clone(); + let h = fhandle.clone(); + inflight.push(async move { + // Fill the whole chunk, coping with short reads. + let mut data = Vec::with_capacity(want); + let mut o = off; + let end = off + want as u64; + while o < end { + match raw2.read(h.clone(), o, (end - o) as u32).await { + Ok(d) => { + if d.data.is_empty() { + break; + } + o += d.data.len() as u64; + data.extend_from_slice(&d.data); + } + Err(SftpError::Status(s)) if s.status_code == StatusCode::Eof => break, + Err(e) => return Err(anyhow!("read remote: {e}")), + } + } + Ok::<(u64, Vec), anyhow::Error>((off, data)) + }); + } + if inflight.is_empty() { + break; + } + match inflight.next().await { + Some(Ok((off, data))) => { + if !data.is_empty() { + if let Err(e) = local_file.seek(std::io::SeekFrom::Start(off)).await { + err = Some(anyhow!("seek local: {e}")); + } else if let Err(e) = local_file.write_all(&data).await { + err = Some(anyhow!("write local: {e}")); + } else { + done += data.len() as u64; + } + } + if last.elapsed() >= Duration::from_millis(150) { + last = Instant::now(); + emit_transfer(events, id, name, false, done, total, 0, ""); + } + } + Some(Err(e)) => err = Some(e), + None => {} + } + if (cancelled || err.is_some()) && inflight.is_empty() { + 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, ""); + } else { + // Unknown / zero size: serial drain until EOF (rare; keeps correctness). + let mut off = 0u64; + loop { + if cancel.load(Ordering::Relaxed) { + cancelled = true; + break; + } + match raw.read(fhandle.clone(), off, CHUNK as u32).await { + Ok(d) => { + if d.data.is_empty() { + break; + } + local_file + .write_all(&d.data) + .await + .context("write local file")?; + off += d.data.len() as u64; + done += d.data.len() as u64; + if last.elapsed() >= Duration::from_millis(150) { + last = Instant::now(); + emit_transfer(events, id, name, false, done, done, 0, ""); + } + } + Err(SftpError::Status(s)) if s.status_code == StatusCode::Eof => break, + Err(e) => { + err = Some(anyhow!("read remote: {e}")); + break; + } + } } } + + let _ = raw.close(fhandle).await; + + if let Some(e) = err { + drop(local_file); + let _ = tokio::fs::remove_file(local).await; + return Err(e); + } + if cancelled { + drop(local_file); + let _ = tokio::fs::remove_file(local).await; + emit_transfer(events, id, name, false, done, total, 4, t("已取消", "Cancelled")); + return Ok(false); + } local_file.flush().await.context("flush local file")?; emit_transfer(events, id, name, false, done, total.max(done), 1, ""); + Ok(true) +} + +/// Recursively download a remote directory tree under `local_parent` (#50). +/// +/// Iterative (work-stack) rather than a boxed async recursion: each remote dir +/// is mirrored to a sanitized local name, then its files are downloaded with the +/// same per-file pipeline used for single downloads. Names are sanitized (#26) +/// so a hostile server can't escape the chosen folder. +async fn download_dir( + sftp: &SftpSession, + handle: &client::Handle, + remote_root: &str, + local_parent: &str, + events: &UnboundedSender, +) -> Result<()> { + // Folder transfers aren't individually cancellable from the UI; a throwaway + // never-set flag satisfies download_impl's signature. + let no_cancel = Arc::new(AtomicBool::new(false)); + let root_name = sanitize_filename(&base_name(remote_root)); + let root_local = format!("{}/{}", local_parent.trim_end_matches('/'), root_name); + // (remote_dir, local_dir) pairs still to mirror. + let mut stack = vec![(remote_root.trim_end_matches('/').to_string(), root_local)]; + while let Some((rdir, ldir)) = stack.pop() { + tokio::fs::create_dir_all(&ldir) + .await + .with_context(|| format!("create local dir {ldir}"))?; + for entry in list_dir_impl(sftp, &rdir).await? { + if entry.is_dir { + let child_local = format!("{}/{}", ldir, sanitize_filename(&entry.name)); + stack.push((entry.full_path, child_local)); + } else { + let fname = sanitize_filename(&entry.name); + let lpath = format!("{}/{}", ldir, fname); + let id = Uuid::new_v4().to_string(); + download_impl(handle, &entry.full_path, &lpath, &fname, &id, events, &no_cancel) + .await?; + } + } + } + Ok(()) +} + +/// Recursively remove a remote directory tree (#50 follow-up). +/// +/// A plain `remove_dir` only deletes an *empty* directory, so deleting an +/// uploaded folder failed. We BFS to discover every sub-directory (deleting +/// files as we go), then rmdir them deepest-first. +async fn remove_dir_recursive(sftp: &SftpSession, root: &str) -> Result<()> { + let mut all_dirs = vec![root.trim_end_matches('/').to_string()]; + let mut i = 0; + while i < all_dirs.len() { + let d = all_dirs[i].clone(); + i += 1; + for entry in list_dir_impl(sftp, &d).await? { + if entry.is_dir { + all_dirs.push(entry.full_path); + } else { + sftp.remove_file(&entry.full_path) + .await + .map_err(|e| anyhow::anyhow!("remove file {}: {e}", entry.full_path))?; + } + } + } + // BFS discovered parents before children, so reversing gives deepest-first. + for d in all_dirs.iter().rev() { + sftp.remove_dir(d) + .await + .map_err(|e| anyhow::anyhow!("remove dir {d}: {e}"))?; + } Ok(()) } -async fn upload_impl( +/// Recursively upload a local directory tree into `remote_parent` (#50). +/// +/// Iterative work-stack: mirror each local dir to the remote (create_dir, whose +/// "already exists" error is ignored), then upload its files with the pipelined +/// path. Symlinks and other special files are skipped. +async fn upload_dir( + handle: &client::Handle, sftp: &SftpSession, + local_root: &str, + remote_parent: &str, + events: &UnboundedSender, +) -> Result<()> { + // Folder uploads aren't individually cancellable from the UI; a throwaway + // never-set flag satisfies upload_pipelined's signature. + let no_cancel = Arc::new(AtomicBool::new(false)); + let root_name = base_name(local_root); + let remote_root = format!("{}/{}", remote_parent.trim_end_matches('/'), root_name); + let mut stack = vec![(local_root.to_string(), remote_root)]; + while let Some((ldir, rdir)) = stack.pop() { + // Best-effort mkdir; an error usually just means the dir already exists. + let _ = sftp.create_dir(&rdir).await; + let mut rd = tokio::fs::read_dir(&ldir) + .await + .with_context(|| format!("read local dir {ldir}"))?; + while let Some(entry) = rd.next_entry().await.context("read dir entry")? { + let name = entry.file_name().to_string_lossy().to_string(); + let lpath = entry.path().to_string_lossy().to_string(); + let rchild = format!("{}/{}", rdir, name); + let ft = entry.file_type().await.context("file type")?; + if ft.is_dir() { + stack.push((lpath, rchild)); + } else if ft.is_file() { + let id = Uuid::new_v4().to_string(); + upload_pipelined(handle, &lpath, &rchild, &name, &id, events, &no_cancel).await?; + } + } + } + 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, AsyncWriteExt}; + cancel: &Arc, +) -> 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()) @@ -635,59 +1515,129 @@ async fn upload_impl( let mut local_file = tokio::fs::File::open(local) .await .with_context(|| format!("open local {local}"))?; - let mut remote_file = sftp - .create(remote) + + // 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 - .with_context(|| format!("create remote {remote}"))?; + .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 buf = vec![0u8; XFER_CHUNK]; + + let mut offset: u64 = 0; let mut done: u64 = 0; let mut last = Instant::now(); - loop { - let n = local_file.read(&mut buf).await.context("read local file")?; - if n == 0 { - break; + let mut eof = false; + let mut err: Option = None; + let mut cancelled = false; + let mut inflight = FuturesUnordered::new(); + + while !eof || !inflight.is_empty() { + if cancel.load(Ordering::Relaxed) { + cancelled = true; + eof = true; // stop reading more; drain what's in flight } - remote_file - .write_all(&buf[..n]) - .await - .context("write remote file")?; - done += n as u64; - if last.elapsed() >= Duration::from_millis(150) { - last = Instant::now(); - emit_transfer(events, id, name, true, done, total, 0, ""); + // 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; } } - remote_file.flush().await.context("flush remote file")?; + + let _ = raw.close(fhandle).await; + if let Some(e) = err { + // Drop the partial remote file so a failed upload leaves no junk. + let _ = raw.remove(remote).await; + return Err(e); + } + if cancelled { + // Remove the half-written remote file on cancel (#100). + let _ = raw.remove(remote).await; + emit_transfer(events, id, name, true, done, total, 4, t("已取消", "Cancelled")); + return Ok(false); + } emit_transfer(events, id, name, true, done, total.max(done), 1, ""); - Ok(()) + Ok(true) } // --------------------------------------------------------------------------- -// russh client handler (accept any server key, same as the shell handler) +// russh client handler — verifies the host key against known_hosts, reusing the +// shell session's prompt path (#109-5). The UI de-duplicates by host:port, so a +// fresh host confirmed for the shell won't prompt again for SFTP. // --------------------------------------------------------------------------- -struct SftpClientHandler; +struct SftpClientHandler { + host: String, + port: u16, + events: UnboundedSender, +} + +fn sftp_handler(session: &Session, events: &UnboundedSender) -> SftpClientHandler { + SftpClientHandler { + host: session.host.clone(), + port: session.port, + events: events.clone(), + } +} -#[async_trait] impl Handler for SftpClientHandler { type Error = russh::Error; async fn check_server_key( &mut self, - _server_public_key: &PublicKey, + server_public_key: &PublicKey, ) -> Result { - Ok(true) - } - - async fn data( - &mut self, - _channel: russh::ChannelId, - _data: &[u8], - _session: &mut client::Session, - ) -> Result<(), Self::Error> { - Ok(()) + Ok(crate::ssh::verify_host_key(&self.host, self.port, server_public_key, &self.events).await) } } @@ -697,3 +1647,63 @@ const _: fn() = || { 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 String { dt.format("%Y-%m-%d %H:%M").to_string() } +/// The canonical ZMODEM abort sequence: eight CAN (0x18) then eight BS (0x08). +/// Sending this makes the remote `sz`/`rz` give up so the session recovers (#76). +const ZMODEM_CANCEL: [u8; 16] = [ + 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, +]; + +/// Detect the start of a ZMODEM transfer (sz/rz) in a raw channel chunk. +/// +/// Every ZMODEM frame begins with ZDLE (0x18) followed by a type byte; the +/// `sz` handshake leads with a ZRQINIT hex header (`**\x18B00...`). Matching +/// ZDLE followed by `B` (hex frame) or `C` (binary frame) reliably catches the +/// handshake without false-positiving on a lone 0x18 (Ctrl-X) in normal output. +fn contains_zmodem_init(data: &[u8]) -> bool { + data.windows(2) + .any(|w| w[0] == 0x18 && (w[1] == b'B' || w[1] == b'C')) +} + /// 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 { + extract_osc7_end(text).map(|(path, _)| path) +} + +/// Like [`extract_osc7_path`] but also returns the byte index just past the OSC +/// sequence's terminator, so the caller can cut everything up to and including +/// it — used to discard the echoed setup line (which may wrap) at connect (#98). +fn extract_osc7_end(text: &str) -> Option<(String, usize)> { let bytes = text.as_bytes(); let mut i = 0; while i + 1 < bytes.len() { @@ -84,13 +112,13 @@ pub fn extract_osc7_path(text: &str) -> Option { i += 2; // Scan for BEL (0x07) or ST (ESC \) let mut end = i; + let mut term_len = 0; while end < bytes.len() { if bytes[end] == 0x07 { + term_len = 1; break; - } else if bytes[end] == 0x1b - && end + 1 < bytes.len() - && bytes[end + 1] == b'\\' - { + } else if bytes[end] == 0x1b && end + 1 < bytes.len() && bytes[end + 1] == b'\\' { + term_len = 2; break; } end += 1; @@ -108,10 +136,52 @@ pub fn extract_osc7_path(text: &str) -> Option { } else { "/".to_string() }; - return Some(url_decode(&path)); + return Some((url_decode(&path), end + term_len)); + } + } + i = end + term_len.max(1); + } + None +} + +/// Find a meatshell command-capture sequence (`ESC ] 697 ; BEL|ST`) +/// emitted by the shell hook (#113). Returns the command text and the byte +/// range of the whole escape sequence, so the caller can strip it before the +/// text is rendered. An incomplete sequence (terminator not yet received) +/// yields `None` — vt100 buffers it and the next chunk completes it. +pub fn extract_osc_command(text: &str) -> Option<(String, std::ops::Range)> { + 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 seq_start = i; + let osc_start = i + 2; + i += 2; + // Scan for BEL (0x07) or ST (ESC \). + let mut end = i; + let mut term_len = 0; + while end < bytes.len() { + if bytes[end] == 0x07 { + term_len = 1; + break; + } else if bytes[end] == 0x1b && end + 1 < bytes.len() && bytes[end + 1] == b'\\' { + term_len = 2; + break; + } + end += 1; + } + if end >= bytes.len() { + break; // incomplete — leave it for the next chunk + } + if let Ok(content) = std::str::from_utf8(&bytes[osc_start..end]) { + if let Some(cmd) = content.strip_prefix("697;") { + return Some((cmd.to_string(), seq_start..end + term_len)); } } - i = end + 1; + i = end + term_len; } None } @@ -159,6 +229,79 @@ pub enum SessionCommand { Close, } +/// Carries the user's answer to a host-key confirmation prompt back to the +/// blocked `check_server_key` handler. Wrapped in `Arc>>` so the +/// enclosing [`SessionEvent`] stays `Clone` (a bare `oneshot::Sender` is not); +/// the first `respond` consumes the sender, later calls are no-ops. +#[derive(Clone)] +pub struct HostKeyResponder( + Arc>>>, +); + +impl HostKeyResponder { + pub fn new(tx: tokio::sync::oneshot::Sender) -> Self { + Self(Arc::new(std::sync::Mutex::new(Some(tx)))) + } + + /// Deliver the user's decision (`true` = trust). Idempotent. + pub fn respond(&self, accept: bool) { + if let Ok(mut guard) = self.0.lock() { + if let Some(tx) = guard.take() { + let _ = tx.send(accept); + } + } + } +} + +impl std::fmt::Debug for HostKeyResponder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HostKeyResponder") + } +} + +/// The user's answer to a connect-time credential prompt: `(username, password, +/// remember)`, or `None` if they cancelled. +pub type CredentialReply = (String, String, bool); + +/// Carries the credential prompt's answer back to the blocked auth flow (#110). +/// `Arc>>` so the enclosing [`SessionEvent`] stays `Clone`. +#[derive(Clone)] +pub struct CredentialResponder( + Arc>>>>, +); + +impl CredentialResponder { + pub fn new(tx: tokio::sync::oneshot::Sender>) -> Self { + Self(Arc::new(std::sync::Mutex::new(Some(tx)))) + } + + /// Deliver the user's answer (`None` = cancelled). Idempotent. + pub fn respond(&self, reply: Option) { + if let Ok(mut guard) = self.0.lock() { + if let Some(tx) = guard.take() { + let _ = tx.send(reply); + } + } + } +} + +impl std::fmt::Debug for CredentialResponder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CredentialResponder") + } +} + +/// One process row sampled from the remote `ps` (#23). CPU/mem are percentages +/// as reported by `ps` (pcpu/pmem); `command` is the (width-truncated) args. +#[derive(Debug, Clone)] +pub struct ProcInfo { + pub pid: u32, + pub user: String, + pub cpu: f32, + pub mem: f32, + pub command: String, +} + /// Events emitted back to the UI thread. #[derive(Debug, Clone)] pub enum SessionEvent { @@ -170,6 +313,28 @@ pub enum SessionEvent { Connected, /// Connection closed (either cleanly or after an error). Closed(String), + /// The server presented a host key that is unknown or has changed; the UI + /// must show a confirmation dialog and answer via `responder` (#109-5). The + /// handler is blocked awaiting that answer. + HostKeyPrompt { + host: String, + port: u16, + key_type: String, + fingerprint: String, + /// True when a *different* key was previously stored (possible MITM). + changed: bool, + responder: HostKeyResponder, + }, + /// The session is missing a username and/or password; the UI must prompt for + /// them and answer via `responder`. The auth flow is blocked meanwhile (#110). + CredentialPrompt { + session_id: String, + host: String, + user: String, + need_user: bool, + need_password: bool, + responder: CredentialResponder, + }, /// Remote machine resource sample (from the monitor channel). /// Memory/swap are in KiB (as reported by /proc/meminfo). ResourceStats { @@ -182,15 +347,27 @@ pub enum SessionEvent { net: Vec<(String, u64, u64)>, /// Per-filesystem (mount_point, available_bytes, total_bytes). disks: Vec<(String, u64, u64)>, + /// Top processes by CPU (#23). Empty if the host's `ps` is unusable. + procs: Vec, }, + /// A command the user ran in the terminal, captured via the shell hook + /// (OSC 697) so it can join the command-box history (#113). + CommandRan(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 }, + SftpEntries { + path: String, + entries: Vec, + }, /// Free-form SFTP status message (progress, errors, etc.). SftpStatus(String), + /// A directory listing failed (e.g. permission denied): show the message and + /// stop the panel's loading spinner without disturbing the current view (#112). + SftpError(String), /// Directory tree structure changed (full rebuild pushed on every toggle). SftpTreeUpdate(Vec), /// File-transfer progress / completion (download or upload). @@ -203,6 +380,16 @@ pub enum SessionEvent { state: u8, // 0 = active, 1 = done, 2 = error msg: String, }, + /// A remote text file loaded for the built-in viewer/editor (#70). On + /// failure (too large, binary, non-UTF-8, I/O error) `error` is non-empty + /// and `content` is empty. + SftpFileText { + path: String, + name: String, + content: String, + edit: bool, + error: String, + }, } /// Handle retained by the UI layer to talk to a running session. @@ -249,9 +436,16 @@ pub fn spawn_session( let evt_tx_for_task = evt_tx.clone(); let join = runtime.spawn(async move { - if let Err(err) = - run_session(session, cmd_rx, evt_tx_for_task.clone(), initial_cols, initial_rows).await + if let Err(err) = run_session( + session, + cmd_rx, + evt_tx_for_task.clone(), + initial_cols, + initial_rows, + ) + .await { + tracing::warn!("ssh session ended with error: {err:#}"); let _ = evt_tx_for_task.send(SessionEvent::Closed(format!("{err:#}"))); } }); @@ -266,6 +460,55 @@ pub fn spawn_session( ) } +/// Open an SSH transport to the session's host (directly or via a SOCKS5 / HTTP +/// proxy) and return the russh handle, ready for authentication. Factored out so +/// the keyboard-interactive fallback can reconnect on a *fresh* handle — russh +/// hangs if a second auth method is attempted on a handle whose first attempt +/// already failed (#86). +async fn connect_ssh( + session: &Session, + config: Arc, + events: &UnboundedSender, +) -> Result> { + // Remote (-R) forwards are serviced inside the handler when the server opens + // channels back, so it needs the bind-port → local-target map up front (the + // handler is moved into `connect`) (#56). + let remote_forwards: std::collections::HashMap = session + .forwards + .iter() + .filter(|f| f.kind == "remote") + .map(|f| (f.bind_port as u32, (f.host.clone(), f.host_port))) + .collect(); + let handler = ClientHandler { + host: session.host.clone(), + port: session.port, + remote_forwards, + events: events.clone(), + }; + let addr = format!("{}:{}", session.host, session.port); + // Connect directly, or tunnel through a SOCKS5 / HTTP proxy (issue #7). + let 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))?, + }; + Ok(handle) +} + async fn run_session( session: Session, mut commands: UnboundedReceiver, @@ -274,7 +517,8 @@ async fn run_session( initial_rows: u32, ) -> Result<()> { let _ = events.send(SessionEvent::Status(format!( - "连接中 {}@{}:{} ...", + "{} {}@{}:{} ...", + t("连接中", "Connecting"), session.user, session.host, session.port ))); @@ -283,37 +527,82 @@ async fn run_session( ..<_>::default() }); - let handler = ClientHandler {}; - let addr = format!("{}:{}", session.host, session.port); - let mut handle = client::connect(config, addr.as_str(), handler) - .await - .with_context(|| format!("connect {} failed", addr))?; + let mut handle = connect_ssh(&session, config.clone(), &events).await?; + + // Resolve missing username/password by prompting the user (#110). + let (user, password) = match resolve_credentials(&session, &events).await { + Some(c) => c, + None => { + let _ = events.send(SessionEvent::Closed(t("已取消登录", "login cancelled").into())); + let _ = handle + .disconnect(Disconnect::ByApplication, "cancelled", "") + .await; + return Ok(()); + } + }; // --- Auth ---------------------------------------------------------- let authed = match session.auth { - AuthMethod::Password => handle - .authenticate_password(&session.user, &session.password) - .await - .context("password auth failed")?, + AuthMethod::Password => { + // Try plain `password` auth first; if the server doesn't offer it, + // fall back to `keyboard-interactive` and answer each prompt with the + // same password. Many bastions (JumpServer especially) disable the + // `password` method and only accept keyboard-interactive, which is + // why other clients (Xshell / MobaXterm / WindTerm) get in but plain + // password auth fails here (#86). + let mut ok = handle + .authenticate_password(&user, password.as_str()) + .await + .context("password auth failed")? + .success(); + if !ok { + // russh can't switch auth methods on a handle whose first attempt + // already failed (it hangs), so reconnect on a fresh handle before + // trying keyboard-interactive (#86). + let _ = handle.disconnect(Disconnect::ByApplication, "", "").await; + handle = connect_ssh(&session, config.clone(), &events).await?; + ok = keyboard_interactive_password(&mut handle, &user, password.as_str()) + .await + .context("keyboard-interactive auth failed")?; + } + ok + } AuthMethod::Key => { - if session.private_key_path.trim().is_empty() { - return Err(anyhow!("私钥路径为空")); + let raw = session.private_key_path.trim(); + if raw.is_empty() { + return Err(anyhow!(t("私钥路径为空", "private key path is empty"))); } - let keypair = load_secret_key(Path::new(&session.private_key_path), None) - .with_context(|| { - format!("failed to load key {}", session.private_key_path) - })?; - let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), None) - .context("invalid private key / hash algorithm combination")?; + // 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); + // An encrypted private key needs its passphrase; we reuse the + // session's password field for it (empty = unencrypted key) (#90). + let pass = password.as_str(); + let keypair = load_secret_key( + Path::new(&key_path), + if pass.is_empty() { None } else { Some(pass) }, + ) + .with_context(|| format!("failed to load key {key_path}"))?; + // RSA keys must be signed with an explicit SHA-2 hash; every other + // key type carries its own algorithm, so no override is needed. + let hash = keypair.algorithm().is_rsa().then_some(HashAlg::Sha256); + let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash); handle - .authenticate_publickey(&session.user, key_with_hash) + .authenticate_publickey(&user, key_with_hash) .await .context("publickey auth failed")? + .success() } }; if !authed { - let _ = events.send(SessionEvent::Closed("认证失败".into())); + tracing::warn!("ssh authentication failed for {}@{}", user, session.host); + let _ = events.send(SessionEvent::Closed(t("认证失败", "authentication failed").into())); let _ = handle .disconnect(Disconnect::ByApplication, "auth failed", "") .await; @@ -342,7 +631,8 @@ async fn run_session( let _ = events.send(SessionEvent::Connected); let _ = events.send(SessionEvent::Status(format!( - "已连接 {}@{}", + "{} {}@{}", + t("已连接", "Connected"), session.user, session.host ))); @@ -350,19 +640,68 @@ async fn run_session( // 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; + // True from injecting PROMPT_SETUP until the echoed setup line has been + // received and stripped; output is buffered (not shown) during that window. + let mut suppress_echo = false; + // Buffers output while `suppress_echo` so the (long) echoed setup line can be + // stripped even when it splits across reads (#98). + let mut echo_buf = String::new(); + // After a ZMODEM transfer finishes we briefly ignore ZMODEM detection so the + // sender's lingering close frames can't spawn a spurious second receive (#76). + let mut zmodem_done_at: Option = None; - // 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"; + // Cwd-notification (OSC 7) setup, injected once after the first prompt so + // the SFTP panel can follow `cd` (#91). It must work across shells: + // • bash/sh → PROMPT_COMMAND runs `__ms7` before every prompt. + // • zsh → bash's PROMPT_COMMAND is IGNORED by zsh, so we register a + // `precmd` hook via `add-zsh-hook` instead (non-destructive — + // it preserves oh-my-zsh / p10k hooks, unlike `precmd(){…}`). + // • fish → guarded out (fish 3.1+ emits OSC 7 itself). + // `__ms7` is called once at the end so the initial cwd arrives immediately. + // + // The whole shell-specific body lives inside `eval '…'`: fish can't parse + // bash/zsh function & `if` syntax, but it CAN parse `eval ''`, + // and the `test -z "$FISH_VERSION" &&` guard short-circuits before the eval + // ever runs under fish (#71). The body uses only double quotes inside so the + // outer single-quoted string needs no escaping; printf turns \033/\007 into + // ESC/BEL at prompt time. No array syntax → safe to *parse* in dash/ash too. + // + // The leading space keeps the line out of shell history (HISTCONTROL= + // ignorespace, the default on most distros); its echo is stripped locally + // (the needle below) so the bookkeeping command never shows up. + // + // Besides OSC 7 (cwd), the hook also captures the command the user just ran + // and reports it via a private `OSC 697 ; BEL` so it can join the + // command-box history (#113) — terminal-typed commands aren't otherwise + // recorded. `__msc` reads the last history entry with `fc -ln -1`; this only + // ever sees real executed commands, never password prompts (those use + // `read -s` and aren't shell commands). `__cl` remembers the last reported + // command so a redrawn prompt (e.g. Enter on an empty line) doesn't re-emit + // it, and is primed once up front so the pre-session history isn't replayed. + // + // The echoed setup line is discarded by anchoring on the OSC 7 it produces + // (see the suppress block below), so it doesn't matter that the long line + // wraps — we never substring-match it. + const PROMPT_BODY: &str = "test -z \"$FISH_VERSION\" && eval '__msc(){ __c=\"$(fc -ln -1 2>/dev/null)\"; [ -n \"$__c\" ] && [ \"$__c\" != \"$__cl\" ] && { __cl=\"$__c\"; printf \"\\033]697;%s\\007\" \"$__c\"; }; }; __ms7(){ printf \"\\033]7;file://%s%s\\007\" \"$HOSTNAME\" \"$PWD\"; __msc; }; __cl=\"$(fc -ln -1 2>/dev/null)\"; if [ -n \"$ZSH_VERSION\" ]; then autoload -Uz add-zsh-hook 2>/dev/null; add-zsh-hook precmd __ms7; else PROMPT_COMMAND=\"__ms7${PROMPT_COMMAND:+;$PROMPT_COMMAND}\"; fi; __ms7'"; + let prompt_setup = format!(" {}\r", PROMPT_BODY); // --- 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. - const MON_CMD: &[u8] = b"while :; do awk '/^cpu /{print}' /proc/stat; awk '/^(MemTotal|MemAvailable|SwapTotal|SwapFree):/{print}' /proc/meminfo; cat /proc/net/dev; echo __DF__; df -kP 2>/dev/null; echo __MSTICK__; sleep 2; done\n"; + // 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. + // The `ps` section feeds the process monitor (#23): top-40 by CPU, columns + // pid/user/pcpu/pmem/args, each line clipped to 200 chars so a giant command + // line can't bloat the stream. A host whose `ps` lacks `--sort`/`-o` simply + // yields nothing (2>/dev/null), degrading to an empty process list. + 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; cat /proc/net/dev; echo __DF__; df -kP 2>/dev/null; echo __PS__; ps -eo pid,user,pcpu,pmem,args --sort=-pcpu 2>/dev/null | head -n 41 | cut -c -200; 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), @@ -382,15 +721,67 @@ async fn run_session( std::collections::HashMap::new(); // iface -> (rx_bytes, tx_bytes) let mut prev_net_at = std::time::Instant::now(); + // --- Port forwarding / tunnels (#56) -------------------------------- + // Remote (-R) first, while we still hold `handle` mutably (tcpip_forward + // takes &mut self); the server then opens channels back, serviced in the + // handler. Then wrap the handle in an Arc so the local/dynamic listener + // tasks can share it (russh's Handle isn't Clone, but its methods are &self). + for f in session.forwards.iter().filter(|f| f.kind == "remote") { + let bind = if f.bind_addr.trim().is_empty() { + "127.0.0.1".to_string() + } else { + f.bind_addr.trim().to_string() + }; + match handle.tcpip_forward(bind.clone(), f.bind_port as u32).await { + Ok(_) => { + let _ = events.send(SessionEvent::Output(format!( + "\r\n[meatshell] -R {bind}:{} → {}:{}\r\n", + f.bind_port, f.host, f.host_port + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::Output(format!( + "\r\n[meatshell] -R {bind}:{} 请求失败 / request failed: {e}\r\n", + f.bind_port + ))); + } + } + } + let handle = Arc::new(handle); + // Local (-L) and dynamic (-D) listen client-side; their tasks are aborted + // on session exit. + let mut forward_tasks: Vec> = Vec::new(); + for f in &session.forwards { + match f.kind.as_str() { + "local" => forward_tasks.push(crate::forward::spawn_local( + handle.clone(), + f.bind_addr.clone(), + f.bind_port, + f.host.clone(), + f.host_port, + events.clone(), + )), + "dynamic" => forward_tasks.push(crate::forward::spawn_dynamic( + handle.clone(), + f.bind_addr.clone(), + f.bind_port, + events.clone(), + )), + _ => {} + } + } + // --- Main pump ------------------------------------------------------ loop { tokio::select! { cmd = commands.recv() => { match cmd { Some(SessionCommand::RawInput(bytes)) => { - tracing::debug!("ssh channel.data bytes={:02x?}", 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}"))); + let _ = events.send(SessionEvent::Closed(format!("{}: {err}", t("写入失败", "write failed")))); break; } } @@ -406,18 +797,108 @@ async fn run_session( msg = channel.wait() => { match msg { Some(ChannelMsg::Data { data }) => { - let text = String::from_utf8_lossy(&data).into_owned(); + // A `sz` in the terminal starts a ZMODEM send. Receive it + // straight to the Downloads dir (FinalShell style, #76). + // On any protocol error, cancel so the session recovers. + let zmodem_cooldown = zmodem_done_at + .is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2)); + if !zmodem_cooldown && contains_zmodem_init(&data) { + let result = + crate::zmodem::receive(&mut channel, &data, &events).await; + zmodem_done_at = Some(std::time::Instant::now()); + match result { + Ok(leftover) => { + // Bytes after the transfer (the shell prompt): + // run them through the normal output path so + // the prompt shows and the cwd updates. + if !leftover.is_empty() { + let text = + String::from_utf8_lossy(&leftover).into_owned(); + if let Some(cwd) = extract_osc7_path(&text) { + let _ = + events.send(SessionEvent::CwdChanged(cwd)); + } + let _ = events.send(SessionEvent::Output(text)); + } + } + Err(e) => { + tracing::warn!("zmodem receive failed: {e:#}"); + let _ = channel.data(&ZMODEM_CANCEL[..]).await; + let _ = events.send(SessionEvent::Output(format!( + "\r\n[meatshell] {}: {e}\r\n", + t("ZMODEM 接收失败,已取消", "ZMODEM receive failed; cancelled") + ).into())); + } + } + continue; + } + + let chunk = String::from_utf8_lossy(&data).into_owned(); // Inject PROMPT_COMMAND after the first real shell output. - if !prompt_injected && !text.trim().is_empty() { + if !prompt_injected && !chunk.trim().is_empty() { prompt_injected = true; - let _ = channel.data(PROMPT_SETUP).await; + suppress_echo = true; + let _ = channel.data(prompt_setup.as_bytes()).await; + // Fall through: this chunk is buffered below so the + // echoed setup line is stripped as a single piece. } - // 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)); + // While suppressing, buffer output until our echoed setup + // command AND the OSC 7 that the injected __ms7 prints right + // after it have both arrived. Then delete just that span — + // from the start of the command's line through the OSC 7 — + // which removes the echoed command (even if it WRAPPED across + // the terminal width, since we cut by byte range) and the + // now-redundant first prompt, while PRESERVING any MOTD/banner + // printed before it (#98). The command line is located by a + // short, un-wrappable prefix of the injected command. A size + // cap is the safety valve for a shell that never reports back + // (e.g. dash without PROMPT_COMMAND). + const PROMPT_PREFIX: &str = "test -z \"$FISH_VERSION\""; + let mut text = if suppress_echo { + echo_buf.push_str(&chunk); + const ECHO_BUF_CAP: usize = 1 << 14; // 16 KiB + // The command echo + its trailing OSC 7 (the one after + // our command, not any earlier prompt OSC 7). + let landed = echo_buf.find(PROMPT_PREFIX).and_then(|p| { + extract_osc7_end(&echo_buf[p..]) + .map(|(cwd, rel)| (p, p + rel, cwd)) + }); + if let Some((cmd_pos, osc_end, cwd)) = landed { + suppress_echo = false; + tracing::debug!("OSC7 cwd={:?}", cwd); + let _ = events.send(SessionEvent::CwdChanged(cwd)); + let mut buf = std::mem::take(&mut echo_buf); + let line_start = + buf[..cmd_pos].rfind('\n').map(|i| i + 1).unwrap_or(0); + buf.replace_range(line_start..osc_end, ""); + buf + } else if echo_buf.len() >= ECHO_BUF_CAP { + suppress_echo = false; + std::mem::take(&mut echo_buf) + } else { + continue; // keep buffering; show nothing yet + } + } else { + // Scan for the OSC 7 CWD notification (cd-follow). + if let Some(cwd) = extract_osc7_path(&chunk) { + tracing::debug!("OSC7 cwd={:?}", cwd); + let _ = events.send(SessionEvent::CwdChanged(cwd)); + } + chunk + }; + + // Capture commands run in the terminal via our OSC 697 + // hook, and strip the sequence so it never reaches the + // renderer (#113). Skip our own injected setup line in the + // rare case HISTCONTROL=ignorespace isn't in effect. + while let Some((cmd, range)) = extract_osc_command(&text) { + text.replace_range(range, ""); + let cmd = cmd.trim(); + if !cmd.is_empty() && !cmd.contains("__ms7") { + let _ = events.send(SessionEvent::CommandRan(cmd.to_string())); + } } let _ = events.send(SessionEvent::Output(text)); @@ -428,7 +909,7 @@ async fn run_session( } Some(ChannelMsg::ExitStatus { exit_status }) => { let _ = events.send(SessionEvent::Status( - format!("远程进程退出 (code {exit_status})"), + format!("{} (code {exit_status})", t("远程进程退出", "remote process exited")), )); } Some(ChannelMsg::Close) | None => { @@ -465,6 +946,14 @@ async fn run_session( 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; @@ -475,10 +964,19 @@ async fn run_session( } } + // Tear down any port-forward listeners (#56); -R forwards die with the + // session's disconnect below. + for task in forward_tasks { + task.abort(); + } + let _ = handle .disconnect(Disconnect::ByApplication, "bye", "") .await; - let _ = events.send(SessionEvent::Closed("连接已关闭".into())); + // The shell pump loop only exits when the channel closes / EOFs (incl. a + // peer/bastion-initiated disconnect), so record it for #86 diagnostics. + tracing::warn!("ssh connection closed ({}@{})", session.user, session.host); + let _ = events.send(SessionEvent::Closed(t("连接已关闭", "connection closed").into())); Ok(()) } @@ -505,19 +1003,50 @@ fn parse_monitor_block( 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 in_df = false; + // Processes from `ps` (#23): top-by-CPU rows. + let mut procs: Vec = Vec::new(); + // The sample is split into sections by `echo` markers; everything before the + // first marker is the cpu/mem/net block. + enum Section { + Top, + Df, + Ps, + } + let mut section = Section::Top; + + // 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; for line in block.lines() { if line == "__DF__" { - in_df = true; + section = Section::Df; continue; } - if in_df { - if let Some(d) = parse_df_line(line) { - disks.push(d); - } + if line == "__PS__" { + section = Section::Ps; continue; } + match section { + Section::Df => { + if disks.len() < MAX_MON_ENTRIES { + if let Some(d) = parse_df_line(line) { + disks.push(d); + } + } + continue; + } + Section::Ps => { + if procs.len() < MAX_MON_ENTRIES { + if let Some(p) = parse_ps_line(line) { + procs.push(p); + } + } + continue; + } + Section::Top => {} + } if let Some(rest) = line.strip_prefix("cpu ") { let nums: Vec = rest .split_whitespace() @@ -525,8 +1054,10 @@ fn parse_monitor_block( .collect(); // user nice system idle iowait irq softirq steal ... if nums.len() >= 4 { - cpu_total = nums.iter().sum(); - cpu_idle = nums[3] + nums.get(4).copied().unwrap_or(0); // idle + iowait + // 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:") { @@ -537,8 +1068,10 @@ fn parse_monitor_block( swap_total = parse_meminfo_kib(v); } else if let Some(v) = line.strip_prefix("SwapFree:") { swap_free = parse_meminfo_kib(v); - } else if let Some((iface, counters)) = parse_net_dev_line(line) { - net_now.push((iface, counters.0, counters.1)); + } 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)); + } } } @@ -595,6 +1128,30 @@ fn parse_monitor_block( swap_total_kib: swap_total, net, disks, + procs, + }) +} + +/// Parse one `ps -eo pid,user,pcpu,pmem,args` line into a [`ProcInfo`]. The +/// header row (`PID` is not numeric) and any malformed line yield `None`. +/// `args` (everything past the four fixed columns) keeps internal spacing +/// collapsed — fine for a display-only command column. +fn parse_ps_line(line: &str) -> Option { + let mut it = line.split_whitespace(); + let pid: u32 = it.next()?.parse().ok()?; + let user = it.next()?.to_string(); + let cpu: f32 = it.next()?.parse().ok()?; + let mem: f32 = it.next()?.parse().ok()?; + let command = it.collect::>().join(" "); + if command.is_empty() { + return None; + } + Some(ProcInfo { + pid, + user, + cpu, + mem, + command, }) } @@ -612,7 +1169,9 @@ fn parse_df_line(line: &str) -> Option<(String, u64, u64)> { } // Mount point is the last column (joined in case it contains spaces). let mount = f[5..].join(" "); - Some((mount, avail_kb * 1024, total_kb * 1024)) + // 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 @@ -644,28 +1203,176 @@ fn parse_net_dev_line(line: &str) -> Option<(String, (u64, u64))> { Some((iface.to_string(), (nums[0], nums[8]))) } -/// Dead-simple client handler. For v0.1 we accept any server key (similar to -/// `ssh -o StrictHostKeyChecking=no`). A real host-key verification flow -/// with on-disk known_hosts is on the roadmap. -struct ClientHandler; +/// Authenticate via `keyboard-interactive`, answering every prompt with the +/// given password. This is the fallback for bastions that disable the plain +/// `password` method (e.g. JumpServer) but still authenticate by password — the +/// server sends a single "Password:" prompt over keyboard-interactive (#86). +/// +/// Prompts are answered with the password regardless of their text, which covers +/// the common single-password case; genuine multi-factor prompts (an OTP code on +/// top of the password) would need interactive input and are not handled here. +async fn keyboard_interactive_password( + handle: &mut Handle, + user: &str, + password: &str, +) -> Result { + use russh::client::KeyboardInteractiveAuthResponse as Kb; + let mut res = handle + .authenticate_keyboard_interactive_start(user.to_string(), None) + .await?; + // Bound the exchange so a misbehaving server can't loop us forever. + for _ in 0..16 { + match res { + Kb::Success => return Ok(true), + Kb::Failure { .. } => return Ok(false), + Kb::InfoRequest { prompts, .. } => { + let responses = prompts.iter().map(|_| password.to_string()).collect(); + res = handle + .authenticate_keyboard_interactive_respond(responses) + .await?; + } + } + } + Ok(false) +} + +/// Client handler. Verifies the server host key against the known_hosts store, +/// prompting the user on first contact / on a changed key (#109-5). +/// +/// Carries the remote-forward (-R) map so we can service channels the server +/// opens back to us: server bind-port → local `(host, port)` target (#56). +pub(crate) struct ClientHandler { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) remote_forwards: std::collections::HashMap, + pub(crate) events: UnboundedSender, +} + +/// Shared host-key check used by both the shell and SFTP connections: trust a +/// matching stored key silently; otherwise ask the UI (via `events`) and, on +/// acceptance, remember the key. A dropped/closed reply channel (UI gone) +/// counts as a rejection so we never connect to an unverified host. +pub(crate) async fn verify_host_key( + host: &str, + port: u16, + key: &PublicKey, + events: &UnboundedSender, +) -> bool { + use crate::known_hosts::HostKeyStatus; + match crate::known_hosts::verify(host, port, key) { + HostKeyStatus::Match => true, + status => { + let changed = status == HostKeyStatus::Changed; + let (tx, rx) = tokio::sync::oneshot::channel(); + let sent = events.send(SessionEvent::HostKeyPrompt { + host: host.to_string(), + port, + key_type: key.algorithm().to_string(), + fingerprint: crate::known_hosts::fingerprint(key), + changed, + responder: HostKeyResponder::new(tx), + }); + if sent.is_err() { + return false; // no UI to ask + } + match rx.await { + Ok(true) => { + if let Err(e) = crate::known_hosts::remember(host, port, key) { + tracing::warn!("could not save host key for {host}:{port}: {e:#}"); + } + true + } + _ => false, + } + } + } +} + +/// Resolve a session's username/password, prompting the UI for whatever is +/// missing (#110). Returns the effective `(user, password)`, or `None` if the +/// user cancelled. Both the shell and SFTP connections call this; the UI +/// de-duplicates by session id so a single dialog serves both. A dropped reply +/// channel (no UI) falls through with the stored values so auth fails normally. +pub(crate) async fn resolve_credentials( + session: &Session, + events: &UnboundedSender, +) -> Option<(String, String)> { + let mut user = session.user.trim().to_string(); + let mut password = session.password.as_str().to_string(); + let need_user = user.is_empty(); + let need_password = + matches!(session.auth, AuthMethod::Password) && password.is_empty(); + if !(need_user || need_password) { + return Some((user, password)); + } + let (tx, rx) = tokio::sync::oneshot::channel(); + let sent = events.send(SessionEvent::CredentialPrompt { + session_id: session.id.clone(), + host: session.host.clone(), + user: user.clone(), + need_user, + need_password, + responder: CredentialResponder::new(tx), + }); + if sent.is_err() { + return Some((user, password)); + } + match rx.await { + Ok(Some((u, p, _remember))) => { + if need_user { + user = u.trim().to_string(); + } + if need_password { + password = p; + } + Some((user, password)) + } + _ => None, + } +} -#[async_trait] impl Handler for ClientHandler { type Error = russh::Error; async fn check_server_key( &mut self, - _server_public_key: &PublicKey, + server_public_key: &PublicKey, ) -> Result { - Ok(true) + Ok(verify_host_key(&self.host, self.port, server_public_key, &self.events).await) } - async fn data( + /// Remote forward (-R): the server opened a channel for a connection that + /// arrived on a port we asked it to listen on. Connect to the configured + /// local target and splice the two together (#56). + async fn server_channel_open_forwarded_tcpip( &mut self, - _channel: ChannelId, - _data: &[u8], + channel: Channel, + connected_address: &str, + connected_port: u32, + _originator_address: &str, + _originator_port: u32, _session: &mut client::Session, ) -> Result<(), Self::Error> { + let target = self.remote_forwards.get(&connected_port).cloned(); + let events = self.events.clone(); + let bind = connected_address.to_string(); + tokio::spawn(async move { + let Some((host, port)) = target else { + tracing::warn!("forwarded-tcpip on {bind}:{connected_port} with no mapping"); + return; + }; + match tokio::net::TcpStream::connect((host.as_str(), port)).await { + Ok(mut tcp) => { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut tcp, &mut stream).await; + } + Err(e) => { + let _ = events.send(SessionEvent::Output(format!( + "\r\n[meatshell] -R {host}:{port} 连接失败 / connect failed: {e}\r\n" + ))); + } + } + }); Ok(()) } } @@ -677,3 +1384,77 @@ fn _assert_handle_send() { takes::>(); } +#[cfg(test)] +mod osc_command_tests { + use super::extract_osc_command; + + #[test] + fn extracts_and_locates_bel_terminated() { + let text = "before\u{1b}]697;ls -la\u{07}after"; + let (cmd, range) = extract_osc_command(text).expect("found"); + assert_eq!(cmd, "ls -la"); + // Stripping the range leaves the surrounding text intact. + let mut s = text.to_string(); + s.replace_range(range, ""); + assert_eq!(s, "beforeafter"); + } + + #[test] + fn extracts_st_terminated() { + let text = "\u{1b}]697;echo hi\u{1b}\\"; + let (cmd, _) = extract_osc_command(text).expect("found"); + assert_eq!(cmd, "echo hi"); + } + + #[test] + fn ignores_other_osc_and_incomplete() { + // OSC 7 (cwd) is not a command sequence. + assert!(extract_osc_command("\u{1b}]7;file:///home\u{07}").is_none()); + // No terminator yet → wait for more. + assert!(extract_osc_command("\u{1b}]697;ls").is_none()); + assert!(extract_osc_command("plain text").is_none()); + } +} + +#[cfg(test)] +mod monitor_hardening_tests { + use super::{parse_df_line, parse_monitor_block}; + 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()); + } +} diff --git a/src/ssh_config.rs b/src/ssh_config.rs new file mode 100644 index 00000000..88cf65b6 --- /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 index f8229e31..63a5e357 100644 --- a/src/system.rs +++ b/src/system.rs @@ -124,6 +124,26 @@ impl SystemSampler { } } +/// Format a used/total memory pair (both in MiB) for the narrow sidebar. +/// Below 1 GiB it stays in megabytes (`512/2048M`); at or above, it switches to +/// gigabytes and drops the decimal for whole or large values to stay compact +/// (`1.5G/16G`, `120G/256G`). +pub fn format_mem(used_mib: u64, total_mib: u64) -> String { + if total_mib < 1024 { + return format!("{used_mib}/{total_mib}M"); + } + // MiB → GiB, with a tidy width: integer when round or ≥100, else one decimal. + fn gib(mib: u64) -> String { + let g = mib as f64 / 1024.0; + if g.fract() == 0.0 || g >= 100.0 { + (g as u64).to_string() + } else { + format!("{g:.1}") + } + } + format!("{}G/{}G", gib(used_mib), gib(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"]; diff --git a/src/telnet.rs b/src/telnet.rs new file mode 100644 index 00000000..ed0b4f27 --- /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/src/zmodem.rs b/src/zmodem.rs new file mode 100644 index 00000000..1d402fba --- /dev/null +++ b/src/zmodem.rs @@ -0,0 +1,540 @@ +//! Minimal ZMODEM **receiver** — the `rz` side of an `sz` transfer (#76). +//! +//! When the user runs `sz ` in the terminal, the remote starts a ZMODEM +//! send. We implement just enough of the protocol to receive: reply to ZRQINIT +//! with ZRINIT, accept ZFILE, drive the transfer with ZRPOS/ZACK, collect the +//! ZDATA subpackets into a local file, and finish on ZEOF/ZFIN. Files land in +//! the user's Downloads directory (FinalShell style). +//! +//! We advertise CANFC32, so the sender uses CRC-32 binary frames; the CRC-16 +//! paths are implemented for completeness but rarely exercised. +//! +//! This is intentionally a *receive-only* implementation; `rz` (upload) is not +//! handled here. Every header is logged at debug level to aid diagnosis, since +//! the binary protocol can't easily be tested without a live server. + +use crate::i18n::t; +use crate::ssh::SessionEvent; +use anyhow::{bail, Context, Result}; +use russh::client::Msg; +use russh::{Channel, ChannelMsg}; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc::UnboundedSender; + +// --- Frame types ----------------------------------------------------------- +const ZRQINIT: u8 = 0; +const ZRINIT: u8 = 1; +const ZACK: u8 = 3; +const ZFILE: u8 = 4; +const ZNAK: u8 = 6; +const ZABORT: u8 = 7; +const ZFIN: u8 = 8; +const ZRPOS: u8 = 9; +const ZDATA: u8 = 10; +const ZEOF: u8 = 11; +const ZCAN: u8 = 16; + +// --- Control bytes --------------------------------------------------------- +const ZDLE: u8 = 0x18; // ZMODEM escape (also CAN) +const ZPAD: u8 = b'*'; +const ZBIN: u8 = b'A'; // binary header, CRC-16 +const ZHEX: u8 = b'B'; // hex header, CRC-16 +const ZBIN32: u8 = b'C'; // binary header, CRC-32 + +// Data-subpacket terminators (the byte right after a ZDLE inside data). +const ZCRCE: u8 = b'h'; // end of frame, header follows, no ZACK +const ZCRCG: u8 = b'i'; // frame continues, no ZACK +const ZCRCQ: u8 = b'j'; // frame continues, ZACK expected +const ZCRCW: u8 = b'k'; // end of frame, ZACK expected +const ZRUB0: u8 = b'l'; // escaped 0x7f +const ZRUB1: u8 = b'm'; // escaped 0xff + +// ZRINIT capability flags we advertise: full-duplex, overlap I/O, CRC-32. +const CANFDX: u8 = 0x01; +const CANOVIO: u8 = 0x02; +const CANFC32: u8 = 0x20; + +/// Receive one or more files via ZMODEM. `first` is the channel chunk that +/// triggered detection (it contains the leading ZRQINIT). +/// +/// Returns any bytes read past the end of the ZMODEM session (typically the +/// shell prompt the sender's exit produces) so the caller can feed them back to +/// the terminal — otherwise the prompt would be swallowed. On a protocol failure +/// it returns an error and the caller cancels. +pub async fn receive( + channel: &mut Channel, + first: &[u8], + events: &UnboundedSender, +) -> Result> { + let dest = download_dir(); + tokio::fs::create_dir_all(&dest) + .await + .with_context(|| format!("create download dir {}", dest.display()))?; + + tracing::debug!( + "zmodem: receive start, first[{}]={:02x?}", + first.len(), + &first[..first.len().min(80)] + ); + + let mut rx = Rx::new(channel, first); + let mut received = 0u32; + let mut cur: Option = None; + // A header already read ahead (e.g. the next ZFILE peeked after a ZEOF). + let mut pending: Option<(u8, [u8; 4])> = None; + + loop { + let (ftype, hdr) = match pending.take() { + Some(h) => h, + None => rx.read_header().await?, + }; + tracing::debug!("zmodem rx header type={ftype} data={hdr:02x?}"); + match ftype { + ZRQINIT => rx.send_hex(ZRINIT, [0, 0, 0, CANFDX | CANOVIO | CANFC32]).await?, + ZFILE => { + // Data subpacket: "name\0size mtime mode ...". + let (sub, _end) = rx.read_subpacket(true).await?; + let nul = sub.iter().position(|&b| b == 0).unwrap_or(sub.len()); + let name = sanitize(&String::from_utf8_lossy(&sub[..nul])); + let size = sub + .get(nul + 1..) + .map(|rest| String::from_utf8_lossy(rest)) + .and_then(|s| s.split_whitespace().next().map(str::to_owned)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let path = dest.join(&name); + let file = tokio::fs::File::create(&path) + .await + .with_context(|| format!("create {}", path.display()))?; + let id = format!("zmodem-{}", uuid::Uuid::new_v4()); + emit(events, &id, &name, 0, size, 0, ""); + cur = Some(CurFile { + file, + name, + id, + size, + written: 0, + }); + rx.send_hex(ZRPOS, 0u32.to_le_bytes()).await?; + } + ZDATA => { + loop { + let (chunk, end) = rx.read_subpacket(true).await?; + if let Some(c) = cur.as_mut() { + c.file.write_all(&chunk).await.context("write file")?; + c.written += chunk.len() as u64; + emit(events, &c.id, &c.name, c.written, c.size.max(c.written), 0, ""); + } + match end { + ZCRCG => continue, + ZCRCQ => { + let pos = cur.as_ref().map(|c| c.written).unwrap_or(0) as u32; + rx.send_hex(ZACK, pos.to_le_bytes()).await?; + } + ZCRCE => break, + ZCRCW => { + let pos = cur.as_ref().map(|c| c.written).unwrap_or(0) as u32; + rx.send_hex(ZACK, pos.to_le_bytes()).await?; + break; + } + _ => break, + } + } + } + ZEOF => { + if let Some(mut c) = cur.take() { + c.file.flush().await.context("flush file")?; + emit(events, &c.id, &c.name, c.written, c.size.max(c.written), 1, ""); + received += 1; + } + // ZEOF ends one *file*, not the whole session (#109). Tell the + // sender we're ready for more (ZRINIT) and peek the next frame: + // a multi-file `sz` sends the next ZFILE; otherwise it sends ZFIN + // (or just waits for our ZFIN and sends nothing). The peek is + // capped by a short timeout so a finished single-file transfer + // never blocks on the long per-byte read timeout — anything that + // isn't a ZFILE drops to the close handshake below. + rx.send_hex(ZRINIT, [0, 0, 0, CANFDX | CANOVIO | CANFC32]).await?; + match tokio::time::timeout(Duration::from_secs(2), rx.read_header()).await { + Ok(Ok(h)) if h.0 == ZFILE => pending = Some(h), + _ => break, // ZFIN / unexpected / parse error / timeout → done + } + } + ZFIN => break, // sender signals the whole session is done + ZCAN | ZABORT => bail!("{}", t("传输被远端取消", "transfer aborted by sender")), + ZNAK => { /* sender NAK; just keep going */ } + _ => tracing::debug!("zmodem: ignoring unhandled frame type {ftype}"), + } + } + + // Close handshake. The sender (lrzsz `sz`) just sent ZEOF and is finishing + // its session: it expects a ZRINIT, then sends ZFIN and waits for OUR ZFIN + // before emitting "OO" (over-and-out) and exiting. We reply so it exits + // promptly, and consume its ZFIN + OO here so they don't leak to the terminal + // or get re-detected as a new transfer. Whatever follows (the shell prompt) + // stays in the buffer and is returned to the caller (#76). + if received > 0 { + // Send ZRINIT + ZFIN *immediately and unconditionally*. This sender + // finishes its session waiting for OUR ZFIN and does not send its own + // first; if we wait to read its ZFIN it never comes and the sender hangs + // ~100 s on its global timeout. Sending ZFIN proactively makes it exit at + // once. Then swallow its lingering close frames (its ZFIN / "OO"), + // stopping at the first byte that isn't part of a ZMODEM hex header or + // "OO" — that byte begins the shell prompt, returned as leftover (#76). + let _ = rx + .send_hex(ZRINIT, [0, 0, 0, CANFDX | CANOVIO | CANFC32]) + .await; + let _ = rx.send_hex(ZFIN, [0, 0, 0, 0]).await; + let _ = tokio::time::timeout(Duration::from_millis(800), async { + for _ in 0..64 { + match rx.byte().await { + Ok(b) if is_close_byte(b) => continue, + Ok(b) => { + rx.buf.push_front(b); // start of the shell prompt + break; + } + Err(_) => break, + } + } + }) + .await; + } + + let _ = events.send(SessionEvent::Output( + format!( + "\r\n[meatshell] {} {} → {}\r\n", + received, + t("个文件已通过 sz 下载到", "file(s) downloaded via sz to"), + dest.display() + ) + .into(), + )); + // Hand back any trailing bytes (the shell prompt) so the caller can display + // them instead of the receiver swallowing them. + Ok(rx.buf.drain(..).collect()) +} + +struct CurFile { + file: tokio::fs::File, + name: String, + id: String, + size: u64, + written: u64, +} + +/// Reader/writer over the SSH channel with a byte buffer and ZMODEM helpers. +struct Rx<'a> { + ch: &'a mut Channel, + buf: VecDeque, + closed: bool, +} + +impl<'a> Rx<'a> { + fn new(ch: &'a mut Channel, first: &[u8]) -> Self { + Rx { + ch, + buf: first.iter().copied().collect(), + closed: false, + } + } + + /// Next raw byte; pulls more channel data when the buffer drains. + async fn byte(&mut self) -> Result { + loop { + if let Some(b) = self.buf.pop_front() { + return Ok(b); + } + if self.closed { + bail!("channel closed during ZMODEM"); + } + // Guard against a stalled transfer hanging the session forever. + let msg = tokio::time::timeout(Duration::from_secs(30), self.ch.wait()) + .await + .map_err(|_| anyhow::anyhow!("ZMODEM read timed out"))?; + match msg { + Some(ChannelMsg::Data { data }) => self.buf.extend(data.iter().copied()), + Some(ChannelMsg::ExtendedData { data, .. }) => { + self.buf.extend(data.iter().copied()) + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => self.closed = true, + _ => {} + } + } + } + + /// One logical byte with ZDLE un-escaping applied. + async fn zbyte(&mut self) -> Result { + let b = self.byte().await?; + if b != ZDLE { + return Ok(b); + } + let e = self.byte().await?; + Ok(match e { + ZRUB0 => 0x7f, + ZRUB1 => 0xff, + _ => e ^ 0x40, + }) + } + + /// Read the next frame header, scanning past any padding/garbage. Returns + /// the frame type and its four data bytes. + async fn read_header(&mut self) -> Result<(u8, [u8; 4])> { + loop { + // Find ZDLE followed by a recognised format byte. + if self.byte().await? != ZDLE { + continue; + } + match self.byte().await? { + ZHEX => return self.read_hex_header().await, + ZBIN => return self.read_bin_header(false).await, + ZBIN32 => return self.read_bin_header(true).await, + _ => continue, // not a header start (could be a CAN run); keep scanning + } + } + } + + async fn read_hex_header(&mut self) -> Result<(u8, [u8; 4])> { + let mut bytes = [0u8; 5]; + for b in bytes.iter_mut() { + *b = self.hex_byte().await?; + } + let crc_hi = self.hex_byte().await?; + let crc_lo = self.hex_byte().await?; + let crc = u16::from_be_bytes([crc_hi, crc_lo]); + if crc16(&bytes) != crc { + bail!("hex header CRC mismatch"); + } + // Swallow the trailing CR/LF (+ optional XON) up to the newline. + for _ in 0..3 { + match self.byte().await? { + b'\n' => break, + _ => continue, + } + } + Ok((bytes[0], [bytes[1], bytes[2], bytes[3], bytes[4]])) + } + + async fn read_bin_header(&mut self, crc32: bool) -> Result<(u8, [u8; 4])> { + let mut bytes = [0u8; 5]; + for b in bytes.iter_mut() { + *b = self.zbyte().await?; + } + if crc32 { + let mut c = [0u8; 4]; + for b in c.iter_mut() { + *b = self.zbyte().await?; + } + if crc32_of(&bytes) != u32::from_le_bytes(c) { + bail!("bin32 header CRC mismatch"); + } + } else { + let hi = self.zbyte().await?; + let lo = self.zbyte().await?; + if crc16(&bytes) != u16::from_be_bytes([hi, lo]) { + bail!("bin16 header CRC mismatch"); + } + } + Ok((bytes[0], [bytes[1], bytes[2], bytes[3], bytes[4]])) + } + + /// Read a data subpacket, returning the (un-escaped) data and the terminator + /// byte (ZCRCE/ZCRCG/ZCRCQ/ZCRCW). The CRC covers data + terminator. + async fn read_subpacket(&mut self, crc32: bool) -> Result<(Vec, u8)> { + let mut data = Vec::new(); + loop { + let b = self.byte().await?; + if b != ZDLE { + data.push(b); + continue; + } + let e = self.byte().await?; + match e { + ZCRCE | ZCRCG | ZCRCQ | ZCRCW => { + let mut crcbuf = data.clone(); + crcbuf.push(e); + if crc32 { + let mut c = [0u8; 4]; + for x in c.iter_mut() { + *x = self.zbyte().await?; + } + if crc32_of(&crcbuf) != u32::from_le_bytes(c) { + bail!("subpacket CRC-32 mismatch"); + } + } else { + let hi = self.zbyte().await?; + let lo = self.zbyte().await?; + if crc16(&crcbuf) != u16::from_be_bytes([hi, lo]) { + bail!("subpacket CRC-16 mismatch"); + } + } + return Ok((data, e)); + } + ZRUB0 => data.push(0x7f), + ZRUB1 => data.push(0xff), + _ => data.push(e ^ 0x40), + } + } + } + + /// Read two hex ASCII digits into a byte. + async fn hex_byte(&mut self) -> Result { + let hi = from_hex(self.byte().await?)?; + let lo = from_hex(self.byte().await?)?; + Ok((hi << 4) | lo) + } + + /// Send a hex-encoded header (always accepted regardless of CRC mode). + async fn send_hex(&mut self, ftype: u8, data: [u8; 4]) -> Result<()> { + let payload = [ftype, data[0], data[1], data[2], data[3]]; + let crc = crc16(&payload); + let mut out = vec![ZPAD, ZPAD, ZDLE, ZHEX]; + for &b in &payload { + out.extend_from_slice(&hex_digits(b)); + } + out.extend_from_slice(&hex_digits((crc >> 8) as u8)); + out.extend_from_slice(&hex_digits((crc & 0xff) as u8)); + out.extend_from_slice(b"\r\n"); + // XON after every hex header except ZACK/ZFIN (per the protocol). + if ftype != ZACK && ftype != ZFIN { + out.push(0x11); + } + tracing::debug!("zmodem tx type={ftype} bytes={:02x?}", &out); + self.ch.data(&out[..]).await.context("zmodem send header")?; + Ok(()) + } +} + +/// True for bytes that make up a ZMODEM hex close frame (ZFIN) or the "OO" +/// over-and-out, used to drain the sender's lingering close frames without +/// eating the shell prompt that follows (which starts with ESC/letters) (#76). +fn is_close_byte(b: u8) -> bool { + matches!(b, + b'*' | ZDLE | b'A' | b'B' | b'C' | b'O' + | b'\r' | b'\n' | 0x8a | 0x11 + | b'0'..=b'9' | b'a'..=b'f') +} + +/// Where received files go: the user's Downloads dir, else a temp fallback. +fn download_dir() -> PathBuf { + directories::UserDirs::new() + .and_then(|u| u.download_dir().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::env::temp_dir().join("meatshell")) +} + +/// Reduce a sender-supplied name to a safe basename inside the download dir. +fn sanitize(name: &str) -> String { + let base = name.rsplit(|c| c == '/' || c == '\\').next().unwrap_or(name); + let cleaned: String = base + .chars() + .filter(|c| !matches!(c, '\0' | '/' | '\\')) + .collect(); + // Trim trailing dots/spaces (illegal on Windows) and leading spaces, but + // KEEP leading dots so dotfiles like ".viminfo" keep their name (#76). + let cleaned = cleaned + .trim_end_matches(|c| c == '.' || c == ' ') + .trim_start_matches(' '); + if cleaned.is_empty() || cleaned.chars().all(|c| c == '.') { + "download".to_string() + } else { + cleaned.to_string() + } +} + +fn emit( + events: &UnboundedSender, + id: &str, + name: &str, + transferred: u64, + total: u64, + state: u8, + msg: &str, +) { + let _ = events.send(SessionEvent::SftpTransfer { + id: id.to_string(), + name: name.to_string(), + is_upload: false, + transferred, + total, + state, + msg: msg.to_string(), + }); +} + +fn hex_digits(b: u8) -> [u8; 2] { + const H: &[u8; 16] = b"0123456789abcdef"; + [H[(b >> 4) as usize], H[(b & 0x0f) as usize]] +} + +fn from_hex(c: u8) -> Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + _ => bail!("invalid hex digit {c:#x}"), + } +} + +/// CRC-16/XMODEM (poly 0x1021, init 0, no final xor) — ZMODEM header/subpacket. +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &b in data { + crc ^= (b as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x1021 + } else { + crc << 1 + }; + } + } + crc +} + +/// CRC-32/ISO-HDLC (zlib): init 0xFFFFFFFF, reflected, final xor 0xFFFFFFFF. +fn crc32_of(data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for &b in data { + crc ^= b as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc16_known_vector() { + // CRC-16/XMODEM of "123456789" is 0x31C3. + assert_eq!(crc16(b"123456789"), 0x31C3); + } + + #[test] + fn crc32_known_vector() { + // CRC-32 of "123456789" is 0xCBF43926. + assert_eq!(crc32_of(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn sanitize_strips_paths() { + assert_eq!(sanitize("/etc/passwd"), "passwd"); + assert_eq!(sanitize("..\\..\\x"), "x"); + assert_eq!(sanitize(""), "download"); + // Dotfiles keep their leading dot. + assert_eq!(sanitize(".viminfo"), ".viminfo"); + assert_eq!(sanitize("/home/jeff/.bashrc"), ".bashrc"); + // Trailing dots/spaces are trimmed; pure-dot names rejected. + assert_eq!(sanitize("name..."), "name"); + assert_eq!(sanitize(".."), "download"); + } +} diff --git a/ui/app.slint b/ui/app.slint index 88f4b62e..180e4453 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -2,22 +2,34 @@ // 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). +// "Meatshell Mono" is Cascadia Mono re-named to a unique family (#114): full +// box-drawing + block-elements + braille (needed by htop/btop graphs). The +// original "Cascadia Mono" name collides with the system font family, so on a +// machine without Cascadia installed (e.g. Win11 Home) the OS font matcher +// substituted a glyph-poor fallback and box/braille rendered as tofu. A unique +// name (like Material Icons) can't be shadowed, so the embedded font is always +// used. // Material Icons: open-source icon font replacing Windows-only Segoe MDL2 Assets. -import "fonts/CascadiaMono-Regular.ttf"; -import "fonts/CascadiaMono-Bold.ttf"; +import "fonts/MeatshellMono-Regular.ttf"; +import "fonts/MeatshellMono-Bold.ttf"; import "fonts/MaterialIcons-Regular.ttf"; import { Theme } from "theme.slint"; import { Sidebar, DiskInfo } from "sidebar.slint"; import { TabBar, TabInfo } from "tabs.slint"; import { Welcome, SessionInfo } from "welcome.slint"; -import { SessionDialog, SessionDraft } from "session_dialog.slint"; -import { TerminalView, TermSpan, TermMatch } from "terminal_view.slint"; +import { SessionDialog, SessionDraft, PortFwd } from "session_dialog.slint"; +import { ConfirmDialog } from "confirm_dialog.slint"; +import { LabeledInput, PrimaryButton, GhostButton } from "widgets.slint"; +import { TerminalView, TermSpan, TermMatch, QuickCmd } from "terminal_view.slint"; import { SftpEntry, SftpTreeNode } from "sftp_panel.slint"; +import { ProcWindow, ProcRow } from "proc_window.slint"; +import { InterfacePanel } from "interface_panel.slint"; +// Re-export so `slint::include_modules!` generates the ProcWindow Rust type. +export { ProcWindow } +import { ComboBox, SpinBox, LineEdit, Button, ScrollView, CheckBox, Palette } from "std-widgets.slint"; -export { SessionInfo, SessionDraft, TabInfo, SftpEntry, SftpTreeNode } +export { SessionInfo, SessionDraft, TabInfo, SftpEntry, SftpTreeNode, QuickCmd, PortFwd } // --- TerminalState --------------------------------------------------------- // Per-terminal view-model exposed to Rust. A TabInfo has the metadata; the @@ -33,6 +45,8 @@ export struct TransferInfo { is-upload: bool, } +// ProcRow now lives in proc_window.slint (the detachable process window). + export struct TerminalState { id: string, status: string, @@ -40,6 +54,8 @@ export struct TerminalState { cursor-row: int, // cursor cell on the grid cursor-col: int, rows-used: int, // content rows, for viewport sizing + scroll-max: int, // scrollback depth (max offset) — terminal scrollbar (#103) + scroll-offset: int, // current scrollback offset (0 = live bottom) is-alt-screen: bool, find-matches: [TermMatch], // search-highlight rectangles selection: [TermMatch], // drag-selection highlight rectangles @@ -50,21 +66,140 @@ export struct TerminalState { sftp-status: string, sftp-loading: bool, sftp-tree-nodes: [SftpTreeNode], + sftp-selected-count: int, // checked entries in the SFTP panel (#100) +} + +// A 26×26 top-bar icon button with a hover tooltip (right-aligned so it never +// runs off the window edge). `active` keeps the hover background lit (e.g. while +// its popup is open). +// A Windows-style window-control button (minimize / maximize / close) for the +// custom title bar (#119). `danger` gives the close button its red hover. +component WinBtn inherits Rectangle { + in property icon; + in property danger: false; + callback clicked(); + width: 46px; + background: ta.has-hover ? (root.danger ? #e81123 : Theme.bg-hover) : transparent; + animate background { duration: 90ms; } + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + Text { + text: root.icon; + font-family: "Material Icons"; + color: (root.danger && ta.has-hover) ? white : Theme.text-secondary; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +component IconBtn inherits Rectangle { + in property icon; + in property icon-color: Theme.text-secondary; + in property tooltip; + in property active; + callback clicked(); + width: 26px; + height: 26px; + border-radius: Theme.radius-sm; + background: ta.has-hover || root.active ? Theme.bg-hover : transparent; + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + Text { + text: root.icon; + font-family: "Material Icons"; + color: root.icon-color; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + if ta.has-hover && root.tooltip != "" : Rectangle { + y: parent.height + 5px; + x: parent.width - self.width; // align right edge with the icon + width: tip-txt.preferred-width + 16px; + height: 22px; + background: Theme.bg-elevated; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 8px; + drop-shadow-color: #00000070; + tip-txt := Text { + text: root.tooltip; + color: Theme.text-primary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; + height: parent.height; + } + } +} + +// One row in the keyboard-shortcuts dialog: a key chip + a description (#103). +component ShortcutRow inherits Rectangle { + in property keys; + in property desc; + height: 28px; + HorizontalLayout { + spacing: 10px; + Rectangle { + width: 116px; + chip := Rectangle { + x: 0; + y: (parent.height - self.height) / 2; + width: min(parent.width, kt.preferred-width + 16px); + height: 20px; + background: Theme.bg-root; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + kt := Text { + text: root.keys; + color: Theme.text-primary; + font-family: Theme.font-mono; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; + height: parent.height; + } + } + } + Text { + text: root.desc; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + horizontal-stretch: 1; + } + } } export component AppWindow inherits Window { title: "meatshell"; icon: @image-url("../assets/icon.png"); + // Frameless when we draw our own title bar (#119); Rust sets custom-titlebar + // true on Windows/Linux. Slint maps no-frame → winit set_decorations(false). + no-frame: root.custom-titlebar; min-width: 960px; min-height: 600px; - preferred-width: 1200px; - preferred-height: 760px; + preferred-width: 1440px; + preferred-height: 900px; background: Theme.bg-root; + // A CJK-capable default so UI text (incl. fullwidth punctuation like :()) + // never tofus on glyph fallback (#54). Rust sets Theme.ui-font-family to a + // system CJK font on startup; empty on Linux keeps the Slint default. + default-font-family: Theme.ui-font-family; // --- Sidebar data ------------------------------------------------------ - in property connection-state: "未连接"; + in property connection-state: @tr("Not connected"); in property conn-state; // 0 gray / 1 green / 2 yellow - in property resource-title: "本机资源"; // "本机资源" | "服务器资源" + 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; @@ -82,19 +217,219 @@ export component AppWindow inherits Window { 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 app-version; // from Rust: env!("CARGO_PKG_VERSION") 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 + + // Immersive custom title bar (#119). Rust sets `custom-titlebar` true on + // Windows/Linux (frameless); macOS keeps native decorations. The callbacks + // drive the winit window via Rust. + // Default true so the window is frameless from creation (no native-bar flash); + // Rust sets it false on macOS, which keeps native decorations. + in-out property custom-titlebar: true; + in-out property window-maximized: false; + callback win-minimize(); + callback win-maximize-toggle(); + callback win-close(); + callback win-drag(); // start an OS window-move + callback win-resize(int); // start an OS resize; arg = direction code + in-out property sidebar-collapsed: false; // left panel hidden + // User-draggable size of the resource sidebar. width is used when docked + // left/right, height when docked top/bottom. Seeded / persisted via the + // persist-* callbacks (fired on drag end). + in-out property sidebar-width: 220px; + in-out property sidebar-height: 240px; + property sidebar-dragging; // splitter being dragged + property sidebar-drag-start-x; // mouse-x at drag start + property sidebar-drag-start-y; // mouse-y at drag start + callback persist-sidebar-width(float /* logical px */); + + // --- Panel docking (resource sidebar; SFTP follows in a later phase) ---- + // Which window edge the resource panel is docked to: left|right|top|bottom. + in-out property sidebar-dock: "left"; + property sidebar-horizontal: root.sidebar-dock == "left" || root.sidebar-dock == "right"; + // Drag-to-dock state: while a panel's header is dragged, a 4-edge snap + // overlay shows and `dock-target` tracks the edge under the cursor. + property panel-dragging; + property drag-nx; // cursor x within the dock area, 0..1 + property drag-ny; // cursor y within the dock area, 0..1 + property dock-target: + !root.panel-dragging ? "" + : root.drag-nx < 0.22 ? "left" + : root.drag-nx > 0.78 ? "right" + : root.drag-ny < 0.22 ? "top" + : root.drag-ny > 0.78 ? "bottom" + : ""; + callback persist-sidebar-dock(string); + // The top-right toolbar icons float over the tab row; when the sidebar docks + // top/right it covers that corner, so shift the icons to stay over the tabs. + property toolbar-x-off: + (root.sidebar-dock == "right" && !root.sidebar-collapsed) ? root.sidebar-width + 4px : 0px; + property toolbar-y-off: + (root.sidebar-dock == "top" && !root.sidebar-collapsed) ? root.sidebar-height + 4px : 0px; in-out property about-open: false; // centered About dialog + in-out property shortcuts-open: false; // centered keyboard-shortcuts dialog (#103) in property download-dir; // preset SFTP download folder ("" = ask) + // Process monitor is now a detachable top-level window (ProcWindow). Rust + // shows/hides it in response to open-processes(); proc-list/proc-available + // stay here as the live data source shared with that window (#23). + callback open-processes(); + in property proc-available; // a live remote session is active + in property <[ProcRow]> proc-list; // top remote processes by CPU + // Command bar (#55): quick commands + history, fed from Rust. + in property <[QuickCmd]> quick-commands; + in property <[string]> command-history; // oldest first (newest last, #113) + in property <[string]> history-view; // filtered dropdown list (#101) + callback run-command(string /* tab-id */, string /* cmd */, bool /* to-all */); + callback copy-text(string); // copy a history command (#96) + callback delete-history(int /* index */); // remove a history entry (#96) + callback delete-history-cmd(string /* cmd */); // remove a history entry by command (#101) + callback search-history(string /* query */); // filter the history dropdown (#101) + in-out property quick-cmd-manage-open: false; // manage dialog + in-out property qcm-name; // manage form: name field + in-out property qcm-command; // manage form: command field + in-out property qcm-group; // manage form: group field (#55) + in-out property qcm-edit-index: -1; // -1 = add, >=0 = edit that entry (#55) + callback add-quick-command(string /* name */, string /* command */, string /* group */); + callback save-quick-command(int /* index */, string /* name */, string /* command */, string /* group */); + callback delete-quick-command(int /* index */); + callback edit-quick-command(int /* index */); + callback duplicate-quick-command(int /* index */); + callback move-quick-command(int /* index */, string /* group */); + callback toggle-quick-group(string /* group */); // collapse/expand a group (#55) + // Quick-command group management (mirror session groups) (#55). + in-out property qcg-open: false; // group name dialog open + property qcg-name; // group dialog: name field + property qcg-orig; // group dialog: "" = new, else rename target + callback submit-quick-group(string /* orig */, string /* name */); + callback delete-quick-group(string /* name */); in property <[TransferInfo]> transfers; // download/upload records (newest first) 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; + // Keep Slint's std-widgets (LineEdit / Button / ComboBox in the dialogs) in + // step with our theme — otherwise in light mode they keep the platform/dark + // palette and their text/borders blend into the light dialog background. + init => { Palette.color-scheme = root.dark-mode ? ColorScheme.dark : ColorScheme.light; } + changed dark-mode => { Palette.color-scheme = root.dark-mode ? ColorScheme.dark : ColorScheme.light; } + in-out property term-font-family <=> Theme.term-font-family; + in-out property term-font-size <=> Theme.term-font-size; + in-out property ui-scale <=> Theme.ui-scale; // global UI zoom (#100) + in-out property ui-font-family <=> Theme.ui-font-family; + in property <[string]> term-fonts; // installed monospace families + in-out property interface-open: false; // Interface settings overlay + // Interface card position (draggable by its title bar, clamped inside the + // window so it can't leave the parent); init centered. + property ifd-x: (root.width - 660px) / 2; + property ifd-y: (root.height - 430px) / 2; + property ifd-dragging; + property ifd-base-x; + property ifd-base-y; + property ifd-grab-x; + property ifd-grab-y; + callback set-term-font(string); // apply + persist font family + callback set-term-font-size(int); // apply + persist font size (px) + callback set-ui-scale(int); // apply + persist UI scale (percent) (#100) + // SFTP follows the terminal's cd; opt out in Interface settings. + in-out property sftp-follow-cd: true; + callback set-sftp-follow-cd(bool); // apply + persist + // Always ask where to save on each download (#87); default off. + in-out property download-always-ask: false; + callback set-download-always-ask(bool); // apply + persist + // Collapse-by-default settings + shared SFTP collapse state (#78). + in-out property collapse-sidebar-default: false; + callback set-collapse-sidebar-default(bool); // apply + persist + in-out property collapse-sftp-default: false; + callback set-collapse-sftp-default(bool); // apply + persist + in-out property sftp-collapsed: false; // shared across tabs + in-out property sftp-saved-height: 220px; // restore height on un-collapse + // Confirm-before-close when there are live session tabs (#88): a stray + // double-click on the title-bar icon / X shouldn't drop active sessions. + in-out property confirm-close-open: false; + callback confirm-close-yes(); // quit the app for real + // Host-key confirmation (#109-5). Rust opens this when a server presents an + // unknown / changed host key; the user's answer flows back via the callbacks + // and Rust closes the dialog. + in-out property hostkey-prompt-open: false; + in-out property hostkey-changed: false; // true = key differs from stored + in-out property hostkey-title; + in-out property hostkey-message; + in-out property hostkey-detail; // host:port + type + fingerprint + in-out property hostkey-confirm-label; // trust-button text (from Rust, bilingual) + callback hostkey-accept(); // trust (and remember) the key + callback hostkey-reject(); // abort the connection + // Connect-time credential prompt (#110). Rust opens this when a session is + // missing its username and/or password; the answer flows back via accept. + in-out property cred-prompt-open: false; + in-out property cred-host; // host shown in the dialog title + in-out property cred-need-user: false; + in-out property cred-need-password: false; + in-out property cred-user; // username field value + in-out property cred-password; // password field value + in-out property cred-remember: false; // persist to the saved session + callback cred-accept(); // use the entered credentials + callback cred-reject(); // cancel the connection + // Group create / rename dialog (#41). + in-out property group-dialog-open: false; + in-out property group-dialog-orig; // "" = create, else rename + in-out property group-dialog-name; + callback delete-group(string /* name */); + callback submit-group(string /* orig */, string /* name */); + callback toggle-theme(); // flip dark ↔ light + // Session sync / broadcast input (#78 pt.4): mirror keystrokes to every + // online session. Default off (it types into ALL sessions at once). + in-out property sync-input: false; + callback set-sync-input(bool); + // Mirror SFTP uploads to other sessions while sync-input is on (#sync). + in-out property sync-upload-enabled: false; + callback set-sync-upload-enabled(bool); in-out property sftp-panel-height: 220px; // shared; lets Rust locate the drop zone + in-out property sftp-panel-width: 380px; // SFTP extent when docked left/right + in-out property sftp-dock: "bottom"; // SFTP dock edge, shared across tabs 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 cancel-transfer(string); // cancel an in-progress transfer by id (#100) + callback set-language(string); // switch UI language ("zh" / "en") + + // --- Update check (#48) ------------------------------------------------ + // Set by Rust after a background GitHub-releases query; purely informational + // (the app keeps working on the current version). + in-out property update-available: false; + in property update-version; // e.g. "v0.3.3" + callback open-update-url(); // open the release page in a browser + + // --- Built-in file viewer/editor (#70) -------------------------------- + in-out property editor-open: false; + in-out property editor-name; // file name shown in the title + in-out property editor-path; // remote path (used on save) + in-out property editor-content; // text, two-way with the input + in-out property editor-readonly; // view = true, edit = false + in-out property editor-dirty: false; // edited since load + in-out property editor-line-numbers; // newline-joined "1..N" from Rust (#81) + callback save-file(); // Ctrl+S / Save button (edit only) + callback close-editor(); // X / Esc; uploads if dirty + callback editor-recount(string); // rebuild line numbers after an edit + // Editor card geometry: draggable by its title bar, resizable from the + // bottom-right grip (same absolute-position pattern as the Interface + // dialog). Defaults centre the card; user drags/resizes override them. + property ed-w: 760px; + property ed-h: 560px; + property ed-x: (root.width - root.ed-w) / 2; + property ed-y: (root.height - root.ed-h) / 2; + property ed-dragging; + property ed-resizing; + property ed-base-x; + property ed-base-y; + property ed-base-w; + property ed-base-h; + property ed-grab-x; + property ed-grab-y; // --- Session list ------------------------------------------------------ in property <[SessionInfo]> sessions; @@ -111,18 +446,47 @@ export component AppWindow inherits Window { in-out property dialog-name; in-out property dialog-host; in-out property dialog-port: "22"; - in-out property dialog-user: "root"; + in-out property dialog-user; in-out property dialog-auth: "password"; in-out property dialog-password; in-out property dialog-key-path; + in-out property dialog-proxy-type: "none"; // "none" | "socks5" | "http" + in-out property dialog-proxy-hostport; + in-out property dialog-group; + 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"; + + // Delete-confirmation modal state (#28). + in-out property confirm-delete-open: false; + in-out property confirm-delete-batch: false; // delete checked entries (#100) + in-out property confirm-delete-tab; + in-out property confirm-delete-path; // --- Callbacks up to Rust --------------------------------------------- callback new-session-clicked(); + callback import-ssh-config(); // import hosts from ~/.ssh/config + in-out property ssh-import-hint; // result text shown after import + callback export-sessions(); // export all sessions to a file (#46) + callback import-sessions(); // import sessions from a file (#46) callback connect-session(string /* session-id */); callback edit-session(string /* session-id */); + callback duplicate-session(string /* session-id */); + callback move-session(string /* session-id */, string /* group */); + callback toggle-group(string /* group */); callback remove-session(string /* session-id */); callback session-dialog-submit(SessionDraft); callback session-dialog-cancel(); + callback session-dialog-pick-key(); + // Port forwards for the session being edited (#56). + in-out property <[PortFwd]> dialog-forwards; + callback add-forward(string /* name */, string /* kind */, string /* bind */, int /* port */, + string /* host */, int /* host-port */); + callback delete-forward(int /* index */); callback tab-selected(string); callback tab-closed(string); @@ -143,14 +507,40 @@ export component AppWindow inherits Window { // 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 */); + callback sftp-upload-clicked(string /* tab-id */, string /* remote-dir */, bool /* folder */); // 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-toggle-select(string /* tab-id */, int /* row */); // (#100) + callback sftp-download-selected(string /* tab-id */); // (#100) + callback sftp-delete-selected(string /* tab-id */); // (#100) callback sftp-view(string /* tab-id */, string /* path */); callback sftp-edit(string /* tab-id */, string /* path */); + callback sftp-open-external(string /* tab-id */, string /* path */); + callback sftp-edit-external(string /* tab-id */, string /* path */); + // SFTP context-menu extensions (#69): rename / chmod / mkdir / touch share + // one single-input prompt dialog; copy-path goes straight to the clipboard. + in-out property sftp-prompt-open: false; + in-out property sftp-prompt-kind; // "rename"|"chmod"|"mkdir"|"touch" + in-out property sftp-prompt-tab; // tab the action applies to + in-out property sftp-prompt-target; // path acted on (or parent dir) + in-out property sftp-prompt-value; // user input + callback sftp-prompt-submit(string /* tab */, string /* kind */, string /* target */, string /* value */); + callback sftp-copy-path(string); // put the path on the clipboard + + // Visual chmod dialog (#84). Rust decomposes the current mode into nine + // bools when opening and recomposes on apply (Slint has no bitwise ops). + in-out property chmod-open: false; + in-out property chmod-tab; + in-out property chmod-path; + in-out property chmod-name; + in-out property chmod-or; in-out property chmod-ow; in-out property chmod-ox; + in-out property chmod-gr; in-out property chmod-gw; in-out property chmod-gx; + in-out property chmod-tr; in-out property chmod-tw; in-out property chmod-tx; + callback sftp-chmod-open(string /* tab */, string /* path */, string /* name */, int /* mode */); + callback sftp-chmod-apply(); // reads the bools, chmods, closes // --- Clipboard callbacks ---------------------------------------------- // Paste clipboard text into the active terminal. @@ -163,17 +553,91 @@ export component AppWindow inherits Window { 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 */); + callback terminal-scroll-to(string /* tab-id */, int /* offset */); // scrollbar drag (#103) // 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 */); - HorizontalLayout { + VerticalLayout { spacing: 0; - // Left sidebar + // Immersive custom title bar (#119) — only on frameless platforms + // (Windows/Linux). App icon + name on the left, a draggable middle + // (move window / double-click to maximize), then the toolbar icons and + // window controls on the right. macOS keeps its native title bar and + // shows the toolbar icons via the overlay further below. + if root.custom-titlebar : Rectangle { + height: 38px; + background: Theme.bg-panel; + tb-drag := TouchArea { + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { + root.win-drag(); + } + } + double-clicked => { root.win-maximize-toggle(); } + } + HorizontalLayout { + padding-left: 12px; + spacing: 8px; + // Vertical-centering wrappers so the icon and name line up (#119). + VerticalLayout { + alignment: center; + Image { + source: @image-url("../assets/icon.png"); + width: 18px; + height: 18px; + } + } + VerticalLayout { + alignment: center; + Text { + text: "meatshell"; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + } + // Draggable spacer (no TouchArea → clicks fall to tb-drag). + Rectangle { horizontal-stretch: 1; } + // Window controls only — the app toolbar icons live in the tab + // row below, not here (#119). + WinBtn { icon: "\u{E15B}"; clicked => { root.win-minimize(); } } + WinBtn { + icon: root.window-maximized ? "\u{E3E0}" : "\u{E835}"; + clicked => { root.win-maximize-toggle(); } + } + WinBtn { icon: "\u{E5CD}"; danger: true; clicked => { root.win-close(); } } + } + } + + // Dock area — fills below the title bar. The resource sidebar and the + // central content are positioned by absolute geometry computed from + // `sidebar-dock`, so the sidebar can sit on any window edge (#dock). + dock-area := Rectangle { + vertical-stretch: 1; + // Sidebar extent along its docked axis (0 when collapsed); plus a 4px + // splitter unless collapsed. + property sb-w: root.sidebar-collapsed ? 0px : root.sidebar-width; + property sb-h: root.sidebar-collapsed ? 0px : root.sidebar-height; + property sp: root.sidebar-collapsed ? 0px : 4px; + + // Left sidebar — collapsible and drag-resizable, dockable to any edge. + // The animation is disabled while dragging so the splitter tracks the + // cursor 1:1 instead of lagging behind. Sidebar { + x: root.sidebar-dock == "right" ? parent.width - parent.sb-w + : 0px; + y: root.sidebar-dock == "bottom" ? parent.height - parent.sb-h + : 0px; + width: root.sidebar-horizontal ? parent.sb-w : parent.width; + height: root.sidebar-horizontal ? parent.height : parent.sb-h; + clip: true; + // Docked top/bottom → lay the resource widgets out in a row. + lay-horizontal: !root.sidebar-horizontal; + dock-edge: root.sidebar-dock; + animate width, height { duration: root.sidebar-dragging ? 0ms : 150ms; easing: ease-out; } connection-state: root.connection-state; conn-state: root.conn-state; resource-title: root.resource-title; @@ -192,23 +656,110 @@ export component AppWindow inherits Window { net-selected: root.net-selected; net-show-selector: root.net-show-selector; disks: root.disks; + proc-available: root.proc-available; + app-version: root.app-version; select-net-iface(i) => { root.select-net-iface(i); } + show-processes() => { root.open-processes(); } + // Drag the sidebar header to dock it to another edge. Coordinates + // arrive in window space; convert to dock-area-relative fractions. + dock-drag-move(ax, ay) => { + root.panel-dragging = true; + root.drag-nx = (ax - dock-area.absolute-position.x) / dock-area.width; + root.drag-ny = (ay - dock-area.absolute-position.y) / dock-area.height; + } + dock-drag-end() => { + if (root.dock-target != "") { + root.sidebar-dock = root.dock-target; + root.persist-sidebar-dock(root.sidebar-dock); + } + root.panel-dragging = false; + } + toggle-collapse() => { root.sidebar-collapsed = !root.sidebar-collapsed; } } - // Right side: tabs + content - VerticalLayout { + // Splitter — drag to resize the sidebar. Orientation + which dimension it + // adjusts follow the dock edge. Hidden while collapsed. + Rectangle { + visible: !root.sidebar-collapsed; + // Horizontal docks → a vertical 4px bar at the sidebar's inner edge; + // vertical docks → a horizontal 4px bar. + x: root.sidebar-dock == "left" ? parent.sb-w + : root.sidebar-dock == "right" ? parent.width - parent.sb-w - 4px + : 0px; + y: root.sidebar-dock == "top" ? parent.sb-h + : root.sidebar-dock == "bottom" ? parent.height - parent.sb-h - 4px + : 0px; + width: root.sidebar-horizontal ? 4px : parent.width; + height: root.sidebar-horizontal ? parent.height : 4px; + background: sb-split-ta.has-hover || root.sidebar-dragging + ? Theme.accent + : Theme.border-subtle; + sb-split-ta := TouchArea { + mouse-cursor: root.sidebar-horizontal ? ew-resize : ns-resize; + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + root.sidebar-dragging = true; + root.sidebar-drag-start-x = self.mouse-x; + root.sidebar-drag-start-y = self.mouse-y; + } else if (ev.kind == PointerEventKind.up) { + root.sidebar-dragging = false; + root.persist-sidebar-width(root.sidebar-width / 1px); + } + } + moved => { + if (root.sidebar-dragging) { + if (root.sidebar-horizontal) { + // left grows with +dx, right grows with -dx. + root.sidebar-width = clamp( + root.sidebar-width + + (root.sidebar-dock == "right" ? -1 : 1) + * (self.mouse-x - root.sidebar-drag-start-x), + 160px, 520px); + } else { + // top grows with +dy, bottom grows with -dy. + root.sidebar-height = clamp( + root.sidebar-height + + (root.sidebar-dock == "bottom" ? -1 : 1) + * (self.mouse-y - root.sidebar-drag-start-y), + 120px, 480px); + } + } + } + } + } + + // Right side: tabs + content — positioned in the rect left after the + // sidebar is carved off its docked edge. Clipped so the terminal / SFTP + // panel can never bleed (visually or for clicks) onto a panel docked on + // the bottom or right (#dock). + central := Rectangle { + x: root.sidebar-dock == "left" ? parent.sb-w + parent.sp : 0px; + y: root.sidebar-dock == "top" ? parent.sb-h + parent.sp : 0px; + width: root.sidebar-horizontal ? parent.width - parent.sb-w - parent.sp : parent.width; + height: root.sidebar-horizontal ? parent.height : parent.height - parent.sb-h - parent.sp; + clip: true; + VerticalLayout { spacing: 0; - horizontal-stretch: 1; - TabBar { - tabs: root.tabs; - active-id: root.active-tab-id; - tab-selected(id) => { - root.active-tab-id = id; - root.tab-selected(id); + HorizontalLayout { + spacing: 0; + // The sidebar collapse toggle now lives inside the panel header + // (so it follows the panel to any edge); expand-when-collapsed is + // a floating button at the docked edge below (#dock). + TabBar { + horizontal-stretch: 1; + // Reserve room for the top-right toolbar icons that sit over + // this row so tabs don't slide underneath them (#122). + reserve-right: 140px; + tabs: root.tabs; + active-id: root.active-tab-id; + tab-selected(id) => { + root.active-tab-id = id; + root.tab-selected(id); + } + tab-closed(id) => { root.tab-closed(id); } + new-tab() => { root.new-tab-clicked(); } } - tab-closed(id) => { root.tab-closed(id); } - new-tab() => { root.new-tab-clicked(); } } // Content area swaps based on active tab kind. @@ -216,12 +767,31 @@ export component AppWindow inherits Window { vertical-stretch: 1; background: Theme.bg-root; - // Welcome tab + // Welcome tab — fill the content area explicitly (some renderers + // don't auto-fill, which spread the header/card apart) (#dock). if root.active-tab-id == "welcome" : Welcome { + width: parent.width; + height: parent.height; sessions: root.sessions; + import-hint: root.ssh-import-hint; new-session => { root.new-session-clicked(); } + import-ssh-config => { root.import-ssh-config(); } connect-session(id) => { root.connect-session(id); } edit-session(id) => { root.edit-session(id); } + duplicate-session(id) => { root.duplicate-session(id); } + move-session(id, group) => { root.move-session(id, group); } + toggle-group(group) => { root.toggle-group(group); } + new-group => { + root.group-dialog-orig = ""; + root.group-dialog-name = ""; + root.group-dialog-open = true; + } + edit-group(name) => { + root.group-dialog-orig = name; + root.group-dialog-name = name; + root.group-dialog-open = true; + } + delete-group(name) => { root.delete-group(name); } remove-session(id) => { root.remove-session(id); } } @@ -239,95 +809,530 @@ export component AppWindow inherits Window { cursor-row: term.cursor-row; cursor-col: term.cursor-col; rows-used: term.rows-used; + scroll-max: term.scroll-max; + scroll-offset: term.scroll-offset; is-alt-screen: term.is-alt-screen; find-matches: term.find-matches; selection: term.selection; sftp-panel-height <=> root.sftp-panel-height; + sftp-panel-width <=> root.sftp-panel-width; + sftp-dock <=> root.sftp-dock; + sftp-collapsed <=> root.sftp-collapsed; + sftp-saved-height <=> root.sftp-saved-height; 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; + sftp-selected-count: term.sftp-selected-count; + quick-commands: root.quick-commands; + command-history: root.command-history; + history-view: root.history-view; + run-command(cmd, all) => { root.run-command(term.id, cmd, all); } + copy-text(t) => { root.copy-text(t); } + delete-history(i) => { root.delete-history(i); } + delete-history-cmd(c) => { root.delete-history-cmd(c); } + search-history(q) => { root.search-history(q); } + manage-quick-commands() => { + root.qcm-name = ""; + root.qcm-command = ""; + root.qcm-group = ""; + root.qcm-edit-index = -1; + root.quick-cmd-manage-open = true; + } + toggle-quick-group(g) => { root.toggle-quick-group(g); } + edit-quick-command(i) => { root.edit-quick-command(i); } + duplicate-quick-command(i) => { root.duplicate-quick-command(i); } + delete-quick-command(i) => { root.delete-quick-command(i); } + move-quick-command(i, g) => { root.move-quick-command(i, g); } + new-quick-group() => { root.qcg-orig = ""; root.qcg-name = ""; root.qcg-open = true; } + rename-quick-group(g) => { root.qcg-orig = g; root.qcg-name = g; root.qcg-open = true; } + delete-quick-group(g) => { root.delete-quick-group(g); } 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-upload-clicked(dir, folder) => { root.sftp-upload-clicked(term.id, dir, folder); } sftp-refresh(path) => { root.sftp-refresh(term.id, path); } sftp-tree-expand(path) => { root.sftp-tree-expand(term.id, path); } - sftp-delete(path) => { root.sftp-delete(term.id, path); } + sftp-toggle-select(i) => { root.sftp-toggle-select(term.id, i); } + sftp-download-selected() => { root.sftp-download-selected(term.id); } + // Confirm before deleting the checked entries (#100), reusing + // the delete-confirm dialog in batch mode. + sftp-delete-selected() => { + root.confirm-delete-tab = term.id; + root.confirm-delete-batch = true; + root.confirm-delete-path = root.lang-en ? "the selected items" : "选中的项目"; + root.confirm-delete-open = true; + } + // 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-view(path) => { root.sftp-view(term.id, path); } sftp-edit(path) => { root.sftp-edit(term.id, path); } + sftp-open-external(path) => { root.sftp-open-external(term.id, path); } + sftp-edit-external(path) => { root.sftp-edit-external(term.id, path); } + // #69 context-menu extensions → shared prompt dialog. + sftp-rename-request(path, name) => { + root.sftp-prompt-kind = "rename"; + root.sftp-prompt-tab = term.id; + root.sftp-prompt-target = path; + root.sftp-prompt-value = name; + root.sftp-prompt-open = true; + } + sftp-chmod-request(path, name, mode) => { + root.sftp-chmod-open(term.id, path, name, mode); + } + sftp-copy-path(path) => { root.sftp-copy-path(path); } + sftp-new-folder(dir) => { + root.sftp-prompt-kind = "mkdir"; + root.sftp-prompt-tab = term.id; + root.sftp-prompt-target = dir; + root.sftp-prompt-value = ""; + root.sftp-prompt-open = true; + } + sftp-new-file(dir) => { + root.sftp-prompt-kind = "touch"; + root.sftp-prompt-tab = term.id; + root.sftp-prompt-target = dir; + root.sftp-prompt-value = ""; + root.sftp-prompt-open = true; + } 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); } + terminal-scroll-to(o) => { root.terminal-scroll-to(term.id, o); } 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); } } } + } } - } - // --- Top-right buttons: [下载] [设置] (设置 on the far right) ---------- - // 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 => { - root.settings-open = !root.settings-open; - root.download-open = false; + // Expand button — shown only while the panel is collapsed, anchored to + // the edge it's docked on so it's always reachable to bring it back. + if root.sidebar-collapsed : Rectangle { + width: 24px; height: 24px; + x: root.sidebar-dock == "right" ? parent.width - self.width - 2px + : root.sidebar-dock == "top" || root.sidebar-dock == "bottom" ? 6px + : 2px; + // Left/right docks: centre vertically so it doesn't collide with the + // top-right toolbar icons (settings/theme/…); top/bottom: at the edge. + y: root.sidebar-dock == "bottom" ? parent.height - self.height - 2px + : root.sidebar-dock == "top" ? 2px + : (parent.height - self.height) / 2; + border-radius: Theme.radius-sm; + background: exp-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + exp-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.sidebar-collapsed = false; } + } + Text { + text: root.sidebar-dock == "right" ? "\u{E5CB}" // chevron_left + : root.sidebar-dock == "top" ? "\u{E5CF}" // expand_more + : root.sidebar-dock == "bottom" ? "\u{E5CE}" // expand_less + : "\u{E5CC}"; // chevron_right + font-family: "Material Icons"; font-size: 16px; color: Theme.text-secondary; + horizontal-alignment: center; vertical-alignment: center; } } - Text { - 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; + + // Drag-to-dock snap overlay — four edge zones; the one under the cursor + // (root.dock-target) lights up. Purely visual (no TouchArea) so the + // header's drag keeps receiving move events (#dock). + if root.panel-dragging : Rectangle { + x: 0; y: 0; + width: parent.width; + height: parent.height; + Rectangle { // left zone + x: 0; y: 0; width: parent.width * 0.22; height: parent.height; + background: root.dock-target == "left" + ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.dock-target == "left" ? 2px : 0px; + border-color: Theme.accent; + } + Rectangle { // right zone + x: parent.width * 0.78; y: 0; width: parent.width * 0.22; height: parent.height; + background: root.dock-target == "right" + ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.dock-target == "right" ? 2px : 0px; + border-color: Theme.accent; + } + Rectangle { // top zone + x: 0; y: 0; width: parent.width; height: parent.height * 0.22; + background: root.dock-target == "top" + ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.dock-target == "top" ? 2px : 0px; + border-color: Theme.accent; + } + Rectangle { // bottom zone + x: 0; y: parent.height * 0.78; width: parent.width; height: parent.height * 0.22; + background: root.dock-target == "bottom" + ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.dock-target == "bottom" ? 2px : 0px; + border-color: Theme.accent; + } } } - // Download manager — left of settings. - Rectangle { - x: root.width - 64px; + } + + // App toolbar icons (settings/download/theme/sync). They sit at the top-right + // of the tab row — below the custom title bar when frameless (y offset), or + // at the very top on macOS where the native title bar is separate (#119). + IconBtn { + x: root.width - 34px - root.toolbar-x-off; y: (root.custom-titlebar ? 46px : 8px) + root.toolbar-y-off; + icon: "\u{E8B8}"; + tooltip: root.lang-en ? "Settings" : "设置"; + active: root.settings-open; + clicked => { root.settings-open = !root.settings-open; root.download-open = false; root.ssh-import-hint = ""; } + } + IconBtn { + x: root.width - 64px - root.toolbar-x-off; y: (root.custom-titlebar ? 46px : 8px) + root.toolbar-y-off; + icon: "\u{E2C4}"; + tooltip: root.lang-en ? "Download" : "下载"; + active: root.download-open; + clicked => { root.download-open = !root.download-open; root.settings-open = false; } + } + IconBtn { + x: root.width - 94px - root.toolbar-x-off; y: (root.custom-titlebar ? 46px : 8px) + root.toolbar-y-off; + icon: Theme.dark ? "\u{E518}" : "\u{E51C}"; + icon-color: Theme.dark ? #f5c542 : #5856d6; + tooltip: root.lang-en ? "Toggle theme" : "切换主题"; + clicked => { root.toggle-theme(); } + } + IconBtn { + x: root.width - 124px - root.toolbar-x-off; y: (root.custom-titlebar ? 46px : 8px) + root.toolbar-y-off; + icon: "\u{E627}"; + icon-color: root.sync-input ? Theme.accent : Theme.text-secondary; + active: root.sync-input; + tooltip: root.lang-en + ? (root.sync-input ? "Sync input: ON" : "Sync input") + : (root.sync-input ? "会话同步:开" : "会话同步"); + clicked => { root.sync-input = !root.sync-input; root.set-sync-input(root.sync-input); } + } + + // Frameless resize edges (#119): thin hit-zones that start an OS-level + // resize via winit. Hidden when maximized. + if root.custom-titlebar && !root.window-maximized : Rectangle { + width: 100%; + height: 100%; + TouchArea { // top + x: 8px; y: 0; width: parent.width - 16px; height: 5px; + mouse-cursor: ns-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(0); } } + } + TouchArea { // bottom + x: 8px; y: parent.height - 5px; width: parent.width - 16px; height: 5px; + mouse-cursor: ns-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(1); } } + } + TouchArea { // left + x: 0; y: 8px; width: 5px; height: parent.height - 16px; + mouse-cursor: ew-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(3); } } + } + TouchArea { // right + x: parent.width - 5px; y: 8px; width: 5px; height: parent.height - 16px; + mouse-cursor: ew-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(2); } } + } + TouchArea { // top-left + x: 0; y: 0; width: 8px; height: 8px; + mouse-cursor: nwse-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(5); } } + } + TouchArea { // top-right + x: parent.width - 8px; y: 0; width: 8px; height: 8px; + mouse-cursor: nesw-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(4); } } + } + TouchArea { // bottom-left + x: 0; y: parent.height - 8px; width: 8px; height: 8px; + mouse-cursor: nesw-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(7); } } + } + TouchArea { // bottom-right + x: parent.width - 8px; y: parent.height - 8px; width: 8px; height: 8px; + mouse-cursor: nwse-resize; + pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { root.win-resize(6); } } + } + } + + // --- Update-available banner (#48) ------------------------------------ + // A dismissible top-centre banner; the app stays usable on the current + // version. "Download" opens the GitHub release page in the browser. + if root.update-available : Rectangle { + x: (parent.width - 384px) / 2; 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 => { - root.download-open = !root.download-open; - root.settings-open = false; + width: 384px; + height: 38px; + background: Theme.accent; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 14px; + drop-shadow-color: #00000060; + HorizontalLayout { + padding-left: 12px; + padding-right: 6px; + spacing: 8px; + Text { + text: "🎉 " + @tr("Update available:") + " " + root.update-version; + color: #ffffff; + font-size: Theme.fs-sm; + font-family: Theme.ui-font-family; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Rectangle { + width: 68px; height: 26px; + border-radius: Theme.radius-sm; + background: upd-dl-ta.has-hover ? #ffffff45 : #ffffff28; + upd-dl-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.open-update-url(); } + } + Text { text: @tr("Download"); color: #ffffff; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 26px; height: 26px; + border-radius: Theme.radius-sm; + background: upd-cl-ta.has-hover ? #ffffff45 : transparent; + upd-cl-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.update-available = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; color: #ffffff; + font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } } } - Text { - 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; + } + + // --- Built-in file viewer / editor (#70) ------------------------------ + // View = read-only (selectable/copyable); Edit = writable, Ctrl+S or the + // Save button uploads, and closing uploads any unsaved changes. + if root.editor-open : Rectangle { + // Modal backdrop; clicking it does NOT close (avoids losing edits to a + // stray click) — use the close button or Esc. Also swallow wheel events + // so scrolling can't leak through to the terminal underneath (#70). + background: #00000088; + TouchArea { + scroll-event(event) => { accept } + } + edit-fs := FocusScope { + // Ctrl+S saves, Esc closes. These bubble up from the focused + // TextInput because it doesn't consume them. + key-pressed(e) => { + if (e.text == "s" && e.modifiers.control) { + root.save-file(); + return accept; + } + if (e.text == Key.Escape) { + root.close-editor(); + return accept; + } + return reject; + } + Rectangle { + x: root.ed-x; + y: root.ed-y; + width: root.ed-w; + height: root.ed-h; + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 24px; + drop-shadow-color: #00000080; + clip: true; + + VerticalLayout { + // Title bar — drag to move; the buttons stay on top. + Rectangle { + height: 40px; + background: Theme.bg-panel-alt; + ed-title-drag := TouchArea { + mouse-cursor: move; + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + root.ed-dragging = true; + root.ed-base-x = root.ed-x; + root.ed-base-y = root.ed-y; + root.ed-grab-x = self.absolute-position.x + self.mouse-x; + root.ed-grab-y = self.absolute-position.y + self.mouse-y; + } else if (e.kind == PointerEventKind.up) { + root.ed-dragging = false; + } + } + moved => { + if (root.ed-dragging) { + root.ed-x = root.ed-base-x + (self.absolute-position.x + self.mouse-x - root.ed-grab-x); + root.ed-y = root.ed-base-y + (self.absolute-position.y + self.mouse-y - root.ed-grab-y); + } + } + } + HorizontalLayout { + padding-left: 14px; + padding-right: 8px; + // Centre the 28px buttons inside the 40px bar so the + // Save button isn't glued to the top edge (#70). + padding-top: 6px; + padding-bottom: 6px; + spacing: 8px; + Text { + text: root.editor-name + + (root.editor-readonly + ? " · " + @tr("Read-only") + : (root.editor-dirty ? " · " + @tr("Modified") : "")); + color: Theme.text-primary; + font-size: Theme.fs-md; + font-weight: 600; + font-family: Theme.ui-font-family; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + if !root.editor-readonly : Rectangle { + width: 76px; height: 28px; + border-radius: Theme.radius-sm; + background: ed-save-ta.has-hover ? Theme.accent-hover : Theme.accent; + ed-save-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.save-file(); } + } + Text { text: @tr("Save"); color: #ffffff; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 28px; height: 28px; + border-radius: Theme.radius-sm; + background: ed-close-ta.has-hover ? Theme.bg-hover : transparent; + ed-close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.close-editor(); } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-md; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Text area: a line-number gutter on the left + the editor. + // word-wrap is OFF so one logical line == one display line and + // the numbers line up (long lines scroll horizontally); the + // gutter is pinned horizontally but tracks the editor's + // vertical scroll via ed-scroll.viewport-y (#81). + Rectangle { + vertical-stretch: 1; + background: Theme.bg-root; + HorizontalLayout { + spacing: 0; + Rectangle { + width: 48px; + background: Theme.bg-panel-alt; + clip: true; + Text { + x: 0; + y: ed-scroll.viewport-y + 8px; + width: parent.width - 6px; + text: root.editor-line-numbers; + horizontal-alignment: right; + font-family: Theme.ui-font-family; + font-size: Theme.fs-md; + color: Theme.text-muted; + } + } + Rectangle { width: 1px; background: Theme.border-subtle; } + ed-scroll := ScrollView { + viewport-width: max(self.visible-width, edit-input.preferred-width + 16px); + viewport-height: max(self.visible-height, edit-input.preferred-height + 16px); + edit-input := TextInput { + x: 8px; + y: 8px; + width: max(parent.visible-width - 16px, self.preferred-width); + height: self.preferred-height; + text <=> root.editor-content; + read-only: root.editor-readonly; + single-line: false; + wrap: no-wrap; + color: Theme.text-primary; + font-family: Theme.ui-font-family; + font-size: Theme.fs-md; + edited => { root.editor-dirty = true; root.editor-recount(root.editor-content); } + } + } + } + } + } + + // Bottom-right resize grip (#70). + Rectangle { + x: parent.width - 18px; + y: parent.height - 18px; + width: 18px; + height: 18px; + ed-resize-ta := TouchArea { + mouse-cursor: nwse-resize; + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + root.ed-resizing = true; + root.ed-base-w = root.ed-w; + root.ed-base-h = root.ed-h; + root.ed-grab-x = self.absolute-position.x + self.mouse-x; + root.ed-grab-y = self.absolute-position.y + self.mouse-y; + } else if (e.kind == PointerEventKind.up) { + root.ed-resizing = false; + } + } + moved => { + if (root.ed-resizing) { + root.ed-w = max(420px, root.ed-base-w + (self.absolute-position.x + self.mouse-x - root.ed-grab-x)); + root.ed-h = max(280px, root.ed-base-h + (self.absolute-position.y + self.mouse-y - root.ed-grab-y)); + } + } + } + Text { + text: "◢"; + color: ed-resize-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 10px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + // Focus the text the moment the editor opens. + init => { edit-input.focus(); } } } // --- Download manager popup ------------------------------------------- + // Click-outside-to-close: a full-window transparent sensor sitting *under* + // the popup. Clicking anywhere outside the popup hits this and closes it; + // clicks inside the popup are caught by the popup's own TouchArea first. + if root.download-open : TouchArea { + width: parent.width; + height: parent.height; + clicked => { root.download-open = false; } + } // Always present; toggled via `visible` (avoids destroying interactive // elements mid-event, which crashes when created/removed at the Window root). Rectangle { x: parent.width - 364px; - y: 40px; + y: root.custom-titlebar ? 78px : 40px; width: 350px; height: 400px; visible: root.download-open; @@ -348,7 +1353,7 @@ export component AppWindow inherits Window { HorizontalLayout { Text { - text: "下载设置"; + text: @tr("Download settings"); color: Theme.text-primary; font-size: Theme.fs-md; font-weight: 600; @@ -370,7 +1375,7 @@ export component AppWindow inherits Window { } Text { - text: "下载保存到:"; + text: @tr("Save downloads to:"); color: Theme.text-secondary; font-size: Theme.fs-sm; } @@ -384,7 +1389,7 @@ export component AppWindow inherits Window { x: 8px; width: parent.width - 16px; height: parent.height; - text: root.download-dir == "" ? "(每次下载时询问)" : root.download-dir; + 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; @@ -397,14 +1402,14 @@ export component AppWindow inherits Window { alignment: end; Rectangle { - width: 96px; height: 28px; + width: 110px; height: 28px; border-radius: Theme.radius-sm; background: pick-ta.has-hover ? Theme.accent-hover : Theme.accent; pick-ta := TouchArea { mouse-cursor: pointer; clicked => { root.pick-download-dir(); } } - Text { text: "选择文件夹"; color: #ffffff; font-size: Theme.fs-sm; + Text { text: @tr("Choose save path"); color: #ffffff; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { @@ -415,7 +1420,7 @@ export component AppWindow inherits Window { mouse-cursor: pointer; clicked => { root.open-download-dir(); } } - Text { text: "打开目录"; color: Theme.text-primary; font-size: Theme.fs-sm; + Text { text: @tr("Open folder"); color: Theme.text-primary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } } } @@ -425,7 +1430,7 @@ export component AppWindow inherits Window { // Transfer records (download/upload progress + history). HorizontalLayout { Text { - text: "传输记录"; + text: @tr("Transfers"); color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-stretch: 1; @@ -439,7 +1444,7 @@ export component AppWindow inherits Window { mouse-cursor: pointer; clicked => { root.clear-transfers(); } } - Text { text: "清空"; color: Theme.text-muted; font-size: Theme.fs-xs; + Text { text: @tr("Clear"); color: Theme.text-muted; font-size: Theme.fs-xs; horizontal-alignment: center; vertical-alignment: center; } } } @@ -453,7 +1458,7 @@ export component AppWindow inherits Window { spacing: 4px; if root.transfers.length == 0 : Text { - text: "暂无传输记录"; + text: @tr("No transfers yet"); color: Theme.text-muted; font-size: Theme.fs-xs; } @@ -484,6 +1489,25 @@ export component AppWindow inherits Window { font-size: Theme.fs-xs; vertical-alignment: center; } + // Cancel button — only while the transfer is + // active (0) or preparing (3) (#100). + if t.state == 0 || t.state == 3 : Rectangle { + width: 16px; height: 16px; + border-radius: Theme.radius-sm; + background: cancel-ta.has-hover ? Theme.danger.with-alpha(0.18) : transparent; + cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.cancel-transfer(t.id); } + } + Text { + text: "\u{E5CD}"; // close + font-family: "Material Icons"; + color: cancel-ta.has-hover ? Theme.danger : Theme.text-muted; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } } // progress bar Rectangle { @@ -506,12 +1530,21 @@ export component AppWindow inherits Window { } } + // Process monitor is now ProcWindow (a detachable top-level window); the + // former in-app overlay was removed in favour of a real OS window (#23). + // --- Settings menu popup (top-right) ---------------------------------- + // Click-outside-to-close backdrop (same pattern as the download popup). + if root.settings-open : TouchArea { + width: parent.width; + height: parent.height; + clicked => { root.settings-open = false; } + } Rectangle { - x: parent.width - 158px; - y: 40px; - width: 144px; - height: 44px; + x: parent.width - 196px; + y: root.custom-titlebar ? 78px : 40px; + width: 198px; + height: 220px; visible: root.settings-open; background: Theme.bg-panel; border-radius: Theme.radius-md; @@ -524,87 +1557,939 @@ export component AppWindow inherits Window { VerticalLayout { padding: 4px; + spacing: 1px; + // Interface (font) settings. Rectangle { height: 28px; border-radius: Theme.radius-sm; - background: about-ta.has-hover ? Theme.bg-hover : transparent; - about-ta := TouchArea { + background: iface-ta.has-hover ? Theme.bg-hover : transparent; + iface-ta := TouchArea { mouse-cursor: pointer; clicked => { root.settings-open = false; - root.about-open = true; + root.interface-open = true; } } HorizontalLayout { padding-left: 8px; spacing: 8px; - Text { text: "\u{E88E}"; font-family: "Material Icons"; // Info + Text { text: "\u{E429}"; font-family: "Material Icons"; // tune color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } - Text { text: "关于"; color: Theme.text-primary; font-size: Theme.fs-md; + Text { text: root.lang-en ? "Interface" : "界面"; + color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } - } - } - - // --- 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: "关于 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; - horizontal-alignment: center; vertical-alignment: center; } + // Export all connections to a portable file (#46). + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: export-ta.has-hover ? Theme.bg-hover : transparent; + export-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.settings-open = false; + root.export-sessions(); } } - - Text { - text: "一个由「一坨肉」开发的轻量 Rust + Slint SSH 终端客户端。"; - color: Theme.text-secondary; - font-size: Theme.fs-sm; - wrap: word-wrap; - } + HorizontalLayout { + padding-left: 8px; spacing: 8px; + Text { text: "\u{E5D8}"; font-family: "Material Icons"; // ↑ export + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: root.lang-en ? "Export connections" : "导出连接"; + color: Theme.text-primary; font-size: Theme.fs-md; + vertical-alignment: center; horizontal-stretch: 1; } + } + } + // Import connections from a portable file (#46). + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: impconn-ta.has-hover ? Theme.bg-hover : transparent; + impconn-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.settings-open = false; + root.import-sessions(); + } + } + HorizontalLayout { + padding-left: 8px; spacing: 8px; + Text { text: "\u{E5DB}"; font-family: "Material Icons"; // ↓ import + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: root.lang-en ? "Import connections" : "导入连接"; + color: Theme.text-primary; font-size: Theme.fs-md; + vertical-alignment: center; horizontal-stretch: 1; } + } + } + // Import hosts from ~/.ssh/config. Keep the menu open so the result + // (e.g. "imported 3" / "no new hosts") is visible right here — it used + // to only show on the welcome page, so importing while a session was + // open looked like nothing happened (#133). + Rectangle { + height: root.ssh-import-hint != "" ? 46px : 28px; + border-radius: Theme.radius-sm; + background: import-ta.has-hover ? Theme.bg-hover : transparent; + import-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.import-ssh-config(); } + } + HorizontalLayout { + padding-left: 8px; padding-right: 8px; spacing: 8px; + Text { text: "\u{E890}"; font-family: "Material Icons"; // input/import + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + VerticalLayout { + horizontal-stretch: 1; + alignment: center; + Text { text: @tr("Import ~/.ssh/config"); color: Theme.text-primary; font-size: Theme.fs-md; + vertical-alignment: center; } + if root.ssh-import-hint != "" : Text { + text: root.ssh-import-hint; + color: Theme.text-muted; font-size: Theme.fs-xs; + overflow: elide; + } + } + } + } + // 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 => { + root.settings-open = false; + root.set-language(root.lang-en ? "zh" : "en"); + } + } + HorizontalLayout { + padding-left: 8px; spacing: 8px; + Text { text: "\u{E894}"; font-family: "Material Icons"; // language/translate + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: root.lang-en ? "切换到中文" : "Switch to English"; + color: Theme.text-primary; font-size: Theme.fs-md; + vertical-alignment: center; horizontal-stretch: 1; } + } + } + // Keyboard shortcuts reference (#103). + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: keys-ta.has-hover ? Theme.bg-hover : transparent; + keys-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.settings-open = false; + root.shortcuts-open = true; + } + } + HorizontalLayout { + padding-left: 8px; spacing: 8px; + Text { text: "\u{E312}"; font-family: "Material Icons"; // keyboard + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: root.lang-en ? "Shortcuts" : "快捷键"; + color: Theme.text-primary; font-size: Theme.fs-md; + 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 => { + root.settings-open = false; + root.about-open = true; + } + } + HorizontalLayout { + padding-left: 8px; spacing: 8px; + Text { text: "\u{E88E}"; font-family: "Material Icons"; // Info + color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: @tr("About"); color: Theme.text-primary; font-size: Theme.fs-md; + vertical-alignment: center; horizontal-stretch: 1; } + } + } + } + } + + // --- Group create / rename dialog (#41) ------------------------------- + if root.group-dialog-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000080; + TouchArea { clicked => { root.group-dialog-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 340px; + height: 178px; + 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; + TouchArea {} // swallow inside clicks + + VerticalLayout { + padding: 18px; + spacing: 12px; + Text { + text: root.group-dialog-orig == "" + ? (root.lang-en ? "New group" : "新建分组") + : (root.lang-en ? "Edit group" : "编辑分组"); + color: Theme.text-primary; + font-size: Theme.fs-lg; + font-weight: 700; + } + Text { + text: @tr("Group name"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + ge-input := LineEdit { + text <=> root.group-dialog-name; + font-family: Theme.ui-font-family; // CJK-capable (#54) + placeholder-text: @tr("Group name"); + // Enter confirms when non-empty. + accepted => { + if (root.group-dialog-name != "") { + root.submit-group(root.group-dialog-orig, root.group-dialog-name); + root.group-dialog-open = false; + } + } + } + Rectangle { vertical-stretch: 1; } + HorizontalLayout { + alignment: end; + spacing: 8px; + Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { root.group-dialog-open = false; } + } + Button { + text: root.lang-en ? "OK" : "确定"; + primary: true; + enabled: root.group-dialog-name != ""; + clicked => { + root.submit-group(root.group-dialog-orig, root.group-dialog-name); + root.group-dialog-open = false; + } + } + } + } + } + } + + // --- Quick-command manage dialog (#55) -------------------------------- + if root.quick-cmd-manage-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000080; + TouchArea { clicked => { root.quick-cmd-manage-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 460px; + height: 420px; + 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; + TouchArea {} // swallow inside clicks + + VerticalLayout { + padding: 18px; + spacing: 10px; + HorizontalLayout { + Text { + text: @tr("Quick commands"); + 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: qcm-close.has-hover ? Theme.bg-hover : transparent; + qcm-close := TouchArea { + mouse-cursor: pointer; + clicked => { root.quick-cmd-manage-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; color: Theme.text-secondary; + font-size: Theme.fs-md; horizontal-alignment: center; vertical-alignment: center; } + } + } + + // Add / edit form: name + group (optional) + command. + Text { text: @tr("Name"); color: Theme.text-secondary; font-size: Theme.fs-sm; } + qcm-name-in := LineEdit { + text <=> root.qcm-name; + font-family: Theme.ui-font-family; + placeholder-text: @tr("e.g. tail log"); + } + Text { text: @tr("Group (optional)"); color: Theme.text-secondary; font-size: Theme.fs-sm; } + qcm-group-in := LineEdit { + text <=> root.qcm-group; + font-family: Theme.ui-font-family; + placeholder-text: @tr("leave empty → default"); + } + Text { text: @tr("Command"); color: Theme.text-secondary; font-size: Theme.fs-sm; } + HorizontalLayout { + spacing: 8px; + qcm-cmd-in := LineEdit { + horizontal-stretch: 1; + text <=> root.qcm-command; + font-family: Theme.ui-font-family; + placeholder-text: "tail -f /var/log/syslog"; + accepted => { + if (root.qcm-name != "" && root.qcm-command != "") { + if (root.qcm-edit-index >= 0) { + root.save-quick-command(root.qcm-edit-index, root.qcm-name, root.qcm-command, root.qcm-group); + } else { + root.add-quick-command(root.qcm-name, root.qcm-command, root.qcm-group); + } + root.qcm-edit-index = -1; + root.qcm-name = ""; root.qcm-command = ""; root.qcm-group = ""; + } + } + } + // Cancel only while editing an existing entry. + if root.qcm-edit-index >= 0 : Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { + root.qcm-edit-index = -1; + root.qcm-name = ""; root.qcm-command = ""; root.qcm-group = ""; + } + } + Button { + text: root.qcm-edit-index >= 0 ? (root.lang-en ? "Save" : "保存") + : (root.lang-en ? "Add" : "添加"); + primary: true; + enabled: root.qcm-name != "" && root.qcm-command != ""; + clicked => { + if (root.qcm-edit-index >= 0) { + root.save-quick-command(root.qcm-edit-index, root.qcm-name, root.qcm-command, root.qcm-group); + } else { + root.add-quick-command(root.qcm-name, root.qcm-command, root.qcm-group); + } + root.qcm-edit-index = -1; + root.qcm-name = ""; root.qcm-command = ""; root.qcm-group = ""; + } + } + } + + // New (empty) group. + HorizontalLayout { + Rectangle { horizontal-stretch: 1; } + Button { + text: root.lang-en ? "+ New group" : "+ 新建分组"; + clicked => { root.qcg-orig = ""; root.qcg-name = ""; root.qcg-open = true; } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Existing commands list with delete buttons. + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, qcm-list.preferred-height); + qcm-list := VerticalLayout { + alignment: start; + spacing: 2px; + if root.quick-commands.length == 0 : Text { + text: @tr("No quick commands yet"); + color: Theme.text-muted; font-size: Theme.fs-xs; + } + for q in root.quick-commands : VerticalLayout { + // Group header on the first row of each group (#55). + // Right-click to rename / delete (empty) / new group. + if q.group-header != "" : Rectangle { + height: 26px; + property gmx; + property gmy; + HorizontalLayout { + padding-left: 4px; padding-top: 6px; spacing: 4px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; + color: Theme.text-muted; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: q.group; color: Theme.text-muted; font-size: Theme.fs-xs; + font-weight: 700; vertical-alignment: center; + horizontal-stretch: 1; horizontal-alignment: left; } + } + TouchArea { + clicked => { } // grab so right-click pointer-events register inside the Flickable + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + gmx = self.mouse-x; gmy = self.mouse-y; qgrp-menu.show(); + } + } + } + qgrp-menu := PopupWindow { + x: gmx; y: gmy; width: 150px; + height: (q.orig-index == -1 ? 3 : 2) * 30px + 9px; + 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: 1px; + Rectangle { + height: 30px; border-radius: Theme.radius-sm; + background: qeg-ta.has-hover ? Theme.bg-hover : transparent; + qeg-ta := TouchArea { mouse-cursor: pointer; + clicked => { root.qcg-orig = q.group; root.qcg-name = q.group; root.qcg-open = true; } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: root.lang-en ? "Rename group" : "重命名分组"; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } + } + if q.orig-index == -1 : Rectangle { + height: 30px; border-radius: Theme.radius-sm; + background: qdg-ta.has-hover ? #cc333320 : transparent; + qdg-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-quick-group(q.group); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: root.lang-en ? "Delete group" : "删除分组"; color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } + } + Rectangle { + height: 30px; border-radius: Theme.radius-sm; + background: qng-ta.has-hover ? Theme.bg-hover : transparent; + qng-ta := TouchArea { mouse-cursor: pointer; clicked => { root.qcg-orig = ""; root.qcg-name = ""; root.qcg-open = true; } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2CC}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: root.lang-en ? "New group" : "新建分组"; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } + } + } + } + } + } + + // Command row (skip the empty-group placeholder). + if q.orig-index >= 0 : Rectangle { + height: 38px; + border-radius: Theme.radius-sm; + background: Theme.bg-root; + property ctx-x; + property ctx-y; + item-ta := TouchArea { + clicked => { } // grab so right-click pointer-events register inside the Flickable + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + ctx-x = self.mouse-x; ctx-y = self.mouse-y; qctx-menu.show(); + } + } + } + HorizontalLayout { + padding-left: 8px; padding-right: 6px; spacing: 6px; + VerticalLayout { + horizontal-stretch: 1; + alignment: center; + Text { text: q.name; color: Theme.text-primary; font-size: Theme.fs-sm; + overflow: elide; } + Text { text: q.command; color: Theme.text-muted; font-size: Theme.fs-xs; + overflow: elide; font-family: Theme.ui-font-family; } + } + Rectangle { + width: 28px; + y: (parent.height - self.height) / 2; + height: 28px; + border-radius: Theme.radius-sm; + background: del-ta.has-hover ? Theme.danger.with-alpha(0.18) : transparent; + del-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-quick-command(q.orig-index); } + } + Text { text: "\u{E872}"; font-family: "Material Icons"; + color: Theme.danger; font-size: Theme.fs-md; + horizontal-alignment: center; vertical-alignment: center; } + } + } + qctx-menu := PopupWindow { + x: ctx-x; y: ctx-y; width: 160px; + height: qctx-vl.preferred-height; + 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; + qctx-vl := VerticalLayout { + padding: 4px; spacing: 1px; + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: qe-ta.has-hover ? Theme.bg-hover : transparent; + qe-ta := TouchArea { mouse-cursor: pointer; clicked => { root.edit-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Edit"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: qd-ta.has-hover ? Theme.bg-hover : transparent; + qd-ta := TouchArea { mouse-cursor: pointer; clicked => { root.duplicate-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E14D}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Duplicate"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: qx-ta.has-hover ? #cc333320 : transparent; + qx-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 1px; background: Theme.border-subtle; } + HorizontalLayout { padding-left: 10px; padding-top: 2px; spacing: 6px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; } + Text { text: @tr("Move to"); color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; horizontal-stretch: 1; } } + if q.group != "default" : Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: qmd-ta.has-hover ? Theme.bg-hover : transparent; + qmd-ta := TouchArea { mouse-cursor: pointer; clicked => { root.move-quick-command(q.orig-index, "default"); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: "default"; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + for s in root.quick-commands : Rectangle { + visible: s.group-header != "" && s.group != "default" && s.group != q.group; + height: self.visible ? 28px : 0px; + border-radius: Theme.radius-sm; + background: qm-ta.has-hover ? Theme.bg-hover : transparent; + qm-ta := TouchArea { mouse-cursor: pointer; clicked => { root.move-quick-command(q.orig-index, s.group); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: s.group; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } } } + } + } + } + } + } + } + } + } + } + } + + // --- Quick-command group name dialog (#55): new / rename -------------- + if root.qcg-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000080; + TouchArea { clicked => { root.qcg-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 340px; + height: 178px; + 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; + TouchArea {} // swallow inside clicks + + VerticalLayout { + padding: 18px; + spacing: 12px; + Text { + text: root.qcg-orig == "" + ? (root.lang-en ? "New group" : "新建分组") + : (root.lang-en ? "Rename group" : "重命名分组"); + color: Theme.text-primary; + font-size: Theme.fs-lg; + font-weight: 700; + } + Text { text: @tr("Group name"); color: Theme.text-secondary; font-size: Theme.fs-sm; } + qcg-input := LineEdit { + text <=> root.qcg-name; + font-family: Theme.ui-font-family; + placeholder-text: @tr("Group name"); + accepted => { + if (root.qcg-name != "") { + root.submit-quick-group(root.qcg-orig, root.qcg-name); + root.qcg-open = false; + } + } + } + Rectangle { vertical-stretch: 1; } + HorizontalLayout { + alignment: end; + spacing: 8px; + Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { root.qcg-open = false; } + } + Button { + text: root.lang-en ? "OK" : "确定"; + primary: true; + enabled: root.qcg-name != ""; + clicked => { + root.submit-quick-group(root.qcg-orig, root.qcg-name); + root.qcg-open = false; + } + } + } + } + } + } + + // --- SFTP prompt dialog (#69): rename / chmod / mkdir / touch ---------- + if root.sftp-prompt-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000080; + TouchArea { clicked => { root.sftp-prompt-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 340px; + height: 178px; + 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; + TouchArea {} // swallow inside clicks + + VerticalLayout { + padding: 18px; + spacing: 12px; + Text { + text: root.sftp-prompt-kind == "rename" + ? @tr("Rename") + : (root.sftp-prompt-kind == "chmod" + ? @tr("Permissions") + : (root.sftp-prompt-kind == "mkdir" + ? @tr("New folder") : @tr("New file"))); + color: Theme.text-primary; + font-size: Theme.fs-lg; + font-weight: 700; + } + Text { + text: root.sftp-prompt-kind == "chmod" + ? @tr("Octal mode, e.g. 755") + : (root.sftp-prompt-kind == "rename" + ? @tr("New name") : @tr("Name")); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + sp-input := LineEdit { + text <=> root.sftp-prompt-value; + font-family: Theme.ui-font-family; // CJK-capable (#54) + placeholder-text: root.sftp-prompt-kind == "chmod" ? "755" : ""; + accepted => { + if (root.sftp-prompt-value != "") { + root.sftp-prompt-submit( + root.sftp-prompt-tab, root.sftp-prompt-kind, + root.sftp-prompt-target, root.sftp-prompt-value); + root.sftp-prompt-open = false; + } + } + } + Rectangle { vertical-stretch: 1; } + HorizontalLayout { + alignment: end; + spacing: 8px; + Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { root.sftp-prompt-open = false; } + } + Button { + text: root.lang-en ? "OK" : "确定"; + primary: true; + enabled: root.sftp-prompt-value != ""; + clicked => { + root.sftp-prompt-submit( + root.sftp-prompt-tab, root.sftp-prompt-kind, + root.sftp-prompt-target, root.sftp-prompt-value); + root.sftp-prompt-open = false; + } + } + } + } + } + } + + // --- Confirm-before-close dialog (#88) -------------------------------- + if root.confirm-close-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000088; + TouchArea { clicked => { root.confirm-close-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 340px; + height: 168px; + 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; + TouchArea {} + + VerticalLayout { + padding: 18px; + spacing: 12px; + Text { + text: root.lang-en ? "Close meatshell?" : "确定关闭 meatshell?"; + color: Theme.text-primary; font-size: Theme.fs-lg; font-weight: 700; + } + Text { + text: root.lang-en + ? "There are active sessions. Closing will disconnect them." + : "当前有活动会话,关闭将断开它们。"; + color: Theme.text-secondary; font-size: Theme.fs-sm; wrap: word-wrap; + } + Rectangle { vertical-stretch: 1; } + HorizontalLayout { + alignment: end; + spacing: 8px; + Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { root.confirm-close-open = false; } + } + Button { + text: root.lang-en ? "Close" : "关闭"; + primary: true; + clicked => { root.confirm-close-yes(); } + } + } + } + } + } + + // --- Visual chmod dialog (#84): Owner/Group/Other × Read/Write/Execute -- + if root.chmod-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000080; + TouchArea { clicked => { root.chmod-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 320px; + height: 270px; + 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; + TouchArea {} + + VerticalLayout { + padding: 18px; + spacing: 10px; + Text { + text: root.lang-en ? "Permissions" : "修改文件权限"; + color: Theme.text-primary; font-size: Theme.fs-lg; font-weight: 700; + } + Text { + text: root.chmod-name; + color: Theme.text-secondary; font-size: Theme.fs-sm; + font-family: Theme.ui-font-family; overflow: elide; + } + + for grp in [ + { lbl: root.lang-en ? "Owner" : "所有者", g: 0 }, + { lbl: root.lang-en ? "Group" : "组", g: 1 }, + { lbl: root.lang-en ? "Other" : "其他", g: 2 }, + ] : VerticalLayout { + spacing: 2px; + Text { text: grp.lbl; color: Theme.text-secondary; font-size: Theme.fs-xs; } + HorizontalLayout { + spacing: 14px; + CheckBox { + text: root.lang-en ? "Read" : "读取"; + checked: grp.g == 0 ? root.chmod-or : (grp.g == 1 ? root.chmod-gr : root.chmod-tr); + toggled => { + if (grp.g == 0) { root.chmod-or = self.checked; } + else if (grp.g == 1) { root.chmod-gr = self.checked; } + else { root.chmod-tr = self.checked; } + } + } + CheckBox { + text: root.lang-en ? "Write" : "写入"; + checked: grp.g == 0 ? root.chmod-ow : (grp.g == 1 ? root.chmod-gw : root.chmod-tw); + toggled => { + if (grp.g == 0) { root.chmod-ow = self.checked; } + else if (grp.g == 1) { root.chmod-gw = self.checked; } + else { root.chmod-tw = self.checked; } + } + } + CheckBox { + text: root.lang-en ? "Execute" : "执行"; + checked: grp.g == 0 ? root.chmod-ox : (grp.g == 1 ? root.chmod-gx : root.chmod-tx); + toggled => { + if (grp.g == 0) { root.chmod-ox = self.checked; } + else if (grp.g == 1) { root.chmod-gx = self.checked; } + else { root.chmod-tx = self.checked; } + } + } + } + } + + Rectangle { vertical-stretch: 1; } + HorizontalLayout { + alignment: end; + spacing: 8px; + Button { + text: root.lang-en ? "Cancel" : "取消"; + clicked => { root.chmod-open = false; } + } + Button { + text: root.lang-en ? "OK" : "确定"; + primary: true; + clicked => { root.sftp-chmod-apply(); root.chmod-open = false; } + } + } + } + } + } + + + // --- Keyboard shortcuts dialog (#103) --------------------------------- + Rectangle { + width: parent.width; + height: parent.height; + visible: root.shortcuts-open; + background: #00000080; + TouchArea { clicked => { root.shortcuts-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 424px; + height: 432px; + 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; + TouchArea {} + + VerticalLayout { + padding: 18px; + spacing: 5px; + + HorizontalLayout { + Text { + text: root.lang-en ? "Keyboard shortcuts" : "快捷键"; + 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: sc-close.has-hover ? Theme.bg-hover : transparent; + sc-close := TouchArea { + mouse-cursor: pointer; + clicked => { root.shortcuts-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + Text { text: root.lang-en ? "Terminal" : "终端"; + color: Theme.text-muted; font-size: Theme.fs-xs; } + ShortcutRow { keys: "Ctrl+Shift+C"; desc: root.lang-en ? "Copy selection" : "复制选中内容"; } + ShortcutRow { keys: "Ctrl+V"; desc: root.lang-en ? "Paste" : "粘贴"; } + ShortcutRow { keys: root.lang-en ? "Drag" : "鼠标拖动"; desc: root.lang-en ? "Select text" : "选择文本"; } + ShortcutRow { keys: root.lang-en ? "Wheel / Scrollbar" : "滚轮 / 滚动条"; desc: root.lang-en ? "Scroll history" : "滚动历史"; } + + Rectangle { height: 6px; } + Text { text: root.lang-en ? "Command box" : "命令输入框"; + color: Theme.text-muted; font-size: Theme.fs-xs; } + ShortcutRow { keys: "Enter"; desc: root.lang-en ? "Send command" : "发送命令"; } + ShortcutRow { keys: "↑ / ↓"; desc: root.lang-en ? "Previous / next in history" : "上一条 / 下一条历史"; } + ShortcutRow { keys: "Ctrl+A"; desc: root.lang-en ? "Move to start of line" : "移到行首"; } + ShortcutRow { keys: "Ctrl+E"; desc: root.lang-en ? "Move to end of line" : "移到行尾"; } + ShortcutRow { keys: "Ctrl+K"; desc: root.lang-en ? "Delete to end of line" : "删除到行尾"; } + ShortcutRow { keys: "Ctrl+U"; desc: root.lang-en ? "Delete to start of line" : "删除到行首"; } + + Rectangle { vertical-stretch: 1; } + } + } + } + + // --- 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; + 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: "开源项目 · 协议 MIT / Apache-2.0"; + text: @tr("Open source · MIT / Apache-2.0"); color: Theme.text-muted; font-size: Theme.fs-xs; } @@ -612,7 +2497,7 @@ export component AppWindow inherits Window { Rectangle { height: 1px; background: Theme.border-subtle; } Text { - text: "使用的开源库"; + text: @tr("Open-source libraries used"); color: Theme.text-secondary; font-size: Theme.fs-sm; } @@ -651,7 +2536,239 @@ export component AppWindow inherits Window { draft-auth <=> root.dialog-auth; draft-password <=> root.dialog-password; draft-key-path <=> root.dialog-key-path; + draft-proxy-type <=> root.dialog-proxy-type; + draft-proxy-hostport <=> root.dialog-proxy-hostport; + draft-group <=> root.dialog-group; + 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; + forwards: root.dialog-forwards; submit(draft) => { root.session-dialog-submit(draft); } cancel => { root.session-dialog-cancel(); } + pick-key-file => { root.session-dialog-pick-key(); } + add-forward(n, k, b, p, h, hp) => { root.add-forward(n, k, b, p, h, hp); } + delete-forward(i) => { root.delete-forward(i); } + } + + // --- Delete confirmation (#28) ---------------------------------------- + ConfirmDialog { + width: parent.width; + height: parent.height; + is-open <=> root.confirm-delete-open; + title: @tr("Confirm delete"); + message: root.confirm-delete-batch + ? (root.lang-en ? "Delete the checked items? This cannot be undone." + : "删除选中的多个项目?此操作不可撤销。") + : @tr("Delete this item? This cannot be undone."); + detail: root.confirm-delete-path; + confirm-label: @tr("Delete"); + confirm => { + if (root.confirm-delete-batch) { + root.sftp-delete-selected(root.confirm-delete-tab); + root.confirm-delete-batch = false; + } else { + root.sftp-delete(root.confirm-delete-tab, root.confirm-delete-path); + } + root.confirm-delete-open = false; + } + cancel => { root.confirm-delete-batch = false; root.confirm-delete-open = false; } + } + + // Host-key confirmation (#109-5). Reuses the confirm-dialog card; the + // backdrop / Cancel both reject (safe default = don't connect). + ConfirmDialog { + width: parent.width; + height: parent.height; + is-open <=> root.hostkey-prompt-open; + title: root.hostkey-title; + message: root.hostkey-message; + detail: root.hostkey-detail; + confirm-label: root.hostkey-confirm-label; + confirm => { root.hostkey-accept(); } + cancel => { root.hostkey-reject(); } + } + + // Connect-time credential prompt (#110). Backdrop / Cancel abort the connect. + if root.cred-prompt-open : Rectangle { + width: parent.width; + height: parent.height; + background: #000000aa; + TouchArea { clicked => { root.cred-reject(); } } + Rectangle { + width: 360px; + height: cred-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; + TouchArea {} // swallow clicks inside the card + cred-card := VerticalLayout { + padding: 20px; + spacing: 12px; + Text { + text: @tr("Login"); + color: Theme.text-primary; + font-size: Theme.fs-xl; + font-weight: 600; + } + Text { + text: root.cred-host; + color: Theme.text-secondary; + font-family: Theme.font-mono; + font-size: Theme.fs-sm; + } + if root.cred-need-user : LabeledInput { + label: @tr("Username"); + placeholder: "root"; + value <=> root.cred-user; + } + if root.cred-need-password : LabeledInput { + label: @tr("Password"); + placeholder: "••••••••"; + password: true; + value <=> root.cred-password; + } + CheckBox { + text: @tr("Remember for this session"); + checked <=> root.cred-remember; + } + HorizontalLayout { + alignment: end; + spacing: 8px; + GhostButton { + text: @tr("Cancel"); + clicked => { root.cred-reject(); } + } + PrimaryButton { + text: @tr("Connect"); + clicked => { root.cred-accept(); } + } + } + } + } + } + + // --- Interface settings (modal overlay) ------------------------------- + // Embedded in the main window (not a detached window), so it can't be + // dragged outside the parent. The dim backdrop + a focus-grabbing scope make + // it modal: no mouse or keyboard input reaches the app while it's open. The + // card itself is draggable by its title bar but clamped inside the window. + if root.interface-open : Rectangle { + width: 100%; + height: 100%; + background: #00000080; // dim backdrop + // Grab focus and swallow keys so nothing leaks to the terminal beneath. + FocusScope { + init => { self.focus(); } + key-pressed(e) => { accept } + key-released(e) => { accept } + } + // Modal: clicking the backdrop does nothing (only the ✕ closes it). + TouchArea { scroll-event(e) => { accept } } + + Rectangle { + x: root.ifd-x; + y: root.ifd-y; + width: 660px; + height: 430px; + 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; + clip: true; + TouchArea {} // swallow inside clicks so they don't hit the backdrop + + VerticalLayout { + // Title bar — drag to move (clamped inside the window). + Rectangle { + height: 44px; + background: Theme.bg-elevated; + title-drag := TouchArea { + mouse-cursor: move; + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + root.ifd-dragging = true; + root.ifd-base-x = root.ifd-x; + root.ifd-base-y = root.ifd-y; + root.ifd-grab-x = self.absolute-position.x + self.mouse-x; + root.ifd-grab-y = self.absolute-position.y + self.mouse-y; + } else if (e.kind == PointerEventKind.up) { + root.ifd-dragging = false; + } + } + moved => { + if (root.ifd-dragging) { + root.ifd-x = clamp( + root.ifd-base-x + (self.absolute-position.x + self.mouse-x - root.ifd-grab-x), + 0px, max(0px, root.width - 660px)); + root.ifd-y = clamp( + root.ifd-base-y + (self.absolute-position.y + self.mouse-y - root.ifd-grab-y), + 0px, max(0px, root.height - 430px)); + } + } + } + HorizontalLayout { + padding-left: 16px; + padding-right: 10px; + Text { + text: root.lang-en ? "Settings" : "设置"; + color: Theme.text-primary; + font-size: 14px; + font-weight: 700; + horizontal-stretch: 1; + vertical-alignment: center; + } + VerticalLayout { + alignment: center; + Rectangle { + width: 24px; height: 24px; + border-radius: Theme.radius-sm; + background: if-close-ta.has-hover ? Theme.bg-hover : transparent; + if-close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.interface-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: 15px; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + InterfacePanel { + vertical-stretch: 1; + lang-en: root.lang-en; + term-fonts: root.term-fonts; + term-font-family: root.term-font-family; + term-font-size: root.term-font-size; + ui-scale: root.ui-scale; + sftp-follow-cd <=> root.sftp-follow-cd; + collapse-sidebar-default <=> root.collapse-sidebar-default; + collapse-sftp-default <=> root.collapse-sftp-default; + sync-upload-enabled <=> root.sync-upload-enabled; + download-always-ask <=> root.download-always-ask; + set-term-font(v) => { root.set-term-font(v); } + set-term-font-size(v) => { root.set-term-font-size(v); } + set-ui-scale(v) => { root.set-ui-scale(v); } + set-sftp-follow-cd(v) => { root.set-sftp-follow-cd(v); } + set-collapse-sidebar-default(v) => { root.set-collapse-sidebar-default(v); } + set-collapse-sftp-default(v) => { root.set-collapse-sftp-default(v); } + set-sync-upload-enabled(v) => { root.set-sync-upload-enabled(v); } + set-download-always-ask(v) => { root.set-download-always-ask(v); } + } + } + } } } diff --git a/ui/confirm_dialog.slint b/ui/confirm_dialog.slint new file mode 100644 index 00000000..f78fa418 --- /dev/null +++ b/ui/confirm_dialog.slint @@ -0,0 +1,119 @@ +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 { + padding-left: 14px; + padding-right: 14px; + alignment: center; + Text { + text: root.text; + color: white; + font-size: Theme.fs-md; + font-weight: 600; + 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 { + 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/MeatshellMono-Bold.ttf similarity index 99% rename from ui/fonts/CascadiaMono-Bold.ttf rename to ui/fonts/MeatshellMono-Bold.ttf index 7d89b982..b0b5ac9b 100644 Binary files a/ui/fonts/CascadiaMono-Bold.ttf and b/ui/fonts/MeatshellMono-Bold.ttf differ diff --git a/ui/fonts/CascadiaMono-Regular.ttf b/ui/fonts/MeatshellMono-Regular.ttf similarity index 99% rename from ui/fonts/CascadiaMono-Regular.ttf rename to ui/fonts/MeatshellMono-Regular.ttf index 18408e41..57bfb143 100644 Binary files a/ui/fonts/CascadiaMono-Regular.ttf and b/ui/fonts/MeatshellMono-Regular.ttf differ diff --git a/ui/interface_panel.slint b/ui/interface_panel.slint new file mode 100644 index 00000000..2b77851d --- /dev/null +++ b/ui/interface_panel.slint @@ -0,0 +1,363 @@ +// Interface settings content (#23 follow-up). +// +// Embedded panel (not a window): it lives inside AppWindow's modal overlay, so +// the settings can't be dragged out of the parent and block input while open — +// while keeping the clean, minimal "label-left · control-right" design. +// Typography is fixed (literal px in the chrome) so it stays compact regardless +// of the global UI scale; only the font-preview reflects the real terminal size. + +import { Theme } from "theme.slint"; +import { ComboBox } from "std-widgets.slint"; + +// One left-nav row (icon + label). +component NavItem inherits Rectangle { + in property icon; + in property label; + in property active; + callback clicked(); + height: 30px; + border-radius: 6px; + background: root.active ? Theme.bg-active : (ta.has-hover ? Theme.bg-hover : transparent); + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + HorizontalLayout { + padding-left: 10px; + spacing: 9px; + Text { text: root.icon; font-family: "Material Icons"; + color: root.active ? Theme.text-primary : Theme.text-secondary; + font-size: 15px; vertical-alignment: center; } + Text { text: root.label; + color: root.active ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; vertical-alignment: center; horizontal-stretch: 1; } + } +} + +// Small muted group heading (字体 / 预览 / SFTP …). +component SectionHeader inherits Text { + font-size: 11px; + font-weight: 700; + color: Theme.text-muted; + vertical-alignment: center; +} + +// A settings row: label (+ optional description) on the left, a control slot on +// the right via @children. Right-aligned and vertically centered. +component SettingRow inherits Rectangle { + in property label; + in property desc; + in property show-divider: true; + VerticalLayout { + HorizontalLayout { + spacing: 14px; + padding-top: 9px; + padding-bottom: 9px; + VerticalLayout { + horizontal-stretch: 1; + alignment: center; + spacing: 3px; + Text { text: root.label; font-size: 13px; color: Theme.text-primary; } + if root.desc != "" : Text { text: root.desc; font-size: 11px; + color: Theme.text-muted; wrap: word-wrap; } + } + VerticalLayout { + alignment: center; + @children + } + } + if root.show-divider : Rectangle { height: 1px; background: Theme.border-subtle; } + } +} + +// iOS-style toggle switch. Display-only `on` (driven by the parent); a tap emits +// the intended new value so the parent owns the state (avoids Slint's +// binding-break-on-self-assign gotcha). +component Switch inherits Rectangle { + in property on; + callback toggled(bool); + width: 38px; + height: 22px; + border-radius: 11px; + background: root.on ? Theme.accent : Theme.bg-active; + animate background { duration: 120ms; } + Rectangle { + width: 16px; + height: 16px; + border-radius: 8px; + y: 3px; + x: root.on ? parent.width - self.width - 3px : 3px; + background: white; + animate x { duration: 120ms; easing: ease-out; } + } + TouchArea { + mouse-cursor: pointer; + clicked => { root.toggled(!root.on); } + } +} + +// [- value +] stepper. Display-only `value`; emits the clamped new value. +component Stepper inherits Rectangle { + in property value; + in property minimum: 0; + in property maximum: 100; + in property step: 1; + in property unit; + callback changed(int); + width: 112px; + height: 26px; + border-radius: 6px; + background: Theme.bg-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + clip: true; + HorizontalLayout { + Rectangle { + width: 30px; + background: minus-ta.has-hover ? Theme.bg-hover : transparent; + minus-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.changed(max(root.minimum, root.value - root.step)); } + } + Text { text: "\u{E15B}"; font-family: "Material Icons"; font-size: 14px; + color: Theme.text-secondary; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { width: 1px; background: Theme.border-subtle; } + Text { text: root.value + root.unit; horizontal-stretch: 1; + font-size: 12px; color: Theme.text-primary; + horizontal-alignment: center; vertical-alignment: center; } + Rectangle { width: 1px; background: Theme.border-subtle; } + Rectangle { + width: 30px; + background: plus-ta.has-hover ? Theme.bg-hover : transparent; + plus-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.changed(min(root.maximum, root.value + root.step)); } + } + Text { text: "\u{E145}"; font-family: "Material Icons"; font-size: 14px; + color: Theme.text-secondary; horizontal-alignment: center; vertical-alignment: center; } + } + } +} + +// The settings content (nav + pages). Embedded in AppWindow's modal overlay. +export component InterfacePanel inherits Rectangle { + in property lang-en; + in property <[string]> term-fonts; + in property term-font-family; + in property term-font-size; + in property ui-scale; + in-out property sftp-follow-cd; + in-out property collapse-sidebar-default; + in-out property collapse-sftp-default; + in-out property sync-upload-enabled; + in-out property download-always-ask; + + property ifd-page: "font"; + + callback set-term-font(string); + callback set-term-font-size(int); + callback set-ui-scale(int); + callback set-sftp-follow-cd(bool); + callback set-collapse-sidebar-default(bool); + callback set-collapse-sftp-default(bool); + callback set-sync-upload-enabled(bool); + callback set-download-always-ask(bool); + + HorizontalLayout { + // --- Left nav ------------------------------------------------------ + Rectangle { + width: 168px; + background: Theme.bg-panel-alt; + VerticalLayout { + padding: 10px; + spacing: 2px; + alignment: start; + NavItem { + icon: "\u{E167}"; label: root.lang-en ? "Font" : "字体"; + active: root.ifd-page == "font"; + clicked => { root.ifd-page = "font"; } + } + NavItem { + icon: "\u{E2C7}"; label: "SFTP"; + active: root.ifd-page == "sftp"; + clicked => { root.ifd-page = "sftp"; } + } + NavItem { + icon: "\u{E5CB}"; label: root.lang-en ? "Sidebars" : "侧栏"; + active: root.ifd-page == "sidebar"; + clicked => { root.ifd-page = "sidebar"; } + } + NavItem { + icon: "\u{E627}"; label: root.lang-en ? "Session sync" : "会话同步"; + active: root.ifd-page == "sync"; + clicked => { root.ifd-page = "sync"; } + } + NavItem { + icon: "\u{E2C4}"; label: root.lang-en ? "Download" : "下载"; + active: root.ifd-page == "download"; + clicked => { root.ifd-page = "download"; } + } + } + } + // --- Right content ------------------------------------------------- + Rectangle { + horizontal-stretch: 1; + background: Theme.bg-panel; + + // --- Font page --------------------------------------------- + if root.ifd-page == "font" : VerticalLayout { + padding-left: 20px; + padding-right: 20px; + padding-top: 14px; + spacing: 0; + alignment: start; + + SectionHeader { text: root.lang-en ? "FONT" : "字体"; height: 24px; } + SettingRow { + label: root.lang-en ? "Terminal font" : "终端字体"; + ComboBox { + width: 220px; + model: root.term-fonts; + current-value: root.term-font-family; + selected(v) => { root.set-term-font(v); } + } + } + SettingRow { + label: root.lang-en ? "Terminal font size" : "终端字体大小"; + Stepper { + value: root.term-font-size / 1px; minimum: 8; maximum: 32; step: 1; unit: "px"; + changed(v) => { root.set-term-font-size(v); } + } + } + SettingRow { + label: root.lang-en ? "UI scale" : "界面缩放"; + desc: root.lang-en ? "Zooms the whole interface (not this panel)." + : "缩放整个界面(不影响本面板)。"; + show-divider: false; + Stepper { + value: round(root.ui-scale * 100); minimum: 80; maximum: 200; step: 10; unit: "%"; + changed(v) => { root.set-ui-scale(v); } + } + } + + Rectangle { height: 14px; } + SectionHeader { text: root.lang-en ? "PREVIEW" : "预览"; height: 22px; } + Rectangle { + height: 52px; + background: Theme.term-bg; + border-radius: 6px; + border-width: 1px; + border-color: Theme.border-subtle; + Text { + x: 12px; + text: "meatshell ~$ echo preview 0123"; + font-family: Theme.term-font-family; + font-size: Theme.term-font-size; + color: Theme.term-fg; + vertical-alignment: center; + height: parent.height; + } + } + } + + // --- SFTP page --------------------------------------------- + if root.ifd-page == "sftp" : VerticalLayout { + padding-left: 20px; + padding-right: 20px; + padding-top: 14px; + spacing: 0; + alignment: start; + SectionHeader { text: "SFTP"; height: 24px; } + SettingRow { + label: root.lang-en ? "SFTP follows terminal cd" : "SFTP 跟随 cd 更新"; + desc: root.lang-en + ? "A cd in the terminal switches the SFTP panel to the same directory." + : "终端里执行 cd 时,SFTP 面板自动切到相同目录。"; + show-divider: false; + Switch { + on: root.sftp-follow-cd; + toggled(v) => { root.sftp-follow-cd = v; root.set-sftp-follow-cd(v); } + } + } + } + + // --- Sidebars page ----------------------------------------- + if root.ifd-page == "sidebar" : VerticalLayout { + padding-left: 20px; + padding-right: 20px; + padding-top: 14px; + spacing: 0; + alignment: start; + SectionHeader { text: root.lang-en ? "SIDEBARS" : "侧栏"; height: 24px; } + SettingRow { + label: root.lang-en ? "Collapse the resource panel by default" : "默认收起资源视图面板"; + Switch { + on: root.collapse-sidebar-default; + toggled(v) => { root.collapse-sidebar-default = v; root.set-collapse-sidebar-default(v); } + } + } + SettingRow { + label: root.lang-en ? "Collapse the SFTP panel by default" : "默认收起 SFTP 侧栏"; + desc: root.lang-en + ? "Takes effect on next launch; handy on low-spec jump hosts." + : "下次启动生效;低配跳板机上可减少常驻面板开销。"; + show-divider: false; + Switch { + on: root.collapse-sftp-default; + toggled(v) => { root.collapse-sftp-default = v; root.set-collapse-sftp-default(v); } + } + } + } + + // --- Session sync page ------------------------------------- + if root.ifd-page == "sync" : VerticalLayout { + padding-left: 20px; + padding-right: 20px; + padding-top: 14px; + spacing: 0; + alignment: start; + SectionHeader { text: root.lang-en ? "SESSION SYNC" : "会话同步"; height: 24px; } + SettingRow { + label: root.lang-en ? "Sync file uploads" : "文件上传同步"; + desc: root.lang-en + ? "While session sync is on, an upload goes to the same path on each session; if missing, to that session's current SFTP directory." + : "会话同步开启时,上传会发往各会话的相同路径;路径不存在则发往该会话当前 SFTP 目录。"; + show-divider: false; + Switch { + on: root.sync-upload-enabled; + toggled(v) => { root.sync-upload-enabled = v; root.set-sync-upload-enabled(v); } + } + } + Rectangle { height: 6px; } + Text { + text: root.lang-en + ? "Session sync itself is toggled from the sync icon in the top-right bar." + : "会话同步本身从右上角的同步图标开关。"; + color: Theme.text-muted; font-size: 11px; wrap: word-wrap; + } + } + + // --- Download page ----------------------------------------- + if root.ifd-page == "download" : VerticalLayout { + padding-left: 20px; + padding-right: 20px; + padding-top: 14px; + spacing: 0; + alignment: start; + SectionHeader { text: root.lang-en ? "DOWNLOAD" : "下载"; height: 24px; } + SettingRow { + label: root.lang-en ? "Always ask where to save" : "总是询问保存去何处"; + desc: root.lang-en + ? "Every download prompts for a location instead of using the preset folder." + : "每次下载都询问保存位置,而不是直接存到预设下载目录。"; + show-divider: false; + Switch { + on: root.download-always-ask; + toggled(v) => { root.download-always-ask = v; root.set-download-always-ask(v); } + } + } + } + } + } +} diff --git a/ui/proc_window.slint b/ui/proc_window.slint new file mode 100644 index 00000000..305cc06e --- /dev/null +++ b/ui/proc_window.slint @@ -0,0 +1,197 @@ +// Detachable process-monitor window (#23 follow-up). +// +// Originally an overlay rectangle painted inside AppWindow, the process monitor +// is now a *real* top-level OS window so it can be dragged anywhere — outside +// the main window, or onto a second monitor. It mirrors the frameless custom +// titlebar look of AppWindow (drag to move, ✕ to close) and reads its rows from +// a model shared with the main window, so the table stays live. + +import { Theme } from "theme.slint"; + +// One row in the process table. Lives here (not app.slint) so both the main +// window and this window can import it without a circular dependency. +export struct ProcRow { + pid: string, + user: string, + cpu: string, // "12.3" (percent, one decimal) + mem: string, // "4.5" + command: string, // truncated args + cpu-frac: float, // 0.0..1.0, drives the row load bar +} + +export component ProcWindow inherits Window { + title: "meatshell — " + @tr("Processes"); + icon: @image-url("../assets/icon.png"); + // Theme is a *per-window* global, so a detached window keeps the compile-time + // defaults (dark) unless Rust mirrors the main window's theme/scale/font here. + // These two-way bindings give Rust one knob per setting (see sync in app.rs). + in-out property dark-mode <=> Theme.dark; + in-out property ui-scale <=> Theme.ui-scale; + in-out property ui-font-family <=> Theme.ui-font-family; + // CJK-capable default font so "进程"/host never render as tofu (the embedded + // mono has no CJK glyphs); mirrors AppWindow. + default-font-family: Theme.ui-font-family; + // Frameless + self-drawn titlebar on Windows/Linux (Rust sets this true), + // native frame elsewhere — mirrors AppWindow's approach (#119). + in property custom-titlebar; + no-frame: root.custom-titlebar; + in property <[ProcRow]> proc-list; // top remote processes by CPU + in property host; // "user@host" shown in the titlebar + + callback close(); + callback win-drag(); // start an OS window-move (frameless titlebar) + callback win-resize-se(); // start an OS resize from the bottom-right grip + + min-width: 420px; + min-height: 280px; + preferred-width: 640px; + preferred-height: 520px; + background: Theme.bg-panel; + + VerticalLayout { + spacing: 0; + + // --- Custom titlebar (frameless platforms) ------------------------- + if root.custom-titlebar : Rectangle { + height: 36px; + background: Theme.bg-elevated; + tb-drag := TouchArea { + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { + root.win-drag(); + } + } + } + HorizontalLayout { + padding-left: 12px; + spacing: 8px; + VerticalLayout { + alignment: center; + Image { + source: @image-url("../assets/icon.png"); + width: 16px; + height: 16px; + } + } + VerticalLayout { + alignment: center; + Text { + text: @tr("Processes") + (root.host != "" ? " · " + root.host : ""); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + } + Rectangle { horizontal-stretch: 1; } // draggable spacer + // Close button. + Rectangle { + width: 46px; + background: close-ta.has-hover ? #e81123 : transparent; + animate background { duration: 90ms; } + close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.close(); } + } + Text { + text: "\u{E5CD}"; + font-family: "Material Icons"; + color: close-ta.has-hover ? white : Theme.text-secondary; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + + // --- Table ---------------------------------------------------------- + VerticalLayout { + padding: 12px; + spacing: 8px; + + // Column header. + HorizontalLayout { + padding-left: 6px; + padding-right: 6px; + spacing: 6px; + Text { text: "PID"; width: 56px; color: Theme.text-muted; font-size: Theme.fs-xs; } + Text { text: @tr("User"); width: 84px; color: Theme.text-muted; font-size: Theme.fs-xs; } + Text { text: "CPU%"; width: 52px; color: Theme.text-muted; font-size: Theme.fs-xs; + horizontal-alignment: right; } + Text { text: "MEM%"; width: 52px; color: Theme.text-muted; font-size: Theme.fs-xs; + horizontal-alignment: right; } + Text { text: @tr("Command"); horizontal-stretch: 1; color: Theme.text-muted; font-size: Theme.fs-xs; } + } + + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, proc-col.preferred-height); + proc-col := VerticalLayout { + alignment: start; + spacing: 1px; + + if root.proc-list.length == 0 : Text { + text: @tr("No processes"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + + for p in root.proc-list : Rectangle { + height: 20px; + border-radius: 2px; + clip: true; + + // CPU load bar behind the row. + Rectangle { + x: 0; y: 0; + height: parent.height; + width: parent.width * clamp(p.cpu-frac, 0.0, 1.0); + background: p.cpu-frac > 0.5 ? Theme.warning.with-alpha(0.28) + : Theme.accent.with-alpha(0.20); + } + HorizontalLayout { + padding-left: 6px; + padding-right: 6px; + spacing: 6px; + Text { text: p.pid; width: 56px; color: Theme.text-secondary; + font-size: Theme.fs-xs; vertical-alignment: center; } + Text { text: p.user; width: 84px; color: Theme.text-secondary; + font-size: Theme.fs-xs; vertical-alignment: center; overflow: elide; } + Text { text: p.cpu; width: 52px; color: Theme.text-primary; + font-size: Theme.fs-xs; vertical-alignment: center; + horizontal-alignment: right; } + Text { text: p.mem; width: 52px; color: Theme.text-secondary; + font-size: Theme.fs-xs; vertical-alignment: center; + horizontal-alignment: right; } + Text { text: p.command; horizontal-stretch: 1; color: Theme.text-muted; + font-size: Theme.fs-xs; vertical-alignment: center; overflow: elide; } + } + } + } + } + } + } + + // --- Bottom-right resize grip (frameless platforms) -------------------- + if root.custom-titlebar : Rectangle { + width: 16px; + height: 16px; + x: root.width - self.width; + y: root.height - self.height; + TouchArea { + mouse-cursor: nwse-resize; + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { + root.win-resize-se(); + } + } + } + // Classic dotted resize grip (a diagonal of 2px dots, bottom-right). + Rectangle { x: parent.width - 5px; y: parent.height - 5px; width: 2px; height: 2px; + background: Theme.text-muted; } + Rectangle { x: parent.width - 9px; y: parent.height - 5px; width: 2px; height: 2px; + background: Theme.text-muted; } + Rectangle { x: parent.width - 5px; y: parent.height - 9px; width: 2px; height: 2px; + background: Theme.text-muted; } + } +} diff --git a/ui/session_dialog.slint b/ui/session_dialog.slint index c4cf6b14..d8f78d43 100644 --- a/ui/session_dialog.slint +++ b/ui/session_dialog.slint @@ -1,18 +1,91 @@ import { Theme } from "theme.slint"; import { LabeledInput, PrimaryButton, GhostButton } from "widgets.slint"; +// One SSH port-forward row shown in the session dialog (#56). `summary` is a +// ready-to-display line like "-L 127.0.0.1:8080 → host:80". +export struct PortFwd { + kind: string, + name: string, + summary: string, +} + export struct SessionDraft { id: string, name: 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" + group: string, // folder/group name ("" = ungrouped) + // 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" } -// Modal dialog for creating / editing an SSH session. +// 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 { + 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(); } + } +} + +// Compact single-line input (no label) used in the port-forward add form (#56). +component MiniInput inherits Rectangle { + in property placeholder; + in-out property value; + height: 30px; + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: mi.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + mi := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + color: Theme.text-primary; + font-family: Theme.ui-font-family; + font-size: Theme.fs-md; + vertical-alignment: center; + single-line: true; + text <=> root.value; + } + if root.value == "" : Text { + x: 8px; + height: parent.height; + text: root.placeholder; + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } +} + +// 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 { @@ -20,19 +93,49 @@ export component SessionDialog inherits Rectangle { in-out property is-editing: false; in-out property draft-id; in-out property draft-name; + 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-user; + // Set when Create/Save is pressed with an empty host (SSH/Telnet) (#110). + in-out property host-missing: false; in-out property draft-auth: "password"; in-out property draft-password; in-out property draft-key-path; + in-out property draft-proxy-type: "none"; // "none" | "socks5" | "http" + in-out property draft-proxy-hostport; + in-out property draft-group; + 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"; + + // Port forwards / tunnels (#56). `forwards` is the current list (fed from + // Rust); the nf-* properties hold the add-form fields. + in property <[PortFwd]> forwards; + property nf-kind: "local"; // "local" | "remote" | "dynamic" + property nf-name; // optional rule label (#100) + property nf-bind: "127.0.0.1"; + property nf-port; + property nf-host; + property nf-host-port; + property show-advanced: false; // collapse proxy + forwarding (#56) callback submit(SessionDraft); callback cancel(); + callback add-forward(string /* name */, string /* kind */, string /* bind */, int /* port */, + string /* host */, int /* host-port */); + callback delete-forward(int /* index */); + callback pick-key-file(); // open a native file picker for the private key visible: is-open; background: #000000aa; + // Clear the host-required flag each time the dialog opens (#110). + changed is-open => { if (self.is-open) { self.host-missing = false; } } + // Swallow clicks on the backdrop TouchArea { width: 100%; height: 100%; @@ -40,8 +143,11 @@ export component SessionDialog inherits Rectangle { } Rectangle { - width: 420px; - height: 460px; + width: 440px; + // Height follows the content, but never exceeds the window — past that + // the body scrolls (the dialog grows with the advanced section / many + // port forwards) (#56). + height: min(body.preferred-height, parent.height - 40px); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.bg-panel; @@ -56,134 +162,435 @@ export component SessionDialog inherits Rectangle { width: 100%; height: 100%; } - VerticalLayout { - padding: 20px; - spacing: 14px; + Flickable { + width: 100%; + height: 100%; + viewport-width: self.width; + viewport-height: body.preferred-height; + body := VerticalLayout { + padding: 20px; + spacing: 12px; Text { - text: is-editing ? "编辑会话" : "新建会话"; + text: is-editing ? @tr("Edit session") : @tr("New session"); color: Theme.text-primary; font-size: Theme.fs-xl; font-weight: 600; } + // Connection type selector (always shown). + VerticalLayout { + spacing: 4px; + Text { + text: @tr("Connection type"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + 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: "名称"; - placeholder: "例如:生产环境 web-01"; + label: @tr("Name"); + placeholder: @tr("e.g. production web-01"); value <=> root.draft-name; } - HorizontalLayout { + // Optional group/folder to organize sessions by business (#41). + LabeledInput { + label: @tr("Group (optional)"); + placeholder: @tr("e.g. Production / Database"); + value <=> root.draft-group; + } + + // ── SSH / Telnet share host + port ────────────────────────────── + if root.draft-kind != "serial" : HorizontalLayout { spacing: 10px; LabeledInput { - label: "主机 / IP"; + label: @tr("Host / IP"); placeholder: "192.168.1.10"; value <=> root.draft-host; horizontal-stretch: 2; } LabeledInput { - label: "端口"; - placeholder: "22"; + label: @tr("Port"); + placeholder: root.draft-kind == "telnet" ? "23" : "22"; value <=> root.draft-port; horizontal-stretch: 1; } } - LabeledInput { - label: "用户名"; - placeholder: "root"; + // Inline error when a host-less SSH/Telnet session is submitted (#110). + if root.host-missing && root.draft-host == "" && root.draft-kind != "serial" : Text { + text: @tr("Please enter a host / IP address."); + color: Theme.danger; + font-size: Theme.fs-sm; + } + + // ── SSH-only: user + auth ─────────────────────────────────────── + if root.draft-kind == "ssh" : LabeledInput { + label: @tr("Username"); + placeholder: @tr("Prompted on connect if blank"); value <=> root.draft-user; } - // Auth method toggle - VerticalLayout { + if root.draft-kind == "ssh" : VerticalLayout { spacing: 4px; Text { - text: "认证方式"; + text: @tr("Authentication"); color: Theme.text-secondary; font-size: Theme.fs-sm; } HorizontalLayout { 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 { + spacing: 8px; Rectangle { - height: 30px; + height: 32px; horizontal-stretch: 1; border-radius: Theme.radius-sm; border-width: 1px; - background: root.draft-auth == "password" ? Theme.accent : Theme.bg-root; - border-color: root.draft-auth == "password" ? Theme.accent : Theme.border-subtle; - Text { - text: "密码"; - color: root.draft-auth == "password" ? white : Theme.text-primary; + 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-family: Theme.ui-font-family; // CJK-capable (#54) font-size: Theme.fs-md; - horizontal-alignment: center; vertical-alignment: center; + single-line: true; } - TouchArea { - mouse-cursor: pointer; - clicked => { root.draft-auth = "password"; } + 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 { - height: 30px; - horizontal-stretch: 1; + width: 64px; height: 32px; border-radius: Theme.radius-sm; border-width: 1px; - background: root.draft-auth == "key" ? Theme.accent : Theme.bg-root; - border-color: root.draft-auth == "key" ? Theme.accent : Theme.border-subtle; + border-color: Theme.border-subtle; + background: browse-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; Text { - text: "私钥"; - color: root.draft-auth == "key" ? white : Theme.text-primary; - font-size: Theme.fs-md; + text: @tr("Browse…"); + color: Theme.text-primary; + font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } - TouchArea { + browse-ta := TouchArea { mouse-cursor: pointer; - clicked => { root.draft-auth = "key"; } + 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; + } } - if root.draft-auth == "password" : LabeledInput { - label: "密码"; - placeholder: "••••••••"; + // Passphrase for an *encrypted* private key (#90). Reuses the + // password field; blank means the key isn't encrypted (or, when + // editing, keep the saved passphrase). + if root.draft-kind == "ssh" && root.draft-auth == "key" : LabeledInput { + label: @tr("Key passphrase"); + placeholder: root.is-editing + ? @tr("Leave blank to keep the current passphrase") + : @tr("Leave blank if the key isn't encrypted"); password: true; value <=> root.draft-password; } - if root.draft-auth == "key" : LabeledInput { - label: "私钥路径"; - placeholder: "C:\\Users\\you\\.ssh\\id_ed25519"; - value <=> root.draft-key-path; + // ── Serial-only fields ────────────────────────────────────────── + if root.draft-kind == "serial" : LabeledInput { + label: @tr("Serial port"); + placeholder: "COM3 / /dev/ttyUSB0"; + value <=> root.draft-serial-port; } - Rectangle { vertical-stretch: 1; } + 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 { + 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 { + 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"; } } + } + } + + // ── Advanced (collapsed by default): proxy + port forwarding ──── + if root.draft-kind != "serial" : Rectangle { + height: 26px; + adv-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.show-advanced = !root.show-advanced; } + } + HorizontalLayout { + spacing: 4px; + Text { + text: @tr("Advanced (proxy, tunnels)"); + color: adv-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + text: root.show-advanced ? "\u{E5CE}" : "\u{E5CF}"; + font-family: "Material Icons"; + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } + } + } + + // ── Optional outbound proxy (SSH / Telnet only) ───────────────── + if root.show-advanced && root.draft-kind != "serial" : VerticalLayout { + spacing: 4px; + Text { + text: @tr("Proxy (optional)"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + spacing: 6px; + SegOption { label: @tr("None"); active: root.draft-proxy-type == "none"; clicked => { root.draft-proxy-type = "none"; } } + SegOption { label: "SOCKS5"; active: root.draft-proxy-type == "socks5"; clicked => { root.draft-proxy-type = "socks5"; } } + SegOption { label: "HTTP"; active: root.draft-proxy-type == "http"; clicked => { root.draft-proxy-type = "http"; } } + } + if root.draft-proxy-type != "none" : LabeledInput { + label: @tr("Proxy address"); + placeholder: "127.0.0.1:1080"; + value <=> root.draft-proxy-hostport; + } + Text { + text: root.draft-proxy-type == "none" + ? @tr("No proxy — use $ALL_PROXY or connect directly") + : @tr("Format: host:port (with auth: user:pass@host:port)"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } + + // ── Port forwarding / tunnels (SSH only) (#56) ────────────────── + if root.show-advanced && root.draft-kind == "ssh" : VerticalLayout { + spacing: 6px; + Text { + text: @tr("Port forwarding"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + // Existing forwards, each with a delete button. + for f[i] in root.forwards : Rectangle { + height: 30px; + border-radius: Theme.radius-sm; + background: Theme.bg-root; + HorizontalLayout { + padding-left: 8px; padding-right: 4px; spacing: 6px; + if f.name != "" : Text { + text: f.name; + color: Theme.accent; + font-size: Theme.fs-sm; + font-weight: 600; + font-family: Theme.ui-font-family; + vertical-alignment: center; + overflow: elide; + } + Text { + text: f.summary; + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-family: Theme.ui-font-family; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Rectangle { + width: 24px; + y: (parent.height - self.height) / 2; + height: 24px; + border-radius: Theme.radius-sm; + background: fdel.has-hover ? Theme.danger.with-alpha(0.18) : transparent; + fdel := TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-forward(i); } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.danger; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + + // Add form: kind selector, then bind/port (+ target for L/R). + HorizontalLayout { + spacing: 6px; + SegOption { label: @tr("Local (-L)"); active: root.nf-kind == "local"; clicked => { root.nf-kind = "local"; } } + SegOption { label: @tr("Remote (-R)"); active: root.nf-kind == "remote"; clicked => { root.nf-kind = "remote"; } } + SegOption { label: @tr("Dynamic (-D)"); active: root.nf-kind == "dynamic"; clicked => { root.nf-kind = "dynamic"; } } + } + MiniInput { placeholder: @tr("Name (optional)"); value <=> root.nf-name; } + HorizontalLayout { + spacing: 6px; + MiniInput { placeholder: "127.0.0.1"; value <=> root.nf-bind; } + MiniInput { placeholder: @tr("Listen port"); value <=> root.nf-port; } + } + if root.nf-kind != "dynamic" : HorizontalLayout { + spacing: 6px; + MiniInput { placeholder: @tr("Target host"); value <=> root.nf-host; } + MiniInput { placeholder: @tr("Target port"); value <=> root.nf-host-port; } + } + HorizontalLayout { + alignment: end; + PrimaryButton { + text: @tr("Add"); + clicked => { + root.add-forward(root.nf-name, root.nf-kind, root.nf-bind, root.nf-port.to-float(), + root.nf-host, root.nf-host-port.to-float()); + root.nf-name = ""; + root.nf-port = ""; + root.nf-host = ""; + root.nf-host-port = ""; + } + } + } + Text { + text: root.nf-kind == "local" + ? @tr("-L: local port → target reached from the server") + : (root.nf-kind == "remote" + ? @tr("-R: server port → target reached from here") + : @tr("-D: local SOCKS5 proxy")); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } HorizontalLayout { alignment: end; spacing: 8px; GhostButton { - text: "取消"; + text: @tr("Cancel"); clicked => { root.cancel(); } } PrimaryButton { - text: is-editing ? "保存" : "创建"; + text: is-editing ? @tr("Save") : @tr("Create"); clicked => { + // SSH/Telnet need a host; block submit and flag it (#110). + if (root.draft-kind != "serial" && root.draft-host == "") { + root.host-missing = true; + return; + } + root.host-missing = false; root.submit({ id: root.draft-id, name: root.draft-name, + 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-type == "none" || root.draft-proxy-hostport == "") ? "" + : root.draft-proxy-type == "http" ? "http://" + root.draft-proxy-hostport + : "socks5://" + root.draft-proxy-hostport, + group: root.draft-group, + 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 index 58705889..8bec372c 100644 --- a/ui/sftp_panel.slint +++ b/ui/sftp_panel.slint @@ -11,6 +11,8 @@ export struct SftpEntry { is-dir: bool, size: string, modified: string, + mode: int, // POSIX permission bits, for the chmod dialog (#84) + selected: bool, // multi-select checkbox state (#100) } export struct SftpTreeNode { @@ -25,18 +27,19 @@ export struct SftpTreeNode { component SftpMenuItem inherits Rectangle { in property label; in property tint: Theme.text-primary; + in property enabled: true; // disabled items grey out + ignore clicks callback clicked(); height: 26px; border-radius: Theme.radius-sm; - background: ta.has-hover ? Theme.bg-hover : transparent; + background: (root.enabled && ta.has-hover) ? Theme.bg-hover : transparent; ta := TouchArea { - mouse-cursor: pointer; - clicked => { root.clicked(); } + mouse-cursor: root.enabled ? pointer : default; + clicked => { if (root.enabled) { root.clicked(); } } } Text { x: 10px; text: root.label; - color: root.tint; + color: root.enabled ? root.tint : Theme.text-muted; font-size: Theme.fs-sm; vertical-alignment: center; height: parent.height; @@ -51,24 +54,87 @@ export component SftpPanel inherits Rectangle { // --- Properties -------------------------------------------------------- in property current-path: "/"; in property <[SftpEntry]> entries; - in property status: "SFTP 未连接"; + in property status: @tr("SFTP not connected"); in property loading: false; in property <[SftpTreeNode]> tree-nodes: []; + in property collapsed: false; // when true only the toolbar shows + in property show-tree: true; // hide the directory tree when narrow (#dock) + in property compact: false; // side-docked + collapsed → render nothing but a clean strip (#dock) + // Responsive columns: only when docked left/right (narrow, width-adjustable). + // Docked top/bottom the panel is wide and can only be resized vertically, so + // always show every column there (show-tree is true exactly in that case). + // When narrow, drop Size then Modified the moment keeping them would squeeze + // the Name column below `name-min` (names start eliding to "…"). Reserved + // widths: checkbox+padding+spacing 36px, Size col 76px, Modified 120px. + property list-w: root.show-tree ? root.width - 161px : root.width; + property name-min: 180px; // narrower than this → start dropping columns + property show-size-col: root.show-tree || (root.list-w - 36px - 76px - 120px > root.name-min); + property show-modified-col: root.show-tree || (root.list-w - 36px - 120px > root.name-min); + property selected-row: -1; // clicked/highlighted file-list row (#70) + in property selected-count; // number of checked entries (#100) + // Panel-level right-click context menu (#84). One menu for the whole list: + // an empty `menu-target` means the click landed on whitespace, which greys + // out the item-specific actions (download/rename/…) but keeps new/refresh. + property menu-x; + property menu-y; + property menu-target; // "" = whitespace + property menu-target-name; + property menu-target-is-dir; + property menu-target-mode; // permission bits for the chmod dialog // --- Callbacks --------------------------------------------------------- + callback toggle-collapsed(); // minimise / restore the panel callback navigate(string); callback download(string); - callback upload-clicked(string); + callback upload-clicked(string /* remote dir */, bool /* folder */); callback refresh(string); callback tree-expand(string); // toggle expand/collapse + navigate callback delete(string); // remove a remote file/dir - callback view(string); // download to temp + open (read) - callback edit(string); // download to temp + open + auto-reupload + callback toggle-select(int); // toggle a row's multi-select checkbox (#100) + callback download-selected(); // download all checked entries (#100) + callback delete-selected(); // delete all checked entries (#100) + callback view(string); // open read-only in the built-in viewer + callback edit(string); // open writable in the built-in editor + callback open-external(string); // open with the OS default app (#81) + callback edit-external(string); // open externally + watch/re-upload (#81) + // Context-menu extensions (#69). The *-request callbacks open dialogs in + // the app shell; copy-path goes straight to the system clipboard. + callback rename-request(string /* path */, string /* current name */); + callback chmod-request(string /* path */, string /* name */, int /* mode */); + callback copy-path(string); + callback new-folder(string /* parent dir */); + callback new-file(string /* parent dir */); + // Drag-to-dock: empty areas of the panel are a grab handle; coordinates are + // reported in window space so the parent can compute the target edge (#dock). + callback dock-drag-move(length /* abs-x */, length /* abs-y */); + callback dock-drag-end(); // ----------------------------------------------------------------------- background: Theme.bg-panel; + // Keep the editable path bar in sync when the directory changes elsewhere + // (tree click, file double-click, terminal cd) (#54). + changed current-path => { path-input.text = root.current-path; root.selected-row = -1; } + + // Drag empty space to re-dock; interactive widgets (file list, tree, toolbar + // buttons) sit in front and capture their own events (#dock). + dock-bg := TouchArea { + mouse-cursor: move; + moved => { + root.dock-drag-move(self.absolute-position.x + self.mouse-x, + self.absolute-position.y + self.mouse-y); + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.up) { root.dock-drag-end(); } + } + } + + // Hidden entirely when side-docked + collapsed: the horizontal toolbar would + // otherwise squish into an unusable vertical strip. `visible` (not `if`) so + // ids like path-input stay accessible to handlers. The parent floats a clean + // expand button over this (#dock). VerticalLayout { + visible: !root.compact; spacing: 0; // --- Toolbar (full width) ------------------------------------------ @@ -81,14 +147,93 @@ export component SftpPanel inherits Rectangle { padding-right: 6px; spacing: 4px; - // Path bar fills the left; action buttons sit on the right. - Text { - text: root.current-path; - color: Theme.text-primary; - font-size: Theme.fs-sm; + // Dedicated drag handle to re-dock the panel. The toolbar is full + // of controls, so dragging "empty space" is unreliable; this grip + // is always grabbable and reports window-space coords (#dock). + Rectangle { + width: 16px; + y: (parent.height - self.height) / 2; + height: 22px; + grip-ta := TouchArea { + mouse-cursor: move; + moved => { + root.dock-drag-move(self.absolute-position.x + self.mouse-x, + self.absolute-position.y + self.mouse-y); + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.up) { root.dock-drag-end(); } + } + } + Text { + text: "\u{E945}"; // drag_indicator + font-family: "Material Icons"; + color: grip-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; + height: parent.height; + } + } + + // Editable path bar: type or paste a path and press Enter to + // jump there; copy / paste-jump buttons sit right after it (#54). + Rectangle { horizontal-stretch: 1; - overflow: elide; - vertical-alignment: center; + height: 22px; + border-radius: Theme.radius-sm; + background: Theme.bg-root; + border-width: 1px; + border-color: path-input.has-focus ? Theme.accent : Theme.border-subtle; + path-input := TextInput { + x: 6px; + width: parent.width - 12px; + height: parent.height; + text: root.current-path; + color: Theme.text-primary; + // CJK-capable font so typed Chinese isn't tofu (#54). + font-family: Theme.ui-font-family; + font-size: Theme.fs-sm; + vertical-alignment: center; + single-line: true; + accepted => { root.navigate(self.text); } + } + } + + // Copy the current path to the clipboard. + Rectangle { + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: copy-ta.has-hover ? Theme.bg-hover : transparent; + copy-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + path-input.select-all(); + path-input.copy(); + path-input.clear-selection(); + } + } + Text { text: "\u{E14D}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + + // Paste a path from the clipboard and jump straight to it. + Rectangle { + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: pj-ta.has-hover ? Theme.bg-hover : transparent; + pj-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + path-input.select-all(); + path-input.paste(); + root.navigate(path-input.text); + } + } + Text { text: "\u{E14F}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } } up-btn := Rectangle { @@ -103,16 +248,67 @@ export component SftpPanel inherits Rectangle { horizontal-alignment: center; vertical-alignment: center; } } - Rectangle { + // Upload: a small menu offers file vs folder (#85). The remote + // SFTP upload handles both — only the local picker differs. + ul-btn := Rectangle { 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); } + clicked => { ul-menu.show(); } } - Text { text: "上传"; color: #ffffff; font-size: Theme.fs-sm; + Text { text: @tr("Upload"); color: #ffffff; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } + ul-menu := PopupWindow { + x: 0; y: parent.height + 2px; + width: 124px; + height: 2 * 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; + SftpMenuItem { + label: @tr("Upload file"); + clicked => { root.upload-clicked(root.current-path, false); } + } + SftpMenuItem { + label: @tr("Upload folder"); + clicked => { root.upload-clicked(root.current-path, true); } + } + } + } + } + } + + // Batch actions on checked entries — only when something's checked (#100). + if root.selected-count > 0 : Text { + text: "✓" + root.selected-count; + color: Theme.accent; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + if root.selected-count > 0 : Rectangle { + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: dls-ta.has-hover ? Theme.bg-hover : transparent; + dls-ta := TouchArea { mouse-cursor: pointer; clicked => { root.download-selected(); } } + Text { text: "\u{E2C4}"; font-family: "Material Icons"; color: Theme.text-secondary; + font-size: Theme.fs-md; horizontal-alignment: center; vertical-alignment: center; } + } + if root.selected-count > 0 : Rectangle { + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: dels-ta.has-hover ? Theme.danger.with-alpha(0.18) : transparent; + dels-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-selected(); } } + Text { text: "\u{E872}"; font-family: "Material Icons"; color: Theme.danger; + font-size: Theme.fs-md; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { @@ -127,6 +323,21 @@ export component SftpPanel inherits Rectangle { color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } } + + // Minimise / restore the SFTP panel (#41). + Rectangle { + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: min-ta.has-hover ? Theme.bg-hover : transparent; + min-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-collapsed(); } + } + // expand_less (▲, restore) when collapsed, expand_more (▼) otherwise. + Text { text: root.collapsed ? "\u{E5CE}" : "\u{E5CF}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-md; + horizontal-alignment: center; vertical-alignment: center; } + } } } @@ -135,8 +346,8 @@ export component SftpPanel inherits Rectangle { vertical-stretch: 1; spacing: 0; - // ── Left: directory tree ──────────────────────────────────────── - VerticalLayout { + // ── Left: directory tree (hidden when the panel is narrow) ────── + if root.show-tree : VerticalLayout { width: 160px; spacing: 0; @@ -146,7 +357,7 @@ export component SftpPanel inherits Rectangle { background: Theme.bg-root; Text { x: 8px; - text: "目录树"; + text: @tr("Directory tree"); color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; @@ -211,8 +422,8 @@ export component SftpPanel inherits Rectangle { } } - // Vertical separator - Rectangle { width: 1px; background: Theme.border-subtle; } + // Vertical separator (only when the tree is shown) + if root.show-tree : Rectangle { width: 1px; background: Theme.border-subtle; } // ── Right: file listing ───────────────────────────────────────── VerticalLayout { @@ -224,122 +435,227 @@ export component SftpPanel inherits Rectangle { height: 20px; background: Theme.bg-root; HorizontalLayout { - padding-left: 8px; padding-right: 8px; - Text { text: "名称"; color: Theme.text-muted; font-size: Theme.fs-xs; + padding-left: 8px; padding-right: 8px; spacing: 8px; + // Spacer matching the rows' checkbox so the header columns + // line up with the values below them (#dock). + Rectangle { width: 16px; } + Text { text: @tr("Name"); color: Theme.text-muted; font-size: Theme.fs-xs; horizontal-stretch: 1; vertical-alignment: center; } - Text { text: "大小"; color: Theme.text-muted; font-size: Theme.fs-xs; + if root.show-size-col : Text { text: @tr("Size"); color: Theme.text-muted; font-size: Theme.fs-xs; width: 72px; horizontal-alignment: right; vertical-alignment: center; } - Text { text: "修改时间"; color: Theme.text-muted; font-size: Theme.fs-xs; + if root.show-modified-col : Text { text: @tr("Modified"); color: Theme.text-muted; font-size: Theme.fs-xs; width: 116px; horizontal-alignment: right; vertical-alignment: center; } } } Rectangle { height: 1px; background: Theme.border-subtle; } - // File list with scrollbar - ScrollView { + // File list. The area fills the panel height so right-click works + // on the whitespace below the rows too (#84); the single menu + // below greys out item actions when nothing was hit. + file-area := Rectangle { vertical-stretch: 1; + clip: true; + ScrollView { + width: parent.width; + height: parent.height; + viewport-width: self.visible-width; + viewport-height: max(self.visible-height, file-list.preferred-height); - file-list := VerticalLayout { - spacing: 0; - - if root.loading : Rectangle { - height: 40px; - Text { text: "加载中..."; color: Theme.text-muted; font-size: Theme.fs-sm; - horizontal-alignment: center; vertical-alignment: center; } - } + file-list := VerticalLayout { + spacing: 0; - if !root.loading && root.entries.length == 0 : Rectangle { - height: 40px; - Text { text: "目录为空"; color: Theme.text-muted; font-size: Theme.fs-sm; - horizontal-alignment: center; vertical-alignment: center; } - } - - for entry[i] in root.entries : row := Rectangle { - height: 24px; - background: row-ta.has-hover - ? #4a90e225 - : (mod(i, 2) == 0 ? transparent : #ffffff08); + if root.loading : Rectangle { + height: 40px; + Text { text: @tr("Loading..."); color: Theme.text-muted; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } - // Right-click anchor (relative to this row). - property mx; - property my; + if !root.loading && root.entries.length == 0 : Rectangle { + height: 40px; + Text { text: @tr("Empty directory"); color: Theme.text-muted; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } - row-ta := TouchArea { - mouse-cursor: pointer; - double-clicked => { - if (entry.is-dir) { - root.navigate(entry.full-path); - } else { - root.download(entry.full-path); + for entry[i] in root.entries : row := Rectangle { + height: 24px; + background: entry.selected + ? #4a90e235 + : (i == root.selected-row + ? #4a90e240 + : (row-ta.has-hover + ? #4a90e225 + : (mod(i, 2) == 0 ? transparent : #ffffff08))); + + row-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.selected-row = i; } + double-clicked => { + if (entry.is-dir) { + root.navigate(entry.full-path); + } else { + root.download(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(); + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.right) { + root.selected-row = i; + root.menu-target = entry.full-path; + root.menu-target-name = entry.name; + root.menu-target-is-dir = entry.is-dir; + root.menu-target-mode = entry.mode; + root.menu-x = self.absolute-position.x + self.mouse-x - file-area.absolute-position.x; + root.menu-y = self.absolute-position.y + self.mouse-y - file-area.absolute-position.y; + file-menu.show(); + } } } - } - // Right-click context menu (files: full set; dirs: delete only). - row-menu := PopupWindow { - x: row.mx; - y: row.my; - width: 108px; - height: (entry.is-dir ? 1 : 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: "下载"; - clicked => { root.download(entry.full-path); } - } - if !entry.is-dir : SftpMenuItem { - label: "查看"; - clicked => { root.view(entry.full-path); } + HorizontalLayout { + padding-left: 8px; padding-right: 8px; spacing: 8px; + // Multi-select checkbox (#100). Its own TouchArea + // sits above the row, so toggling doesn't trigger + // navigate/select. + Rectangle { + width: 16px; + y: (parent.height - self.height) / 2; + height: 16px; + border-radius: 3px; + border-width: 1px; + border-color: entry.selected ? Theme.accent : Theme.border-strong; + background: entry.selected ? Theme.accent : transparent; + cb-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-select(i); } } - if !entry.is-dir : SftpMenuItem { - label: "编辑"; - clicked => { root.edit(entry.full-path); } - } - SftpMenuItem { - label: "删除"; - tint: #d86c6c; - clicked => { root.delete(entry.full-path); } + if entry.selected : Text { + text: "\u{E5CA}"; // check + font-family: "Material Icons"; + color: #ffffff; + font-size: 13px; + horizontal-alignment: center; + vertical-alignment: center; + width: parent.width; height: parent.height; } } + Text { + text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name; + color: entry.is-dir ? Theme.accent : Theme.text-primary; + font-size: Theme.fs-sm; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + if root.show-size-col : Text { + text: entry.is-dir ? "" : entry.size; + color: Theme.text-secondary; font-size: Theme.fs-xs; + width: 72px; horizontal-alignment: right; vertical-alignment: center; + } + if root.show-modified-col : Text { + text: entry.modified; + color: Theme.text-muted; font-size: Theme.fs-xs; + width: 116px; horizontal-alignment: right; vertical-alignment: center; + } } } - HorizontalLayout { - padding-left: 8px; padding-right: 8px; spacing: 4px; + // Whitespace below the rows: right-click here opens the + // menu with no target (item actions greyed out) (#84). + ws := Rectangle { + vertical-stretch: 1; + min-height: 24px; + ws-ta := TouchArea { + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.right) { + root.selected-row = -1; + root.menu-target = ""; + root.menu-x = self.absolute-position.x + self.mouse-x - file-area.absolute-position.x; + root.menu-y = self.absolute-position.y + self.mouse-y - file-area.absolute-position.y; + file-menu.show(); + } + } + } + } + } + } - Text { - text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name; - color: entry.is-dir ? Theme.accent : Theme.text-primary; - font-size: Theme.fs-sm; - horizontal-stretch: 1; - overflow: elide; - vertical-alignment: center; + // One context menu for the whole list (#84). Item actions are + // enabled only when an item was right-clicked (menu-target set); + // new/refresh are always available. + file-menu := PopupWindow { + x: root.menu-x; + y: root.menu-y; + width: 148px; + height: 12 * 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; + SftpMenuItem { + label: @tr("Download"); + enabled: root.menu-target != ""; + clicked => { root.download(root.menu-target); } } - Text { - text: entry.is-dir ? "" : entry.size; - color: Theme.text-secondary; font-size: Theme.fs-xs; - width: 72px; horizontal-alignment: right; vertical-alignment: center; + SftpMenuItem { + label: @tr("View"); + enabled: root.menu-target != "" && !root.menu-target-is-dir; + clicked => { root.view(root.menu-target); } } - Text { - text: entry.modified; - color: Theme.text-muted; font-size: Theme.fs-xs; - width: 116px; horizontal-alignment: right; vertical-alignment: center; + SftpMenuItem { + label: @tr("Edit"); + enabled: root.menu-target != "" && !root.menu-target-is-dir; + clicked => { root.edit(root.menu-target); } + } + SftpMenuItem { + label: @tr("Open externally"); + enabled: root.menu-target != "" && !root.menu-target-is-dir; + clicked => { root.open-external(root.menu-target); } + } + SftpMenuItem { + label: @tr("Edit externally"); + enabled: root.menu-target != "" && !root.menu-target-is-dir; + clicked => { root.edit-external(root.menu-target); } + } + SftpMenuItem { + label: @tr("Rename"); + enabled: root.menu-target != ""; + clicked => { root.rename-request(root.menu-target, root.menu-target-name); } + } + SftpMenuItem { + label: @tr("Permissions"); + enabled: root.menu-target != ""; + clicked => { root.chmod-request(root.menu-target, root.menu-target-name, root.menu-target-mode); } + } + SftpMenuItem { + label: @tr("Copy path"); + enabled: root.menu-target != ""; + clicked => { root.copy-path(root.menu-target); } + } + SftpMenuItem { + label: @tr("Delete"); + tint: #d86c6c; + enabled: root.menu-target != ""; + clicked => { root.delete(root.menu-target); } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + SftpMenuItem { + label: @tr("New folder"); + clicked => { root.new-folder(root.current-path); } + } + SftpMenuItem { + label: @tr("New file"); + clicked => { root.new-file(root.current-path); } + } + SftpMenuItem { + label: @tr("Refresh"); + clicked => { root.refresh(root.current-path); } } } } diff --git a/ui/sidebar.slint b/ui/sidebar.slint index 86c5cd82..003cc60b 100644 --- a/ui/sidebar.slint +++ b/ui/sidebar.slint @@ -25,16 +25,17 @@ component StatRow inherits HorizontalLayout { text: label; color: Theme.text-secondary; font-size: Theme.fs-sm; - width: 36px; + width: 52px; // fits English "Memory" as well as 中文 "内存" vertical-alignment: center; + overflow: elide; } - // Progress bar + // Progress bar — stretches to fill the row, with a floor so it stays usable. Rectangle { background: Theme.bg-root; border-radius: 2px; + horizontal-stretch: 2; min-width: 60px; - horizontal-stretch: 1; Rectangle { x: 0; @@ -56,11 +57,13 @@ component StatRow inherits HorizontalLayout { } } + // Used/total figure, right-aligned; reserve width so it never gets clipped. Text { text: detail; color: Theme.text-muted; font-size: Theme.fs-xs; - width: 56px; + horizontal-stretch: 1; + min-width: 64px; horizontal-alignment: right; vertical-alignment: center; } @@ -186,13 +189,180 @@ component NetGraph inherits VerticalLayout { } } +// --- Reusable sections ----------------------------------------------------- +// Each block is self-contained so the sidebar can lay them out vertically (when +// docked left/right) or horizontally (docked top/bottom) without duplicating +// their internals (#dock). + +// Status header — also the drag handle (a grip TouchArea behind the texts) and +// home of the collapse button, so collapsing follows the panel to any edge. +component StatusBlock inherits Rectangle { + in property conn-state; + in property connection-state; + in property collapse-icon; // chevron pointing toward the docked edge + callback dock-drag-move(length, length); + callback dock-drag-end(); + callback collapse(); + TouchArea { + mouse-cursor: move; + moved => { + root.dock-drag-move(self.absolute-position.x + self.mouse-x, + self.absolute-position.y + self.mouse-y); + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.up) { root.dock-drag-end(); } + } + } + VerticalLayout { + spacing: 4px; + HorizontalLayout { + spacing: 6px; + Rectangle { + width: 8px; height: 8px; + y: (parent.height - self.height) / 2; + border-radius: 4px; + background: root.conn-state == 1 ? Theme.success + : root.conn-state == 2 ? Theme.warning + : Theme.text-muted; + } + Text { text: @tr("Status"); color: Theme.text-primary; + font-size: Theme.fs-md; font-weight: 600; + vertical-alignment: center; } + Rectangle { horizontal-stretch: 1; } + // Collapse button — sits in front of the drag grip so it takes the click. + Rectangle { + width: 18px; height: 18px; + y: (parent.height - self.height) / 2; + border-radius: Theme.radius-sm; + background: cl-ta.has-hover ? Theme.bg-hover : transparent; + cl-ta := TouchArea { mouse-cursor: pointer; clicked => { root.collapse(); } } + Text { text: root.collapse-icon; font-family: "Material Icons"; + font-size: 14px; color: Theme.text-secondary; + horizontal-alignment: center; vertical-alignment: center; } + } + } + Text { text: root.connection-state; color: Theme.text-secondary; font-size: Theme.fs-sm; } + } +} + +// Resource title + Processes button + CPU/Memory/Swap rows. +component StatsBlock inherits VerticalLayout { + in property resource-title; + in property proc-available; + in property cpu-percent; + in property mem-percent; + in property swap-percent; + in property mem-detail; + in property swap-detail; + callback show-processes(); + spacing: 6px; + HorizontalLayout { + spacing: 6px; + Text { text: root.resource-title; color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; horizontal-stretch: 1; } + if root.proc-available : Rectangle { + width: 52px; height: 20px; + border-radius: Theme.radius-sm; + background: proc-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + proc-ta := TouchArea { mouse-cursor: pointer; clicked => { root.show-processes(); } } + Text { text: @tr("Processes"); color: Theme.text-secondary; font-size: Theme.fs-xs; + horizontal-alignment: center; vertical-alignment: center; } + } + } + StatRow { label: "CPU"; percent: root.cpu-percent; 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; } +} + +// Dual network graph (active tab on top, local below). +component NetBlock inherits VerticalLayout { + in property net-top-up; + in property net-top-down; + in property <[float]> net-top-history; + in property net-show-selector; + in property <[string]> net-ifaces; + in property net-selected; + in property net-bot-up; + in property net-bot-down; + in property <[float]> net-bot-history; + callback select-net-iface(string); + 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); } + } + Text { text: @tr("Local"); color: Theme.text-muted; font-size: Theme.fs-xs; } + NetGraph { up-text: root.net-bot-up; down-text: root.net-bot-down; history: root.net-bot-history; } +} + +// Disk-usage list with a hover tooltip, self-contained. +component DiskBlock inherits Rectangle { + in property <[DiskInfo]> disks; + private property tip-idx: -1; + private property tip-path: ""; + private property tip-y: tip-idx >= 0 + ? clamp(flick.y + tip-idx * 21px + flick.viewport-y + 20px, 2px, root.height - 28px) : 0px; + VerticalLayout { + 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; } + } + flick := Flickable { + vertical-stretch: 1; + viewport-width: self.width; + 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; + ta := TouchArea { + changed has-hover => { + if (self.has-hover) { + root.tip-path = d.path; root.tip-idx = i; + } else if (root.tip-path == d.path) { + root.tip-path = ""; root.tip-idx = -1; + } + } + } + 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 { + padding-left: 6px; padding-right: 6px; spacing: 4px; + Text { text: d.path; color: Theme.text-secondary; font-size: Theme.fs-xs; + vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } + Text { text: d.detail; color: Theme.text-muted; font-size: Theme.fs-xs; + vertical-alignment: center; horizontal-alignment: right; } + } + } + } + } + } + if tip-path != "" : Rectangle { + x: 4px; y: tip-y; width: parent.width - 8px; height: 24px; z: 100; + background: Theme.bg-elevated; border-radius: Theme.radius-sm; + border-width: 1px; border-color: Theme.border-subtle; + drop-shadow-blur: 8px; drop-shadow-offset-y: 2px; drop-shadow-color: #00000040; + Text { x: 8px; y: (parent.height - self.height) / 2; width: parent.width - 16px; + text: tip-path; color: Theme.text-primary; font-size: Theme.fs-xs; overflow: elide; } + } +} + // --- Sidebar --------------------------------------------------------------- // Left-hand panel showing local machine health, mimicking FinalShell's -// "运行状态" column but kept minimal for the v0.1 baseline. +// @tr("Status") column but kept minimal for the v0.1 baseline. export component Sidebar inherits Rectangle { - in property connection-state: "未连接"; + in property connection-state: @tr("Not connected"); in property conn-state; // 0 gray / 1 green / 2 yellow - in property resource-title: "本机资源"; + in property resource-title: @tr("Local resources"); in property cpu-percent; in property mem-percent; in property swap-percent; @@ -208,181 +378,118 @@ export component Sidebar inherits Rectangle { in property net-selected; in property net-show-selector; in property <[DiskInfo]> disks; + in property proc-available; // a live remote session → show the process button (#23) + // App version string, injected from Rust (env!("CARGO_PKG_VERSION")) so it + // always matches Cargo.toml without hand-editing this file. + in property app-version; callback select-net-iface(string); + callback show-processes(); // open the process monitor popup (#23) + // Drag-to-dock: the header strip is a grab handle; coordinates are reported + // in window space so the AppWindow can compute the target edge (#dock). + callback dock-drag-move(length /* abs-x */, length /* abs-y */); + callback dock-drag-end(); + callback toggle-collapse(); + // When docked top/bottom the panel is short and wide, so the blocks lay out + // in a row; left/right keeps the classic vertical stack (#dock). + in property lay-horizontal; + // Which edge the panel is docked to — drives the collapse chevron direction. + in property dock-edge; + property collapse-icon: + root.dock-edge == "right" ? "\u{E5CC}" // chevron_right + : root.dock-edge == "top" ? "\u{E5CE}" // expand_less + : root.dock-edge == "bottom" ? "\u{E5CF}" // expand_more + : "\u{E5CB}"; // chevron_left (left, default) width: 220px; background: Theme.bg-panel; border-width: 0; - VerticalLayout { + // Drag from any empty area of the panel to re-dock it (the status header is + // a small target). Sits behind the content; interactive widgets (stat bars, + // net selector, disk rows) are in front and capture their own events, so + // this only fires on the panel's empty space (#dock). + dock-bg := TouchArea { + mouse-cursor: move; + moved => { + root.dock-drag-move(self.absolute-position.x + self.mouse-x, + self.absolute-position.y + self.mouse-y); + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.up) { root.dock-drag-end(); } + } + } + + // --- Vertical stack (docked left/right) -------------------------------- + if !root.lay-horizontal : VerticalLayout { padding: 10px; spacing: 10px; - - // Status header - VerticalLayout { - spacing: 4px; - HorizontalLayout { - spacing: 6px; - Rectangle { - width: 8px; height: 8px; - y: (parent.height - self.height) / 2; - border-radius: 4px; - background: root.conn-state == 1 ? Theme.success - : root.conn-state == 2 ? Theme.warning - : Theme.text-muted; - } - Text { - text: "运行状态"; - color: Theme.text-primary; - font-size: Theme.fs-md; - font-weight: 600; - } - } - Text { - text: connection-state; - color: Theme.text-secondary; - font-size: Theme.fs-sm; - } + StatusBlock { + conn-state: root.conn-state; + connection-state: root.connection-state; + collapse-icon: root.collapse-icon; + dock-drag-move(x, y) => { root.dock-drag-move(x, y); } + dock-drag-end() => { root.dock-drag-end(); } + collapse() => { root.toggle-collapse(); } } - 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: ""; - bar-color: Theme.accent; - } - StatRow { - label: "内存"; - percent: root.mem-percent; - detail: root.mem-detail; - bar-color: Theme.success; - } - StatRow { - label: "交换"; - percent: root.swap-percent; - detail: root.swap-detail; - bar-color: Theme.warning; - } + StatsBlock { + resource-title: root.resource-title; proc-available: root.proc-available; + cpu-percent: root.cpu-percent; mem-percent: root.mem-percent; swap-percent: root.swap-percent; + mem-detail: root.mem-detail; swap-detail: root.swap-detail; + show-processes() => { root.show-processes(); } } - Rectangle { height: 1px; background: Theme.border-subtle; } - - // Network — dual graph: top follows the active tab (remote NIC with a - // selector on a session tab; local on the welcome tab), bottom is the - // local machine. - 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); } - } - Text { - text: "本机"; - color: Theme.text-muted; - font-size: Theme.fs-xs; - } - NetGraph { - up-text: root.net-bot-up; - down-text: root.net-bot-down; - history: root.net-bot-history; - } + NetBlock { + net-top-up: root.net-top-up; net-top-down: root.net-top-down; net-top-history: root.net-top-history; + net-show-selector: root.net-show-selector; net-ifaces: root.net-ifaces; net-selected: root.net-selected; + net-bot-up: root.net-bot-up; net-bot-down: root.net-bot-down; net-bot-history: root.net-bot-history; + select-net-iface(i) => { root.select-net-iface(i); } } - Rectangle { height: 1px; background: Theme.border-subtle; } + DiskBlock { vertical-stretch: 1; disks: root.disks; } + Text { + text: "meatshell v" + root.app-version; + color: Theme.text-muted; font-size: Theme.fs-xs; horizontal-alignment: center; + } + } - // Disk-usage panel (active tab's filesystems; local on the welcome tab). + // --- Horizontal row (docked top/bottom) -------------------------------- + if root.lay-horizontal : HorizontalLayout { + padding: 10px; + spacing: 10px; + // Status + server-resources share one container, stacked vertically: + // the status sits as a compact two-row header *above* the CPU/Mem/Swap, + // both left-aligned. `alignment: start` keeps them at the top instead of + // stretching to fill the panel height. VerticalLayout { - vertical-stretch: 1; - spacing: 3px; - - HorizontalLayout { - padding-left: 6px; - padding-right: 6px; - Text { - text: "路径"; - color: Theme.text-muted; - font-size: Theme.fs-xs; - horizontal-stretch: 1; - } - Text { - text: "可用/大小"; - color: Theme.text-muted; - font-size: Theme.fs-xs; - } + alignment: start; + spacing: 8px; + min-width: 200px; + StatusBlock { + conn-state: root.conn-state; + connection-state: root.connection-state; + collapse-icon: root.collapse-icon; + dock-drag-move(x, y) => { root.dock-drag-move(x, y); } + dock-drag-end() => { root.dock-drag-end(); } + collapse() => { root.toggle-collapse(); } } - - 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 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 { - padding-left: 6px; - padding-right: 6px; - spacing: 4px; - Text { - text: d.path; - color: Theme.text-secondary; - font-size: Theme.fs-xs; - vertical-alignment: center; - horizontal-stretch: 1; - overflow: elide; - } - Text { - text: d.detail; - color: Theme.text-muted; - font-size: Theme.fs-xs; - vertical-alignment: center; - horizontal-alignment: right; - } - } - } - } + StatsBlock { + resource-title: root.resource-title; proc-available: root.proc-available; + cpu-percent: root.cpu-percent; mem-percent: root.mem-percent; swap-percent: root.swap-percent; + mem-detail: root.mem-detail; swap-detail: root.swap-detail; + show-processes() => { root.show-processes(); } } } - - Text { - text: "meatshell v0.2"; - color: Theme.text-muted; - font-size: Theme.fs-xs; - horizontal-alignment: center; + Rectangle { width: 1px; background: Theme.border-subtle; } + NetBlock { + min-width: 220px; + net-top-up: root.net-top-up; net-top-down: root.net-top-down; net-top-history: root.net-top-history; + net-show-selector: root.net-show-selector; net-ifaces: root.net-ifaces; net-selected: root.net-selected; + net-bot-up: root.net-bot-up; net-bot-down: root.net-bot-down; net-bot-history: root.net-bot-history; + select-net-iface(i) => { root.select-net-iface(i); } } + Rectangle { width: 1px; background: Theme.border-subtle; } + DiskBlock { horizontal-stretch: 1; disks: root.disks; } } + } diff --git a/ui/tabs.slint b/ui/tabs.slint index 165e0102..e5281e88 100644 --- a/ui/tabs.slint +++ b/ui/tabs.slint @@ -69,6 +69,9 @@ component SingleTab inherits Rectangle { export component TabBar inherits Rectangle { in property <[TabInfo]> tabs; in property active-id; + // Keep this much clear on the right so tabs don't slide under the app's + // top-right toolbar icons, which are positioned over the tab row (#122). + in property reserve-right: 0px; callback tab-selected(string); callback tab-closed(string); callback new-tab(); @@ -76,25 +79,68 @@ export component TabBar inherits Rectangle { height: 36px; background: Theme.bg-panel-alt; + // True only when the tabs are wider than the available strip width (#122). + // Computed from the TabBar's own width (set by the parent) — NOT flick.width — + // so showing the arrows (which shrink the flickable) can't feed back into this + // condition and form a binding loop. The 64px allows for the two arrows. + property tab-overflow: + strip.preferred-width > root.width - root.reserve-right - 64px; + HorizontalLayout { padding: 3px; spacing: 2px; - for tab[i] in tabs : SingleTab { - info: tab; - active: tab.id == root.active-id; - selected => { root.tab-selected(tab.id); } - closed => { root.tab-closed(tab.id); } + // Horizontally-scrollable strip: when the tabs overflow the available + // width they stay reachable by the arrows or by dragging / trackpad (#122). + flick := Flickable { + horizontal-stretch: 1; + viewport-height: self.height; + viewport-width: max(self.width, strip.preferred-width); + strip := HorizontalLayout { + spacing: 2px; + alignment: start; + for tab[i] in tabs : SingleTab { + info: tab; + active: tab.id == root.active-id; + selected => { root.tab-selected(tab.id); } + closed => { root.tab-closed(tab.id); } + } + IconButton { + glyph: "+"; + size: 28px; + y: (parent.height - self.height) / 2; + clicked => { root.new-tab(); } + } + } } + // Reserved gap for the external top-right toolbar icons (#122). + Rectangle { width: root.reserve-right; } + } + + // Scroll arrows — absolute overlays (NOT layout items) so toggling them can't + // change the layout that `tab-overflow` reads (avoids a binding loop). Shown + // only on overflow, masking the edge tab beneath them (#122). + if root.tab-overflow : Rectangle { + x: 3px; + y: (parent.height - self.height) / 2; + width: 26px; height: 26px; + background: Theme.bg-panel-alt; IconButton { - glyph: "+"; - size: 28px; - y: (parent.height - self.height) / 2; - clicked => { root.new-tab(); } + glyph: "‹"; size: 24px; + clicked => { flick.viewport-x = min(0px, flick.viewport-x + 120px); } + } + } + if root.tab-overflow : Rectangle { + x: root.width - root.reserve-right - 29px; + y: (parent.height - self.height) / 2; + width: 26px; height: 26px; + background: Theme.bg-panel-alt; + IconButton { + glyph: "›"; size: 24px; + clicked => { + flick.viewport-x = max(flick.width - flick.viewport-width, flick.viewport-x - 120px); + } } - - // Spacer - Rectangle { horizontal-stretch: 1; } } } diff --git a/ui/terminal_view.slint b/ui/terminal_view.slint index c7c3778d..2aea5293 100644 --- a/ui/terminal_view.slint +++ b/ui/terminal_view.slint @@ -2,12 +2,12 @@ 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"; +// with the CORRECT font (the built-in Meatshell 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 Meatshell Mono for its width calc. +import "fonts/MeatshellMono-Regular.ttf"; +import "fonts/MeatshellMono-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 @@ -22,6 +22,7 @@ export struct TermSpan { row: int, col: int, cells: int, + cjk: bool, // contains CJK chars → render with the CJK-capable font (#54) } // A find-match highlight rectangle on the terminal grid. @@ -31,6 +32,19 @@ export struct TermMatch { len: int, } +// One saved quick command (#55): a name shown on the chip + the text to send. +// Grouped like the welcome session list: group-header is non-empty on the first +// row of each group; collapsed hides the group's rows in the popup; orig-index +// points back to the entry's position in the stored list (delete target). +export struct QuickCmd { + name: string, + command: string, + group: string, + group-header: string, + collapsed: bool, + orig-index: int, +} + // --- MenuItem -------------------------------------------------------------- // One row of the terminal right-click context menu (MDL2 icon + label). component MenuItem inherits Rectangle { @@ -71,11 +85,13 @@ component MenuItem inherits Rectangle { export component TerminalView inherits Rectangle { in property tab-id; - in property status-line: "未连接"; + 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 scroll-max; // scrollback depth (max offset) (#103) + in property scroll-offset; // current offset (0 = live bottom) (#103) in property is-alt-screen; in property <[TermMatch]> find-matches; // search-highlight rectangles in property <[TermMatch]> selection; // drag-selection highlight rectangles @@ -87,22 +103,82 @@ export component TerminalView inherits Rectangle { in property <[SftpTreeNode]> sftp-tree-nodes: []; in-out property sftp-panel-height: 220px; + in-out property sftp-panel-width: 380px; // extent when docked left/right + // Shared with the AppWindow (like sftp-panel-height) so a startup default can + // collapse the panel and the state stays consistent across tabs (#78). + in-out property sftp-saved-height: 220px; // height to restore on un-collapse + in-out property sftp-collapsed: false; // SFTP panel minimised (toolbar only) + + // --- SFTP docking (within the terminal area) --------------------------- + // Which edge of the terminal area the SFTP panel sits on: left|right|top|bottom. + in-out property sftp-dock: "bottom"; + property sftp-horizontal: root.sftp-dock == "left" || root.sftp-dock == "right"; + // Drag-to-dock snap state (mirrors the resource-panel docking in app.slint). + property sftp-panel-dragging; + property sftp-drag-nx; + property sftp-drag-ny; + property sftp-dock-target: + !root.sftp-panel-dragging ? "" + : root.sftp-drag-nx < 0.22 ? "left" + : root.sftp-drag-nx > 0.78 ? "right" + : root.sftp-drag-ny < 0.22 ? "top" + : root.sftp-drag-ny > 0.78 ? "bottom" + : ""; + callback persist-sftp-dock(string); + + // Command bar (#55): quick commands + input box + history (all shared/global, + // fed from Rust); send-to-all is a per-tab toggle. + in property <[QuickCmd]> quick-commands; + in property <[string]> command-history; // oldest first (newest last, #113) + in property <[string]> history-view; // filtered view shown in the dropdown (#101) + property send-to-all: false; + property quick-open: false; // quick-command popup visibility + property history-open: false; // history dropdown visibility + property history-cursor: -1; // ↑/↓ recall index (-1 = live input) + callback run-command(string /* cmd */, bool /* to-all */); + callback manage-quick-commands(); // open the add/edit/delete dialog + callback toggle-quick-group(string /* group */); // collapse/expand a group (#55) + // Right-click management on the popup, mirroring the welcome session list (#55). + callback edit-quick-command(int /* index */); + callback duplicate-quick-command(int /* index */); + callback delete-quick-command(int /* index */); + callback move-quick-command(int /* index */, string /* group */); + callback new-quick-group(); + callback rename-quick-group(string /* group */); + callback delete-quick-group(string /* group */); + callback copy-text(string); // copy a history command to clipboard (#96) + callback delete-history(int /* index */); // remove a history entry (#96) + callback delete-history-cmd(string /* cmd */); // remove a history entry by command (#101) + callback search-history(string /* query */); // filter the history dropdown (#101) 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-upload-clicked(string, bool); callback sftp-refresh(string); callback sftp-tree-expand(string); callback sftp-delete(string); + in property sftp-selected-count; // checked-entry count (#100) + callback sftp-toggle-select(int); // toggle a row's checkbox (#100) + callback sftp-download-selected(); // download checked entries (#100) + callback sftp-delete-selected(); // delete checked entries (#100) callback sftp-view(string); callback sftp-edit(string); + callback sftp-open-external(string); + callback sftp-edit-external(string); + // Context-menu extensions (#69), forwarded up to the app shell. + callback sftp-rename-request(string, string); + callback sftp-chmod-request(string, string, int); + callback sftp-copy-path(string); + callback sftp-new-folder(string); + callback sftp-new-file(string); 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 terminal-scroll-to(int); // jump to an absolute scrollback offset (#103) 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 @@ -138,12 +214,12 @@ export component TerminalView inherits Rectangle { // 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 + // Consolas ~6.5 px/char instead of Meatshell 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: Theme.fs-md; + font-family: Theme.term-font-family; + font-size: Theme.term-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. @@ -195,8 +271,13 @@ export component TerminalView inherits Rectangle { flickable.viewport-y = flickable.height - flickable.viewport-height; } } + // Reset the history filter whenever the dropdown opens; the search box itself + // is recreated empty because the `if` subtree is destroyed on close (#101). + changed history-open => { + if (root.history-open) { root.search-history(""); } + } changed visible => { - if self.visible { + if (self.visible) { ime-input.focus(); // Re-sync the PTY size on return (it was frozen while hidden). root.terminal-resize(root.term-cols, root.term-rows); @@ -229,11 +310,30 @@ export component TerminalView inherits Rectangle { color: Theme.success; font-size: Theme.fs-xs; vertical-alignment: center; - padding-right: 4px; } } } + // SFTP docking region — terminal + command-bar (central) on one side, + // the SFTP panel docked to the chosen edge. Absolute geometry, mirroring + // the resource-panel dock area in app.slint (#dock). + dock-region := Rectangle { + vertical-stretch: 1; + // Collapsed → the panel fully disappears (like the resource panel); a + // floating expand button on the docked edge brings it back (#dock). + property sf-w: root.sftp-collapsed ? 0px : root.sftp-panel-width; + property sf-h: root.sftp-collapsed ? 0px : root.sftp-panel-height; + property sp: root.sftp-collapsed ? 0px : 4px; + + central := Rectangle { + x: root.sftp-dock == "left" ? parent.sf-w + parent.sp : 0px; + y: root.sftp-dock == "top" ? parent.sf-h + parent.sp : 0px; + width: root.sftp-horizontal ? parent.width - parent.sf-w - parent.sp : parent.width; + height: root.sftp-horizontal ? parent.height : parent.height - parent.sf-h - parent.sp; + clip: true; + VerticalLayout { + spacing: 0; + // --- Key-capture scope + scrollable output -------------------------- key-capture := FocusScope { vertical-stretch: 1; @@ -268,6 +368,13 @@ export component TerminalView inherits Rectangle { flickable := Flickable { vertical-stretch: 1; + // Non-interactive: a Flickable steals fast drags from child + // TouchAreas for its own flick gesture, which broke quick + // drag-selection (slow drags worked, fast ones selected + // nothing). We scroll via the wheel (scroll-event below → + // terminal-scroll) and pin programmatically, so we don't need + // the Flickable's own drag-scrolling (#119-followup). + interactive: false; viewport-width: self.width; // Alt-screen: lock the viewport to exactly the visible height // so the full-screen program (nano/vim) renders top-aligned @@ -333,12 +440,18 @@ export component TerminalView inherits Rectangle { floor(self.mouse-y / root.cell-h), floor(self.mouse-x / root.cell-w)); if (!root.is-alt-screen) { - // vy ∈ [0, flickable.height] = where the mouse - // is inside the visible window. + // vy = mouse position inside the visible window. + // Only auto-scroll once the drag actually leaves + // the top/bottom edge — selecting *within* the + // visible area must never make the view jump or + // the selection snap to an edge row (#41). Slint + // captures the pointer while pressed, so dragging + // past the edge still scrolls to extend across + // more than one screen. root.vis-y = self.mouse-y + 8px + flickable.viewport-y; - if (root.vis-y < root.cell-h) { + if (root.vis-y < 0) { root.autoscroll-dir = -1; // past top → older - } else if (root.vis-y > flickable.height - root.cell-h) { + } else if (root.vis-y > flickable.height) { root.autoscroll-dir = 1; // past bottom → newer } else { root.autoscroll-dir = 0; @@ -388,6 +501,10 @@ export component TerminalView inherits Rectangle { width: span.cells * root.cell-w; height: root.cell-h; background: span.bg; + // A wide-CJK span is a single char in a 2-cell box; clip + // it so a glyph whose advance differs from 2×cell-w can't + // bleed over the neighbouring cell / cursor (#60 follow-up). + clip: span.cjk; // Auto-sized Text (no width/height bindings) so its // layout never re-enters parley's FontContext. Text { @@ -395,8 +512,11 @@ export component TerminalView inherits Rectangle { y: 0px; text: span.text; color: span.fg; - font-family: "Cascadia Mono"; - font-size: Theme.fs-md; + // CJK spans use the CJK-capable UI font: the mono + // terminal font lacks CJK glyphs and Slint tofu's + // *isolated* CJK punctuation (、。,) on fallback (#54). + font-family: span.cjk ? Theme.ui-font-family : Theme.term-font-family; + font-size: Theme.term-font-size; font-weight: span.bold ? 700 : 400; } } @@ -428,19 +548,19 @@ export component TerminalView inherits Rectangle { padding: 4px; spacing: 1px; MenuItem { - icon: "\u{E14D}"; label: "复制"; + icon: "\u{E14D}"; label: @tr("Copy"); clicked => { root.copy-terminal-text(); ime-input.focus(); } } MenuItem { - icon: "\u{E14F}"; label: "粘贴"; + icon: "\u{E14F}"; label: @tr("Paste"); clicked => { root.paste-from-clipboard(); ime-input.focus(); } } MenuItem { - icon: "\u{E872}"; label: "清空缓存"; + icon: "\u{E872}"; label: @tr("Clear buffer"); clicked => { root.clear-terminal(); ime-input.focus(); } } MenuItem { - icon: "\u{E8B6}"; label: "查找"; + icon: "\u{E8B6}"; label: @tr("Find"); clicked => { root.find-active = true; } } } @@ -533,6 +653,40 @@ export component TerminalView inherits Rectangle { } // --- Find bar (overlay, top) ------------------------------------ + // Terminal scrollbar (#103) — a slim overlay on the right edge of the + // output area, shown only when there's scrollback (and not on the + // alternate screen). Driven by scroll-max/scroll-offset; click or drag + // the track to jump to an absolute position. + if root.scroll-max > 0 && !root.is-alt-screen : Rectangle { + x: flickable.x + flickable.width - 10px; + y: flickable.y + 4px; + width: 8px; + height: flickable.height - 8px; + track-ta := TouchArea { + moved => { + if (self.pressed) { + root.terminal-scroll-to(clamp(round(root.scroll-max * (1 - self.mouse-y / self.height)), 0, root.scroll-max)); + } + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { + root.terminal-scroll-to(clamp(round(root.scroll-max * (1 - self.mouse-y / self.height)), 0, root.scroll-max)); + } + } + } + Rectangle { + property total: root.scroll-max + root.term-rows; + x: 1px; + width: 6px; + height: max(28px, parent.height * root.term-rows / total); + y: (parent.height - self.height) * (root.scroll-max - root.scroll-offset) / max(1, root.scroll-max); + background: track-ta.has-hover || track-ta.pressed + ? Theme.text-secondary : Theme.border-strong; + border-radius: 3px; + animate background { duration: 100ms; } + } + } + if root.find-active : Rectangle { x: 0; y: 0; @@ -562,6 +716,7 @@ export component TerminalView inherits Rectangle { width: parent.width - 12px; height: parent.height; color: Theme.text-primary; + font-family: Theme.ui-font-family; // CJK-capable (#54) font-size: Theme.fs-md; vertical-alignment: center; single-line: true; @@ -605,53 +760,674 @@ export component TerminalView inherits Rectangle { } } - // --- Drag handle ---------------------------------------------------- + // --- Command bar (#55): quick commands + input box + history --------- + cmd-bar := Rectangle { + height: 34px; + background: Theme.bg-panel-alt; + border-width: 0; + + // Outside-click backdrop: covers the terminal above so clicking + // anywhere out of an open popup dismisses it. + if root.quick-open || root.history-open : TouchArea { + x: 0; y: -2000px; width: parent.width; height: 2000px; + clicked => { root.quick-open = false; root.history-open = false; } + } + + HorizontalLayout { + padding-left: 8px; padding-right: 8px; + padding-top: 4px; padding-bottom: 4px; + spacing: 6px; + + // Quick-command toggle. + Rectangle { + width: qc-row.preferred-width + 16px; + border-radius: Theme.radius-sm; + background: qc-ta.has-hover || root.quick-open ? Theme.bg-hover : Theme.bg-elevated; + qc-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.history-open = false; root.quick-open = !root.quick-open; } + } + qc-row := HorizontalLayout { + width: parent.width; height: parent.height; + alignment: center; + spacing: 3px; + Text { text: @tr("Quick"); color: Theme.text-secondary; font-size: Theme.fs-sm; + vertical-alignment: center; } + Text { text: "\u{E5CE}"; font-family: "Material Icons"; color: Theme.text-muted; + font-size: Theme.fs-sm; vertical-alignment: center; } + } + } + + // Command input. + Rectangle { + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: cmd-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + cmd-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + color: Theme.text-primary; + font-family: Theme.ui-font-family; + font-size: Theme.fs-md; + vertical-alignment: center; + single-line: true; + // Enter sends; ↑/↓ recall history. + accepted => { + root.run-command(self.text, root.send-to-all); + self.text = ""; + root.history-cursor = -1; + } + key-pressed(e) => { + if (e.text == Key.UpArrow) { + // Model is oldest→newest (#113): first ↑ jumps to + // the newest (last), further ↑ steps toward older. + if (root.command-history.length > 0) { + root.history-cursor = root.history-cursor == -1 + ? root.command-history.length - 1 + : max(root.history-cursor - 1, 0); + self.text = root.command-history[root.history-cursor]; + } + accept + } else if (e.text == Key.DownArrow) { + // ↓ steps toward newer; past the newest → live input. + if (root.history-cursor != -1) { + if (root.history-cursor < root.command-history.length - 1) { + root.history-cursor += 1; + self.text = root.command-history[root.history-cursor]; + } else { + root.history-cursor = -1; + self.text = ""; + } + } + accept + // --- readline line-editing keys (#103) --- + // Ctrl+A → start of line. + } else if (e.modifiers.control && (e.text == "a" || e.text == "A" || e.text == "\u{0001}")) { + self.set-selection-offsets(0, 0); + accept + // Ctrl+E → end of line. + } else if (e.modifiers.control && (e.text == "e" || e.text == "E" || e.text == "\u{0005}")) { + self.set-selection-offsets(1000000, 1000000); + accept + // Ctrl+K → kill from cursor to end (deletes + clipboard). + } else if (e.modifiers.control && (e.text == "k" || e.text == "K" || e.text == "\u{000B}")) { + self.set-selection-offsets(self.cursor-position_byte-offset, 1000000); + self.cut(); + accept + // Ctrl+U → kill from start to cursor. + } else if (e.modifiers.control && (e.text == "u" || e.text == "U" || e.text == "\u{0015}")) { + self.set-selection-offsets(0, self.cursor-position_byte-offset); + self.cut(); + accept + } else { + reject + } + } + } + if cmd-input.text == "" : Text { + x: 8px; + height: parent.height; + text: @tr("Type a command, Enter to send"); + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } + } + + // History toggle. + Rectangle { + width: 28px; + border-radius: Theme.radius-sm; + background: hist-ta.has-hover || root.history-open ? Theme.bg-hover : Theme.bg-elevated; + hist-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.quick-open = false; root.history-open = !root.history-open; } + } + Text { text: "\u{E5CE}"; font-family: "Material Icons"; color: Theme.text-secondary; + font-size: Theme.fs-md; horizontal-alignment: center; vertical-alignment: center; } + } + + // Send-to-all toggle. + Rectangle { + width: all-row.preferred-width + 14px; + border-radius: Theme.radius-sm; + background: root.send-to-all ? Theme.accent + : all-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + all-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.send-to-all = !root.send-to-all; } + } + all-row := HorizontalLayout { + width: parent.width; height: parent.height; + alignment: center; + Text { text: @tr("All sessions"); font-size: Theme.fs-sm; vertical-alignment: center; + color: root.send-to-all ? #ffffff : Theme.text-secondary; } + } + } + + // Send button. + Rectangle { + width: send-row.preferred-width + 18px; + border-radius: Theme.radius-sm; + background: send-ta.has-hover ? Theme.accent-hover : Theme.accent; + send-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.run-command(cmd-input.text, root.send-to-all); + cmd-input.text = ""; + root.history-cursor = -1; + } + } + send-row := HorizontalLayout { + width: parent.width; height: parent.height; + alignment: center; + Text { text: @tr("Send"); color: #ffffff; font-size: Theme.fs-sm; + vertical-alignment: center; } + } + } + } + + // --- Quick-command popup (opens upward) -------------------------- + if root.quick-open : Rectangle { + x: 8px; + width: 420px; + height: min(320px, qc-col.preferred-height + 8px); + y: -self.height - 2px; + 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; + TouchArea {} // swallow clicks + Flickable { + viewport-width: self.width; + viewport-height: max(self.height, qc-col.preferred-height); + qc-col := VerticalLayout { + padding: 4px; + alignment: start; + if root.quick-commands.length == 0 : Text { + text: @tr("No quick commands yet"); + color: Theme.text-muted; font-size: Theme.fs-xs; + horizontal-alignment: center; + height: 32px; vertical-alignment: center; + } + for q in root.quick-commands : VerticalLayout { + // Group header on the first row of each group (#55) — + // click to collapse/expand the whole group. + if q.group-header != "" : Rectangle { + height: 24px; + property gmx; + property gmy; + grp-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-quick-group(q.group); } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + gmx = self.mouse-x; gmy = self.mouse-y; qpop-grp-menu.show(); + } + } + } + HorizontalLayout { + padding-left: 4px; padding-top: 4px; spacing: 4px; + Text { + text: q.collapsed ? "\u{E5CC}" : "\u{E5CF}"; // chevron ▶/▼ + font-family: "Material Icons"; + color: Theme.text-muted; font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + text: "\u{E2C7}"; // folder + font-family: "Material Icons"; + color: Theme.text-muted; font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + text: q.group; color: Theme.text-muted; font-size: Theme.fs-xs; + font-weight: 700; vertical-alignment: center; + horizontal-stretch: 1; horizontal-alignment: left; + } + } + qpop-grp-menu := PopupWindow { + x: gmx; y: gmy; width: 150px; + height: (q.orig-index == -1 ? 3 : 2) * 30px + 9px; + 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: 1px; + Rectangle { height: 30px; border-radius: Theme.radius-sm; + background: pg-ren-ta.has-hover ? Theme.bg-hover : transparent; + pg-ren-ta := TouchArea { mouse-cursor: pointer; clicked => { root.rename-quick-group(q.group); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Rename group"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + if q.orig-index == -1 : Rectangle { height: 30px; border-radius: Theme.radius-sm; + background: pg-del-ta.has-hover ? #cc333320 : transparent; + pg-del-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-quick-group(q.group); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Delete group"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 30px; border-radius: Theme.radius-sm; + background: pg-new-ta.has-hover ? Theme.bg-hover : transparent; + pg-new-ta := TouchArea { mouse-cursor: pointer; clicked => { root.new-quick-group(); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2CC}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("New group"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + } + } + } + } + if !q.collapsed && q.orig-index >= 0 : Rectangle { + height: 30px; + border-radius: Theme.radius-sm; + background: q-item-ta.has-hover ? Theme.bg-hover : transparent; + property ctx-x; + property ctx-y; + q-item-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.run-command(q.command, root.send-to-all); + root.quick-open = false; + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + ctx-x = self.mouse-x; ctx-y = self.mouse-y; qpop-ctx-menu.show(); + } + } + } + // Name + command on one line; each elides so a long + // entry never overflows the popup (#55). + HorizontalLayout { + padding-left: 8px; padding-right: 8px; spacing: 10px; + Text { text: q.name; color: Theme.text-primary; font-size: Theme.fs-sm; + vertical-alignment: center; overflow: elide; + horizontal-stretch: 1; } + Text { text: q.command; color: Theme.text-muted; font-size: Theme.fs-xs; + vertical-alignment: center; overflow: elide; + horizontal-alignment: right; horizontal-stretch: 1; + font-family: Theme.ui-font-family; } + } + qpop-ctx-menu := PopupWindow { + x: ctx-x; y: ctx-y; width: 160px; + height: qpop-ctx-vl.preferred-height; + 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; + qpop-ctx-vl := VerticalLayout { + padding: 4px; spacing: 1px; + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: pi-edit-ta.has-hover ? Theme.bg-hover : transparent; + pi-edit-ta := TouchArea { mouse-cursor: pointer; clicked => { root.quick-open = false; root.edit-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Edit"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: pi-dup-ta.has-hover ? Theme.bg-hover : transparent; + pi-dup-ta := TouchArea { mouse-cursor: pointer; clicked => { root.duplicate-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E14D}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Duplicate"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: pi-del-ta.has-hover ? #cc333320 : transparent; + pi-del-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-quick-command(q.orig-index); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + Rectangle { height: 1px; background: Theme.border-subtle; } + HorizontalLayout { padding-left: 10px; padding-top: 2px; spacing: 6px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; } + Text { text: @tr("Move to"); color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; horizontal-stretch: 1; } } + if q.group != "default" : Rectangle { height: 28px; border-radius: Theme.radius-sm; + background: pi-md-ta.has-hover ? Theme.bg-hover : transparent; + pi-md-ta := TouchArea { mouse-cursor: pointer; clicked => { root.move-quick-command(q.orig-index, "default"); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: "default"; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } } } + for s in root.quick-commands : Rectangle { + visible: s.group-header != "" && s.group != "default" && s.group != q.group; + height: self.visible ? 28px : 0px; + border-radius: Theme.radius-sm; + background: pi-mv-ta.has-hover ? Theme.bg-hover : transparent; + pi-mv-ta := TouchArea { mouse-cursor: pointer; clicked => { root.move-quick-command(q.orig-index, s.group); } } + HorizontalLayout { padding-left: 10px; spacing: 8px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: s.group; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } } } + } + } + } + } + } + // Manage entry. + Rectangle { + height: 30px; + border-radius: Theme.radius-sm; + background: mng-ta.has-hover ? Theme.bg-hover : transparent; + mng-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.quick-open = false; root.manage-quick-commands(); } + } + Text { text: @tr("Manage…"); color: Theme.accent; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + } + + // --- History popup (opens upward) ------------------------------- + if root.history-open : Rectangle { + x: parent.width - 308px; + width: 300px; + height: min(296px, 46px + max(28px, min(228px, h-col.preferred-height + 8px))); + y: -self.height - 2px; + 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; + TouchArea {} + VerticalLayout { + padding: 4px; + spacing: 4px; + // Search box (#101): filters the list below. ↑/↓ recall in the + // input box still uses the full history. + Rectangle { + height: 30px; + border-radius: Theme.radius-sm; + background: Theme.bg-root; + border-width: 1px; + border-color: hist-search.has-focus ? Theme.accent : Theme.border-subtle; + HorizontalLayout { + padding-left: 8px; padding-right: 8px; spacing: 4px; + Text { text: "\u{E8B6}"; font-family: "Material Icons"; + color: Theme.text-muted; font-size: Theme.fs-sm; + vertical-alignment: center; } + Rectangle { + horizontal-stretch: 1; + hist-search := TextInput { + width: 100%; height: 100%; + vertical-alignment: center; + color: Theme.text-primary; + font-family: Theme.ui-font-family; + font-size: Theme.fs-sm; + single-line: true; + edited => { root.search-history(self.text); } + } + if hist-search.text == "" : Text { + text: @tr("Search history"); + color: Theme.text-muted; font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + } + } + Flickable { + viewport-width: self.width; + viewport-height: max(self.height, h-col.preferred-height); + h-col := VerticalLayout { + padding: 4px; + alignment: start; + if root.history-view.length == 0 : Text { + text: root.command-history.length == 0 ? @tr("No history yet") : @tr("No match"); + color: Theme.text-muted; font-size: Theme.fs-xs; + horizontal-alignment: center; + height: 32px; vertical-alignment: center; + } + for h in root.history-view : Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: h-item-ta.has-hover ? Theme.bg-hover : transparent; + // Row body click recalls the command into the input box. + h-item-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + cmd-input.text = h; + root.history-open = false; + cmd-input.focus(); + } + } + HorizontalLayout { + padding-left: 8px; padding-right: 4px; spacing: 1px; + Text { + text: h; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + font-family: Theme.ui-font-family; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + // Run (▶) — execute immediately, on this tab. + Rectangle { + width: 24px; y: (parent.height - self.height) / 2; height: 24px; + border-radius: Theme.radius-sm; + background: hrun.has-hover ? Theme.accent.with-alpha(0.25) : transparent; + hrun := TouchArea { + mouse-cursor: pointer; + clicked => { + root.run-command(h, root.send-to-all); + root.history-open = false; + } + } + Text { text: "\u{E037}"; font-family: "Material Icons"; + color: Theme.accent; font-size: Theme.fs-md; + horizontal-alignment: center; vertical-alignment: center; } + } + // Copy (⧉) — to clipboard, keep the popup open. + Rectangle { + width: 24px; y: (parent.height - self.height) / 2; height: 24px; + border-radius: Theme.radius-sm; + background: hcopy.has-hover ? Theme.bg-elevated : transparent; + hcopy := TouchArea { + mouse-cursor: pointer; + clicked => { root.copy-text(h); } + } + Text { text: "\u{E14D}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + // Delete (×) — remove this entry. + Rectangle { + width: 24px; y: (parent.height - self.height) / 2; height: 24px; + border-radius: Theme.radius-sm; + background: hdel.has-hover ? Theme.danger.with-alpha(0.18) : transparent; + hdel := TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-history-cmd(h); } + } + Text { text: "\u{E872}"; font-family: "Material Icons"; + color: Theme.danger; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + } + } + } + } + } + } + } + + // --- SFTP resize splitter (orientation follows the dock edge) ------- Rectangle { - height: 4px; + visible: !root.sftp-collapsed; // gone when collapsed (like the resource splitter) + x: root.sftp-dock == "left" ? parent.sf-w + : root.sftp-dock == "right" ? parent.width - parent.sf-w - 4px + : 0px; + y: root.sftp-dock == "top" ? parent.sf-h + : root.sftp-dock == "bottom" ? parent.height - parent.sf-h - 4px + : 0px; + width: root.sftp-horizontal ? 4px : parent.width; + height: root.sftp-horizontal ? parent.height : 4px; background: divider-ta.has-hover || root.sftp-dragging - ? Theme.accent - : Theme.border-subtle; - + ? Theme.accent : Theme.border-subtle; divider-ta := TouchArea { - mouse-cursor: row-resize; - + // Hit area extends a few px past the thin visual bar so it's easy + // to grab without landing on the terminal / SFTP edge. + x: root.sftp-horizontal ? -4px : 0px; + y: root.sftp-horizontal ? 0px : -4px; + width: root.sftp-horizontal ? 12px : parent.width; + height: root.sftp-horizontal ? parent.height : 12px; + mouse-cursor: root.sftp-horizontal ? ew-resize : ns-resize; pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { - root.sftp-dragging = true; - root.drag-start-mouse-y = self.mouse-y; + root.sftp-dragging = true; + root.sftp-collapsed = false; } else if (ev.kind == PointerEventKind.up) { root.sftp-dragging = false; } } - + // Direct tracking: the panel's docked edge follows the cursor's + // position within the (stable) dock-region — no deltas, so no + // moving-frame / accumulation issues. NOTE: `parent` here is the + // thin splitter Rectangle, so refer to `dock-region` explicitly + // for the region's size/origin (#dock). 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 - ); + if (root.sftp-horizontal) { + // Floor of 150px — by then Size+Modified columns have + // dropped and only Name remains; no narrower (#dock). + root.sftp-panel-width = clamp( + root.sftp-dock == "right" + ? dock-region.width - ((self.absolute-position.x + self.mouse-x) - dock-region.absolute-position.x) + : (self.absolute-position.x + self.mouse-x) - dock-region.absolute-position.x, + 150px, max(160px, dock-region.width - 200px)); + } else { + root.sftp-panel-height = clamp( + root.sftp-dock == "bottom" + ? dock-region.height - ((self.absolute-position.y + self.mouse-y) - dock-region.absolute-position.y) + : (self.absolute-position.y + self.mouse-y) - dock-region.absolute-position.y, + 60px, max(80px, dock-region.height - 80px)); + } } } } } - // --- SFTP panel ----------------------------------------------------- + // --- SFTP panel — positioned on the docked edge --------------------- SftpPanel { - height: root.sftp-panel-height; + x: root.sftp-dock == "right" ? parent.width - parent.sf-w : 0px; + y: root.sftp-dock == "bottom" ? parent.height - parent.sf-h : 0px; + width: root.sftp-horizontal ? parent.sf-w : parent.width; + height: root.sftp-horizontal ? parent.height : parent.sf-h; + show-tree: !root.sftp-horizontal; // hide the tree when narrow (vertical) + compact: root.sftp-collapsed && root.sftp-horizontal; // clean strip when side+collapsed + dock-drag-move(ax, ay) => { + root.sftp-panel-dragging = true; + root.sftp-drag-nx = (ax - dock-region.absolute-position.x) / dock-region.width; + root.sftp-drag-ny = (ay - dock-region.absolute-position.y) / dock-region.height; + } + dock-drag-end() => { + if (root.sftp-dock-target != "") { + root.sftp-dock = root.sftp-dock-target; + root.persist-sftp-dock(root.sftp-dock); + } + root.sftp-panel-dragging = false; + } + collapsed: root.sftp-collapsed; current-path: root.sftp-path; entries: root.sftp-entries; status: root.sftp-status; loading: root.sftp-loading; tree-nodes: root.sftp-tree-nodes; + selected-count: root.sftp-selected-count; + toggle-collapsed => { + if (root.sftp-collapsed) { + // Restore to the saved height. + root.sftp-panel-height = root.sftp-saved-height; + root.sftp-collapsed = false; + } else { + // Minimise: remember the height, shrink to the toolbar (30px). + root.sftp-saved-height = root.sftp-panel-height; + root.sftp-panel-height = 30px; + root.sftp-collapsed = true; + } + } navigate(path) => { root.sftp-navigate(path); } download(path) => { root.sftp-download(path); } - upload-clicked(dir) => { root.sftp-upload-clicked(dir); } + upload-clicked(dir, folder) => { root.sftp-upload-clicked(dir, folder); } refresh(path) => { root.sftp-refresh(path); } tree-expand(path) => { root.sftp-tree-expand(path); } delete(path) => { root.sftp-delete(path); } + toggle-select(i) => { root.sftp-toggle-select(i); } + download-selected() => { root.sftp-download-selected(); } + delete-selected() => { root.sftp-delete-selected(); } view(path) => { root.sftp-view(path); } edit(path) => { root.sftp-edit(path); } + open-external(path) => { root.sftp-open-external(path); } + edit-external(path) => { root.sftp-edit-external(path); } + rename-request(path, name) => { root.sftp-rename-request(path, name); } + chmod-request(path, name, mode) => { root.sftp-chmod-request(path, name, mode); } + copy-path(path) => { root.sftp-copy-path(path); } + new-folder(dir) => { root.sftp-new-folder(dir); } + new-file(dir) => { root.sftp-new-file(dir); } + } + + // Drag-to-dock snap overlay — four edge zones; the one under the cursor + // (sftp-dock-target) lights up. Purely visual (#dock). + if root.sftp-panel-dragging : Rectangle { + x: 0; y: 0; width: parent.width; height: parent.height; + Rectangle { + x: 0; y: 0; width: parent.width * 0.22; height: parent.height; + background: root.sftp-dock-target == "left" ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.sftp-dock-target == "left" ? 2px : 0px; border-color: Theme.accent; + } + Rectangle { + x: parent.width * 0.78; y: 0; width: parent.width * 0.22; height: parent.height; + background: root.sftp-dock-target == "right" ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.sftp-dock-target == "right" ? 2px : 0px; border-color: Theme.accent; + } + Rectangle { + x: 0; y: 0; width: parent.width; height: parent.height * 0.22; + background: root.sftp-dock-target == "top" ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.sftp-dock-target == "top" ? 2px : 0px; border-color: Theme.accent; + } + Rectangle { + x: 0; y: parent.height * 0.78; width: parent.width; height: parent.height * 0.22; + background: root.sftp-dock-target == "bottom" ? Theme.accent.with-alpha(0.35) : Theme.accent.with-alpha(0.10); + border-width: root.sftp-dock-target == "bottom" ? 2px : 0px; border-color: Theme.accent; + } + } + + // Expand button — the collapsed panel fully disappears, so float a clear + // expand button on whichever edge it was docked to (like the resource + // panel). Chevron points away from the edge, toward where it reopens. + if root.sftp-collapsed : Rectangle { + width: 24px; height: 24px; + x: root.sftp-dock == "right" ? parent.width - self.width - 2px + : root.sftp-dock == "top" || root.sftp-dock == "bottom" ? 6px + : 2px; + // Centre vertically on left/right edges (consistent with the resource + // panel); at the edge for top/bottom. + y: root.sftp-dock == "bottom" ? parent.height - self.height - 2px + : root.sftp-dock == "top" ? 2px + : (parent.height - self.height) / 2; + border-radius: Theme.radius-sm; + background: sftp-exp-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + border-width: 1px; border-color: Theme.border-subtle; + sftp-exp-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.sftp-panel-height = root.sftp-saved-height; + root.sftp-collapsed = false; + } + } + Text { + text: root.sftp-dock == "right" ? "\u{E5CB}" // chevron_left + : root.sftp-dock == "top" ? "\u{E5CF}" // expand_more + : root.sftp-dock == "bottom" ? "\u{E5CE}" // expand_less + : "\u{E5CC}"; // chevron_right (left dock) + font-family: "Material Icons"; font-size: 16px; color: Theme.text-secondary; + horizontal-alignment: center; vertical-alignment: center; + } + } } } } diff --git a/ui/theme.slint b/ui/theme.slint index e705010b..b2818b1d 100644 --- a/ui/theme.slint +++ b/ui/theme.slint @@ -1,49 +1,80 @@ // Central design tokens for meatshell. -// Keep these aligned with docs/UI guidelines. +// +// 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: #1b1d23; - out property bg-panel: #23262d; - out property bg-panel-alt: #2a2d35; - out property bg-elevated: #30333c; - out property bg-hover: #373a44; - out property bg-active: #3f4350; + 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 border-subtle: #3a3d46; - out property border-strong: #4a4e59; + out property text-primary: dark ? #e6e8ee : #1d1d1f; + out property text-secondary: dark ? #b4b9c4 : #6e6e73; + out property text-muted: dark ? #9196a3 : #aeaeb2; - out property text-primary: #e6e8ee; - out property text-secondary: #a8adb8; - out property text-muted: #6b6f7a; + out property accent: dark ? #4a90e2 : #0071e3; + out property accent-hover: dark ? #5aa0f2 : #0077ed; + out property accent-pressed: dark ? #3a80d2 : #006adb; - out property accent: #4a90e2; - out property accent-hover: #5aa0f2; - out property accent-pressed: #3a80d2; + out property success: dark ? #4ec9b0 : #34c759; + out property warning: dark ? #e2a84a : #ff9f0a; + out property danger: dark ? #e25c5c : #ff3b30; - out property success: #4ec9b0; - out property warning: #e2a84a; - out property danger: #e25c5c; + // 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; - out property term-bg: #0e0f13; - out property term-fg: #d4d4d4; + // Global UI scale (#100/#117/#118). Rust sets this from the saved setting; + // all font sizes and spacing/radii below multiply by it, so the whole UI + // grows/shrinks live (the terminal font has its own size setting). + in-out property ui-scale: 1.0; // --- 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 fs-xs: 11px * root.ui-scale; + out property fs-sm: 12px * root.ui-scale; + out property fs-md: 13px * root.ui-scale; + out property fs-lg: 15px * root.ui-scale; + out property fs-xl: 18px * root.ui-scale; + + // Embedded glyph-complete monospace (Cascadia Mono under a unique name, #114). + out property font-mono: "Meatshell Mono"; - out property font-mono: "Cascadia Mono"; + // UI (sans) font for editable inputs. The embedded mono font has no CJK + // glyphs and native TextInput doesn't glyph-fallback like Text does, so + // typing Chinese into a bare TextInput renders tofu. Rust sets this to a + // CJK-capable system font on startup (empty = platform default). + in-out property ui-font-family: ""; + + // Terminal font — user-configurable via Interface settings, set by Rust + // from the config on startup. cell size in terminal_view derives from these. + in-out property term-font-family: "Meatshell Mono"; + in-out property term-font-size: 13px; // --- 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; + out property radius-sm: 4px * root.ui-scale; + out property radius-md: 6px * root.ui-scale; + out property radius-lg: 10px * root.ui-scale; + + out property gap-xs: 4px * root.ui-scale; + out property gap-sm: 8px * root.ui-scale; + out property gap-md: 12px * root.ui-scale; + out property gap-lg: 16px * root.ui-scale; } diff --git a/ui/welcome.slint b/ui/welcome.slint index 99981da8..a45fec79 100644 --- a/ui/welcome.slint +++ b/ui/welcome.slint @@ -1,5 +1,6 @@ import { Theme } from "theme.slint"; import { IconButton, PrimaryButton, GhostButton } from "widgets.slint"; +import { ScrollView } from "std-widgets.slint"; export struct SessionInfo { id: string, @@ -9,6 +10,9 @@ export struct SessionInfo { user: string, auth: string, // "password" | "key" last-used: string, // ISO timestamp or "never" + group: string, // folder/group name ("" = ungrouped) + group-header: string, // set on the first row of each group → render a header + collapsed: bool, // group is collapsed → this session row is hidden } // --- SessionRow ------------------------------------------------------------ @@ -16,7 +20,12 @@ component SessionRow inherits Rectangle { in property session; callback connect(); callback edit(); + callback duplicate(); + callback move-to-group(string); callback remove(); + // Full session list — group-header is non-empty once per group, so the + // "move to group" submenu can iterate it to list each group exactly once. + in property <[SessionInfo]> all-sessions; height: 36px; border-radius: Theme.radius-sm; @@ -43,8 +52,9 @@ component SessionRow inherits Rectangle { ctx-menu := PopupWindow { x: ctx-x; y: ctx-y; - width: 120px; - height: 72px; + width: 140px; + // Height follows the content so a variable number of "move to" rows fit. + height: menu-vl.preferred-height; Rectangle { background: Theme.bg-panel; @@ -54,7 +64,7 @@ component SessionRow inherits Rectangle { drop-shadow-blur: 10px; drop-shadow-color: #00000050; - VerticalLayout { + menu-vl := VerticalLayout { padding: 4px; spacing: 2px; @@ -70,13 +80,27 @@ component SessionRow inherits Rectangle { HorizontalLayout { padding-left: 10px; spacing: 8px; - alignment: center; Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } - Text { text: "编辑"; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + 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; } + // Duplicate — clone this session as a starting point (#41). + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: dup-ta.has-hover ? Theme.bg-hover : transparent; + dup-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.duplicate(); } + } + HorizontalLayout { + padding-left: 10px; + spacing: 8px; + Text { text: "\u{E14D}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Duplicate"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } // Delete (red tint) Rectangle { @@ -90,9 +114,40 @@ component SessionRow inherits Rectangle { HorizontalLayout { padding-left: 10px; spacing: 8px; - alignment: center; Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } - Text { text: "删除"; color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // "Move to" hint — labels the group list below so the user knows + // these entries move the session into that folder. + HorizontalLayout { + padding-left: 10px; + padding-top: 2px; + spacing: 6px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; } + Text { text: @tr("Move to"); color: Theme.text-muted; font-size: Theme.fs-xs; vertical-alignment: center; horizontal-stretch: 1; } + } + + // Move to another group (#41). group-header is non-empty once + // per group (incl. empty folders), so iterating the full list + // yields each group once; the current group is skipped. + for s[i] in root.all-sessions : Rectangle { + visible: s.group-header != "" && s.group-header != root.session.group; + height: self.visible ? 28px : 0px; + border-radius: Theme.radius-sm; + background: mv-ta.has-hover ? Theme.bg-hover : transparent; + mv-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.move-to-group(s.group-header); } + } + HorizontalLayout { + padding-left: 10px; + spacing: 8px; + Text { text: "\u{E2C7}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: s.group-header; color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } } } } @@ -149,9 +204,17 @@ component SessionRow inherits Rectangle { // --- Welcome --------------------------------------------------------------- export component Welcome inherits Rectangle { in property <[SessionInfo]> sessions; + in property import-hint; // result text after an import callback new-session(); + callback import-ssh-config(); callback connect-session(string); callback edit-session(string); + callback duplicate-session(string); + callback move-session(string /* id */, string /* group */); + callback toggle-group(string /* group */); + callback new-group(); + callback edit-group(string /* current-name */); + callback delete-group(string /* name */); callback remove-session(string); background: Theme.bg-root; @@ -160,7 +223,12 @@ export component Welcome inherits Rectangle { padding: 24px; spacing: 20px; + // Header (title + tagline) — pinned to the top at its natural height so + // it can't grab the stretch space and spread apart (seen on macOS); the + // quick-connect card below takes all the remaining height (#dock). VerticalLayout { + vertical-stretch: 0; + alignment: start; spacing: 4px; Text { text: "meatshell"; @@ -169,14 +237,17 @@ export component Welcome inherits Rectangle { font-weight: 700; } Text { - text: "meatshell 一个由「一坨肉」开发的轻量 Rust + Slint SSH 客户端"; + text: @tr("meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'"); color: Theme.text-secondary; font-size: Theme.fs-md; + wrap: no-wrap; } } - // Quick Connect card + // Quick Connect card — fills the remaining height so its session list + // can scroll when there are more connections than fit (#116). Rectangle { + vertical-stretch: 1; background: Theme.bg-panel; border-radius: Theme.radius-md; border-width: 1px; @@ -187,16 +258,25 @@ export component Welcome inherits Rectangle { spacing: 12px; HorizontalLayout { + spacing: 8px; Text { - text: "快速连接"; + text: @tr("Quick connect"); 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; + } PrimaryButton { - text: "+ 新建会话"; + text: @tr("+ New session"); clicked => { root.new-session(); } } } @@ -212,13 +292,13 @@ export component Welcome inherits Rectangle { alignment: center; spacing: 6px; Text { - text: "尚无会话"; + text: @tr("No sessions yet"); color: Theme.text-secondary; font-size: Theme.fs-md; horizontal-alignment: center; } Text { - text: "点击右上角 “新建会话” 添加你的第一台服务器"; + text: @tr("Use 'New session' (top-right) to add your first server"); color: Theme.text-muted; font-size: Theme.fs-sm; horizontal-alignment: center; @@ -226,19 +306,134 @@ export component Welcome inherits Rectangle { } } - if sessions.length > 0 : VerticalLayout { + if sessions.length > 0 : ScrollView { + vertical-stretch: 1; + viewport-width: self.visible-width; + viewport-height: max(self.visible-height, sess-list.preferred-height); + sess-list := VerticalLayout { spacing: 2px; - for session[i] in sessions : SessionRow { - session: session; - connect => { root.connect-session(session.id); } - edit => { root.edit-session(session.id); } - remove => { root.remove-session(session.id); } + for session[i] in sessions : VerticalLayout { + spacing: 2px; + // Group header on the first row of each group (#41). + // Click anywhere on it to collapse / expand the group. + if session.group-header != "" : Rectangle { + height: 26px; + // Right-click popup anchor. + property gmx; + property gmy; + HorizontalLayout { + padding-top: 6px; + padding-left: 4px; + spacing: 4px; + // Collapse chevron — shown for every group, empty + // folders included, so they line up and can be + // toggled (an empty group just expands to nothing). + Text { + text: session.collapsed ? "\u{E5CC}" : "\u{E5CF}"; + font-family: "Material Icons"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + text: "\u{E2C7}"; // folder glyph + font-family: "Material Icons"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + text: session.group-header; + color: Theme.text-muted; + font-size: Theme.fs-xs; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; // fill → text stays left + horizontal-alignment: left; + } + } + TouchArea { + mouse-cursor: pointer; + // Left-click toggles collapse (any group, incl. + // empty); right-click opens the group menu. + clicked => { root.toggle-group(session.group); } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + gmx = self.mouse-x; + gmy = self.mouse-y; + grp-menu.show(); + } + } + } + grp-menu := PopupWindow { + x: gmx; + y: gmy; + width: 140px; + // edit + new always; delete only for an empty group. + height: (session.id == "" ? 3 : 2) * 28px + 9px; + 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: 1px; + // Edit (rename) group + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: eg-ta.has-hover ? Theme.bg-hover : transparent; + eg-ta := TouchArea { mouse-cursor: pointer; clicked => { root.edit-group(session.group); } } + HorizontalLayout { + padding-left: 10px; spacing: 8px; + Text { text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Edit group"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + // Delete group — only when it has no sessions (id == "") + if session.id == "" : Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: dg-ta.has-hover ? #cc333320 : transparent; + dg-ta := TouchArea { mouse-cursor: pointer; clicked => { root.delete-group(session.group); } } + HorizontalLayout { + padding-left: 10px; spacing: 8px; + Text { text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("Delete group"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + // New group + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: ng-ta.has-hover ? Theme.bg-hover : transparent; + ng-ta := TouchArea { mouse-cursor: pointer; clicked => { root.new-group(); } } + HorizontalLayout { + padding-left: 10px; spacing: 8px; + Text { text: "\u{E2CC}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; vertical-alignment: center; } + Text { text: @tr("New group"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + } + } + } + } + if session.id != "" && !session.collapsed : SessionRow { + session: session; + all-sessions: root.sessions; + connect => { root.connect-session(session.id); } + edit => { root.edit-session(session.id); } + duplicate => { root.duplicate-session(session.id); } + move-to-group(g) => { root.move-session(session.id, g); } + remove => { root.remove-session(session.id); } + } + } } } } } - - // Filler - Rectangle { vertical-stretch: 1; } } } diff --git a/ui/widgets.slint b/ui/widgets.slint index 872c9bbc..c32e08f0 100644 --- a/ui/widgets.slint +++ b/ui/widgets.slint @@ -126,6 +126,10 @@ export component LabeledInput inherits VerticalLayout { height: parent.height; text <=> root.value; color: Theme.text-primary; + // A CJK-capable family — TextInput doesn't glyph-fallback reliably + // across machines, so typed Chinese (incl. punctuation like 、) can + // render as tofu without an explicit font (#54). + font-family: Theme.ui-font-family; font-size: Theme.fs-md; vertical-alignment: center; single-line: true; diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 00000000..430a391d --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +