Merge upstream main into PR #521

This commit is contained in:
Kang
2026-09-02 00:49:05 +08:00
118 changed files with 10382 additions and 2984 deletions
+1 -13
View File
@@ -87,19 +87,7 @@ runs:
- name: Enable Win7-only Cargo patches
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path ".cargo" | Out-Null
@'
[patch.crates-io]
webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" }
windows-core = { path = "src-tauri/vendor/windows-core" }
'@ | Set-Content -LiteralPath ".cargo/config.toml" -Encoding UTF8
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 | Out-Null
Write-Host "Enabled Win7-only Cargo patches:"
Get-Content -LiteralPath ".cargo/config.toml"
run: ./.github/scripts/enable-win7-cargo-patches.ps1
- name: Print build metadata
shell: pwsh
@@ -0,0 +1,89 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$knownCtorVersion = "0.8.0"
$knownCtorMacrosSha256 = "86ec55f4670e68dbd0fb6f400be0374ba14ac9b621ba041561de7dc629e22fcc"
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$tauriManifest = Join-Path $repositoryRoot "src-tauri\Cargo.toml"
$cargoConfigDirectory = Join-Path $repositoryRoot ".cargo"
$cargoConfigPath = Join-Path $cargoConfigDirectory "config.toml"
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nyaterm-win7-cargo-patches-$([Guid]::NewGuid())"
$patchedCtorRoot = Join-Path $temporaryRoot "ctor-$knownCtorVersion"
Push-Location $repositoryRoot
try {
$metadataJson = & cargo metadata --manifest-path $tauriManifest --locked --format-version 1
if ($LASTEXITCODE -ne 0) {
throw "cargo metadata failed while locating ctor $knownCtorVersion."
}
$metadata = $metadataJson | ConvertFrom-Json
$ctorPackages = @($metadata.packages | Where-Object {
$_.name -eq "ctor" -and $_.version -eq $knownCtorVersion
})
if ($ctorPackages.Count -ne 1) {
throw "Expected exactly one ctor $knownCtorVersion package, found $($ctorPackages.Count)."
}
$ctorRoot = Split-Path -Parent $ctorPackages[0].manifest_path
$ctorMacrosPath = Join-Path $ctorRoot "src\macros\mod.rs"
if (!(Test-Path -LiteralPath $ctorMacrosPath -PathType Leaf)) {
throw "ctor macros file does not exist: $ctorMacrosPath"
}
$actualCtorMacrosSha256 = (Get-FileHash -LiteralPath $ctorMacrosPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualCtorMacrosSha256 -ne $knownCtorMacrosSha256) {
throw "Unexpected ctor $knownCtorVersion macros SHA256. Expected $knownCtorMacrosSha256, got $actualCtorMacrosSha256."
}
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
Copy-Item -LiteralPath $ctorRoot -Destination $patchedCtorRoot -Recurse -Force
$patchedMacrosPath = Join-Path $patchedCtorRoot "src\macros\mod.rs"
$patchedMacros = Get-Content -LiteralPath $patchedMacrosPath -Raw
$unsupportedVendorCondition = 'target_vendor = "pc"'
$replacementCount = ([regex]::Matches($patchedMacros, [regex]::Escape($unsupportedVendorCondition))).Count
if ($replacementCount -ne 3) {
throw "Expected three Windows vendor checks in ctor $knownCtorVersion, found $replacementCount."
}
# The built-in Win7 targets use target_vendor="win7" but retain the normal
# Windows MSVC CRT constructor sections. This is the same compatibility fix
# released upstream in ctor 1.0.4, kept on 0.8.0 for Tauri's version range.
$patchedMacros = $patchedMacros.Replace($unsupportedVendorCondition, 'target_os = "windows"')
Set-Content -LiteralPath $patchedMacrosPath -Value $patchedMacros -Encoding UTF8 -NoNewline
$cargoCtorPath = $patchedCtorRoot.Replace("\", "/").Replace("'", "''")
New-Item -ItemType Directory -Path $cargoConfigDirectory -Force | Out-Null
@"
[patch.crates-io]
ctor = { path = '$cargoCtorPath' }
webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" }
windows-core = { path = "src-tauri/vendor/windows-core" }
"@ | Set-Content -LiteralPath $cargoConfigPath -Encoding UTF8
$patchedMetadataJson = & cargo metadata --manifest-path $tauriManifest --format-version 1
if ($LASTEXITCODE -ne 0) {
throw "cargo metadata failed after enabling the Windows 7 Cargo patches."
}
$patchedMetadata = $patchedMetadataJson | ConvertFrom-Json
$activeCtorPackages = @($patchedMetadata.packages | Where-Object {
$_.name -eq "ctor" -and $_.version -eq $knownCtorVersion -and
(Split-Path -Parent $_.manifest_path) -eq $patchedCtorRoot
})
if ($activeCtorPackages.Count -ne 1) {
throw "The patched ctor $knownCtorVersion package was not selected by Cargo."
}
Write-Host "Enabled Win7-only Cargo patches:"
Get-Content -LiteralPath $cargoConfigPath
Write-Host "Patched ctor source: $patchedCtorRoot"
}
finally {
Pop-Location
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "nyaterm",
"private": true,
"version": "1.2.5",
"version": "1.2.6",
"description": "A modern, high-performance SSH client built with Tauri and React.",
"author": "NyaKang",
"homepage": "https://nyaterm.app",
@@ -67,9 +67,9 @@
"@lezer/highlight": "^1.2.3",
"@mdxeditor/editor": "^4.2.0",
"@tanstack/react-virtual": "^3.14.6",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "~2.10.1",
"@types/papaparse": "^5.5.2",
@@ -107,7 +107,7 @@
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
+69 -69
View File
@@ -93,14 +93,14 @@ importers:
specifier: ^3.14.6
version: 3.14.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
specifier: ^2.11.1
version: 2.11.1
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
specifier: ^2.7.2
version: 2.7.2
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
specifier: ^2.5.4
version: 2.5.4
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
@@ -208,8 +208,8 @@ importers:
specifier: ^4.1.18
version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tauri-apps/cli':
specifier: ^2
version: 2.10.0
specifier: ^2.11.4
version: 2.11.4
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4222,90 +4222,90 @@ packages:
'@tanstack/virtual-core@3.17.4':
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.11.1':
resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
'@tauri-apps/cli-darwin-arm64@2.10.0':
resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==}
'@tauri-apps/cli-darwin-arm64@2.11.4':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.10.0':
resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==}
'@tauri-apps/cli-darwin-x64@2.11.4':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.0':
resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.10.0':
resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==}
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
'@tauri-apps/cli-linux-x64-musl@2.11.4':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.10.0':
resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==}
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.10.0':
resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==}
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.10.0':
resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==}
'@tauri-apps/cli@2.11.4':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-dialog@2.7.2':
resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==}
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-opener@2.5.4':
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
@@ -11490,7 +11490,7 @@ snapshots:
'@types/react-router-config': 5.0.11
combine-promises: 1.2.0
fs-extra: 11.3.3
js-yaml: 4.1.1
js-yaml: 4.3.0
lodash: 4.18.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -14480,70 +14480,70 @@ snapshots:
'@tanstack/virtual-core@3.17.4': {}
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.11.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.0':
'@tauri-apps/cli-darwin-arm64@2.11.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.10.0':
'@tauri-apps/cli-darwin-x64@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.0':
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.10.0':
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.10.0':
'@tauri-apps/cli-linux-x64-musl@2.11.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.10.0':
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.10.0':
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
optional: true
'@tauri-apps/cli@2.10.0':
'@tauri-apps/cli@2.11.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.10.0
'@tauri-apps/cli-darwin-x64': 2.10.0
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0
'@tauri-apps/cli-linux-arm64-gnu': 2.10.0
'@tauri-apps/cli-linux-arm64-musl': 2.10.0
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.0
'@tauri-apps/cli-linux-x64-gnu': 2.10.0
'@tauri-apps/cli-linux-x64-musl': 2.10.0
'@tauri-apps/cli-win32-arm64-msvc': 2.10.0
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
'@tauri-apps/plugin-dialog@2.6.0':
'@tauri-apps/plugin-dialog@2.7.2':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-opener@2.5.3':
'@tauri-apps/plugin-opener@2.5.4':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.11.1
'@testing-library/dom@10.4.1':
dependencies:
+1215 -1490
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nyaterm"
version = "1.2.5"
version = "1.2.6"
description = "A modern remote terminal workspace built with Tauri, React, and Rust."
authors = ["Kang"]
edition = "2024"
@@ -115,6 +115,7 @@ time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
tracing-appender = "0.2"
nucleo-matcher = "0.3"
dirs = "6.0.0"
whoami = "1"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
@@ -213,6 +214,8 @@ webview2-com = "0.38.2"
window-vibrancy = "0.6"
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
@@ -10,6 +10,8 @@ pub const MAX_TEXT_WRITE_BYTES: usize = 1024 * 1024;
pub mod capability {
pub const ENVIRONMENT: &str = "session.environment";
pub const CONNECTION_LIST: &str = "connection.list";
pub const SESSION_OPEN: &str = "session.open";
pub const SESSION_GET: &str = "session.get";
pub const TERMINAL_EXECUTE: &str = "terminal.execute";
pub const TERMINAL_RECENT_OUTPUT: &str = "terminal.recent_output";
@@ -27,6 +29,8 @@ pub mod capability {
pub mod tool {
pub const GET_ENVIRONMENT: &str = "get_environment";
pub const CONNECTION_LIST: &str = "connection_list";
pub const SESSION_OPEN: &str = "session_open";
pub const SESSION_GET: &str = "session_get";
pub const TERMINAL_EXECUTE: &str = "terminal_execute";
pub const TERMINAL_RECENT_OUTPUT: &str = "terminal_recent_output";
@@ -42,6 +46,201 @@ pub mod tool {
pub const OUTPUT_READ: &str = "tool_output_read";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilityAccess {
Read,
SensitiveRead,
Write,
DestructiveWrite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct McpToolDefinition {
pub tool: &'static str,
pub capability: &'static str,
pub description: &'static str,
pub access: CapabilityAccess,
pub requires_session: bool,
pub read_only_hint: bool,
pub destructive_hint: bool,
pub open_world_hint: bool,
}
pub const MCP_TOOL_REGISTRY: &[McpToolDefinition] = &[
McpToolDefinition {
tool: tool::GET_ENVIRONMENT,
capability: capability::ENVIRONMENT,
description: "Return scoped NyaTerm sessions and the optional active and default sessions.",
access: CapabilityAccess::Read,
requires_session: false,
read_only_hint: true,
destructive_hint: false,
open_world_hint: false,
},
McpToolDefinition {
tool: tool::CONNECTION_LIST,
capability: capability::CONNECTION_LIST,
description: "List saved terminal connections using safe metadata only.",
access: CapabilityAccess::Read,
requires_session: false,
read_only_hint: true,
destructive_hint: false,
open_world_hint: false,
},
McpToolDefinition {
tool: tool::SESSION_OPEN,
capability: capability::SESSION_OPEN,
description: "Open a new NyaTerm terminal session from a saved connection.",
access: CapabilityAccess::Write,
requires_session: false,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SESSION_GET,
capability: capability::SESSION_GET,
description: "Return safe metadata and capability availability for a scoped session.",
access: CapabilityAccess::Read,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: false,
},
McpToolDefinition {
tool: tool::TERMINAL_EXECUTE,
capability: capability::TERMINAL_EXECUTE,
description: "Execute a command in an existing scoped NyaTerm terminal session.",
access: CapabilityAccess::Write,
requires_session: true,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::TERMINAL_RECENT_OUTPUT,
capability: capability::TERMINAL_RECENT_OUTPUT,
description: "Read recent ANSI-free terminal output for a scoped session.",
access: CapabilityAccess::SensitiveRead,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_HOME,
capability: capability::SFTP_HOME,
description: "Return the remote home directory.",
access: CapabilityAccess::SensitiveRead,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_LIST,
capability: capability::SFTP_LIST,
description: "List a remote directory.",
access: CapabilityAccess::SensitiveRead,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_STAT,
capability: capability::SFTP_STAT,
description: "Read remote path metadata.",
access: CapabilityAccess::SensitiveRead,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_READ_TEXT,
capability: capability::SFTP_READ,
description: "Read up to 64 KiB of a remote UTF-8 text file.",
access: CapabilityAccess::SensitiveRead,
requires_session: true,
read_only_hint: true,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_WRITE_TEXT,
capability: capability::SFTP_WRITE,
description: "Write a remote UTF-8 text file with optional conflict protection.",
access: CapabilityAccess::Write,
requires_session: true,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_MKDIR,
capability: capability::SFTP_MKDIR,
description: "Create a remote directory.",
access: CapabilityAccess::Write,
requires_session: true,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_RENAME,
capability: capability::SFTP_RENAME,
description: "Rename or move a remote path.",
access: CapabilityAccess::Write,
requires_session: true,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_DELETE,
capability: capability::SFTP_DELETE,
description: "Delete a remote path using NyaTerm's existing delete semantics.",
access: CapabilityAccess::DestructiveWrite,
requires_session: true,
read_only_hint: false,
destructive_hint: true,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::SFTP_CHMOD,
capability: capability::SFTP_CHMOD,
description: "Change remote path permissions.",
access: CapabilityAccess::Write,
requires_session: true,
read_only_hint: false,
destructive_hint: false,
open_world_hint: true,
},
McpToolDefinition {
tool: tool::OUTPUT_READ,
capability: capability::OUTPUT_READ,
description: "Read another chunk of a large result produced on this MCP connection.",
access: CapabilityAccess::SensitiveRead,
requires_session: false,
read_only_hint: true,
destructive_hint: false,
open_world_hint: false,
},
];
pub fn definition_for_tool(name: &str) -> Option<&'static McpToolDefinition> {
MCP_TOOL_REGISTRY
.iter()
.find(|definition| definition.tool == name)
}
pub fn definition_for_capability(id: &str) -> Option<&'static McpToolDefinition> {
MCP_TOOL_REGISTRY
.iter()
.find(|definition| definition.capability == id)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveryDocument {
@@ -112,6 +311,12 @@ pub struct SessionArgs {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SessionOpenArgs {
pub connection_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PathArgs {
@@ -195,3 +400,43 @@ pub struct OutputReadArgs {
#[serde(default)]
pub max_bytes: Option<usize>,
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
#[test]
fn registry_is_unique_and_annotations_match_access() {
let mut tools = HashSet::new();
let mut capabilities = HashSet::new();
for definition in MCP_TOOL_REGISTRY {
assert!(tools.insert(definition.tool), "duplicate tool: {}", definition.tool);
assert!(
capabilities.insert(definition.capability),
"duplicate capability: {}",
definition.capability
);
assert_eq!(
definition.read_only_hint,
matches!(
definition.access,
CapabilityAccess::Read | CapabilityAccess::SensitiveRead
)
);
assert_eq!(
definition.destructive_hint,
definition.access == CapabilityAccess::DestructiveWrite
);
if definition.access == CapabilityAccess::DestructiveWrite {
assert!(definition.destructive_hint);
}
assert_eq!(definition_for_tool(definition.tool), Some(definition));
assert_eq!(
definition_for_capability(definition.capability),
Some(definition)
);
}
}
}
+1 -1
View File
@@ -366,7 +366,7 @@ dependencies = [
[[package]]
name = "nyaterm-mcp"
version = "1.2.5"
version = "1.2.6"
dependencies = [
"dirs",
"nyaterm-mcp-protocol",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nyaterm-mcp"
version = "1.2.5"
version = "1.2.6"
edition = "2024"
publish = false
+314 -76
View File
@@ -41,6 +41,7 @@ struct Connection {
pub struct BridgeClient {
endpoint: BridgeEndpoint,
connection: Arc<Mutex<Option<Connection>>>,
identity: Arc<Mutex<Option<ClientIdentifyParams>>>,
}
impl BridgeClient {
@@ -48,12 +49,14 @@ impl BridgeClient {
Self {
endpoint,
connection: Arc::new(Mutex::new(None)),
identity: Arc::new(Mutex::new(None)),
}
}
pub async fn identify(&self, name: String, version: Option<String>) {
let params =
serde_json::to_value(ClientIdentifyParams { name, version }).unwrap_or_default();
let identity = ClientIdentifyParams { name, version };
*self.identity.lock().await = Some(identity.clone());
let params = serde_json::to_value(identity).unwrap_or_default();
let _ = self.rpc("client.identify", params).await;
}
@@ -71,70 +74,104 @@ impl BridgeClient {
})
.map_err(|error| bridge_error("invalid_argument", &error.to_string()))?;
let mut guard = self.connection.lock().await;
if guard.is_none() {
*guard = Some(connect(&self.endpoint).await.map_err(io_error)?);
}
self.ensure_connection(&mut guard, true).await?;
let connection = guard.as_mut().unwrap();
let id = connection.next_id;
connection.next_id += 1;
write_request(
connection,
RpcRequest {
id,
method: "capability.execute".into(),
params,
},
)
.await
.map_err(io_error)?;
let response = tokio::select! {
let result = tokio::select! {
_ = cancellation.cancelled() => {
let endpoint = self.endpoint.clone();
tokio::spawn(async move { let _ = cancel_request(&endpoint, &request_id).await; });
*guard = None;
return Err(bridge_error("cancelled", "The MCP tool call was cancelled."));
}
response = read_response(connection) => response.map_err(io_error)?,
result = connection_rpc(connection, "capability.execute", params) => result,
};
if response.id != id {
*guard = None;
return Err(bridge_error(
"bridge_disconnected",
"MCP bridge response ID mismatch.",
));
}
match (response.result, response.error) {
(Some(value), None) => Ok(value),
(_, Some(error)) => Err(error),
_ => Err(bridge_error(
"bridge_disconnected",
"MCP bridge returned an empty response.",
)),
}
finish_rpc(&mut guard, result)
}
async fn rpc(&self, method: &str, params: Value) -> Result<Value, RpcError> {
let mut guard = self.connection.lock().await;
if guard.is_none() {
*guard = Some(connect(&self.endpoint).await.map_err(io_error)?);
}
self.ensure_connection(&mut guard, method != "client.identify")
.await?;
let connection = guard.as_mut().unwrap();
let id = connection.next_id;
connection.next_id += 1;
write_request(
connection,
RpcRequest {
id,
method: method.into(),
params,
},
)
let result = connection_rpc(connection, method, params).await;
finish_rpc(&mut guard, result)
}
async fn ensure_connection(
&self,
guard: &mut Option<Connection>,
replay_identity: bool,
) -> Result<(), RpcError> {
if guard.is_some() {
return Ok(());
}
let mut connection = connect(&self.endpoint).await.map_err(io_error)?;
if replay_identity && let Some(identity) = self.identity.lock().await.clone() {
let params = serde_json::to_value(identity)
.map_err(|error| bridge_error("invalid_argument", &error.to_string()))?;
match connection_rpc(&mut connection, "client.identify", params).await {
Ok(_) => {}
Err(ConnectionRpcError::Remote(error)) => return Err(error),
Err(ConnectionRpcError::Disconnected(error)) => return Err(error),
}
}
*guard = Some(connection);
Ok(())
}
}
enum ConnectionRpcError {
Remote(RpcError),
Disconnected(RpcError),
}
fn finish_rpc(
guard: &mut Option<Connection>,
result: Result<Value, ConnectionRpcError>,
) -> Result<Value, RpcError> {
match result {
Ok(value) => Ok(value),
Err(ConnectionRpcError::Remote(error)) => Err(error),
Err(ConnectionRpcError::Disconnected(error)) => {
*guard = None;
Err(error)
}
}
}
async fn connection_rpc(
connection: &mut Connection,
method: &str,
params: Value,
) -> Result<Value, ConnectionRpcError> {
let id = connection.next_id;
connection.next_id += 1;
write_request(
connection,
RpcRequest {
id,
method: method.into(),
params,
},
)
.await
.map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?;
let response = read_response(connection)
.await
.map_err(io_error)?;
let response = read_response(connection).await.map_err(io_error)?;
response
.error
.map_or_else(|| Ok(response.result.unwrap_or(Value::Null)), Err)
.map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?;
if response.id != id {
return Err(ConnectionRpcError::Disconnected(bridge_error(
"bridge_disconnected",
"MCP bridge response ID mismatch.",
)));
}
match (response.result, response.error) {
(Some(value), None) => Ok(value),
(_, Some(error)) => Err(ConnectionRpcError::Remote(error)),
_ => Err(ConnectionRpcError::Disconnected(bridge_error(
"bridge_disconnected",
"MCP bridge returned an empty response.",
))),
}
}
@@ -144,43 +181,36 @@ async fn connect(endpoint: &BridgeEndpoint) -> std::io::Result<Connection> {
let mut connection = Connection {
reader: BufReader::new(read),
writer: write,
next_id: 2,
next_id: 1,
};
let params = serde_json::to_value(AuthParams {
token: endpoint.token.clone(),
generation: endpoint.generation.clone(),
})
.map_err(std::io::Error::other)?;
write_request(
&mut connection,
RpcRequest {
id: 1,
method: "auth".into(),
params,
},
)
.await?;
if read_response(&mut connection).await?.error.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"NyaTerm MCP authentication failed",
));
}
connection_rpc(&mut connection, "auth", params)
.await
.map_err(|error| match error {
ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, error.message)
}
})?;
Ok(connection)
}
async fn cancel_request(endpoint: &BridgeEndpoint, request_id: &str) -> std::io::Result<()> {
let mut connection = connect(endpoint).await?;
write_request(
connection_rpc(
&mut connection,
RpcRequest {
id: 2,
method: "request.cancel".into(),
params: json!({ "requestId": request_id }),
},
"request.cancel",
json!({ "requestId": request_id }),
)
.await?;
let _ = read_response(&mut connection).await?;
.await
.map_err(|error| match error {
ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => {
std::io::Error::other(error.message)
}
})?;
Ok(())
}
@@ -268,3 +298,211 @@ fn bridge_error(code: &str, message: &str) -> RpcError {
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
use super::*;
async fn request(
lines: &mut tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>,
) -> RpcRequest {
serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap()
}
async fn respond(writer: &mut tokio::net::tcp::OwnedWriteHalf, id: u64, result: Value) {
let mut bytes = serde_json::to_vec(&RpcResponse {
id,
result: Some(result),
error: None,
})
.unwrap();
bytes.push(b'\n');
writer.write_all(&bytes).await.unwrap();
}
async fn authenticate(
stream: TcpStream,
auth_count: &AtomicUsize,
) -> (
tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>,
tokio::net::tcp::OwnedWriteHalf,
) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
let auth = request(&mut lines).await;
assert_eq!(auth.method, "auth");
assert_eq!(auth.params["token"], "test-token");
auth_count.fetch_add(1, Ordering::SeqCst);
respond(&mut writer, auth.id, json!({ "authenticated": true })).await;
(lines, writer)
}
#[tokio::test]
async fn disconnect_invalidates_and_next_call_reauthenticates_and_identifies() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let auth_count = Arc::new(AtomicUsize::new(0));
let server_count = auth_count.clone();
let server = tokio::spawn(async move {
let (first, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(first, &server_count).await;
let identify = request(&mut lines).await;
assert_eq!(identify.method, "client.identify");
respond(&mut writer, identify.id, json!({ "identified": true })).await;
let call = request(&mut lines).await;
assert_eq!(call.method, "capability.execute");
respond(&mut writer, call.id, json!({ "value": "first" })).await;
drop(writer);
let (second, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(second, &server_count).await;
let identify = request(&mut lines).await;
assert_eq!(identify.method, "client.identify");
respond(&mut writer, identify.id, json!({ "identified": true })).await;
let call = request(&mut lines).await;
respond(&mut writer, call.id, json!({ "value": "recovered" })).await;
});
let client = BridgeClient::new(BridgeEndpoint::for_test(port));
client
.identify("bridge-test".into(), Some("1.0".into()))
.await;
let first = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap();
assert_eq!(first["value"], "first");
let disconnected = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap_err();
assert_eq!(disconnected.code, "bridge_disconnected");
let recovered = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap();
assert_eq!(recovered["value"], "recovered");
server.await.unwrap();
assert_eq!(auth_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn response_id_mismatch_invalidates_the_connection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let auth_count = Arc::new(AtomicUsize::new(0));
let server_count = auth_count.clone();
let server = tokio::spawn(async move {
let (first, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(first, &server_count).await;
let call = request(&mut lines).await;
respond(&mut writer, call.id + 1, json!({ "wrong": true })).await;
drop(writer);
let (second, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(second, &server_count).await;
let call = request(&mut lines).await;
respond(&mut writer, call.id, json!({ "recovered": true })).await;
});
let client = BridgeClient::new(BridgeEndpoint::for_test(port));
let mismatch = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap_err();
assert_eq!(mismatch.code, "bridge_disconnected");
let recovered = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap();
assert_eq!(recovered["recovered"], true);
server.await.unwrap();
assert_eq!(auth_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn invalid_response_invalidates_the_connection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let auth_count = Arc::new(AtomicUsize::new(0));
let server_count = auth_count.clone();
let server = tokio::spawn(async move {
let (first, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(first, &server_count).await;
let _ = request(&mut lines).await;
writer.write_all(b"not-json\n").await.unwrap();
drop(writer);
let (second, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(second, &server_count).await;
let call = request(&mut lines).await;
respond(&mut writer, call.id, json!({ "recovered": true })).await;
});
let client = BridgeClient::new(BridgeEndpoint::for_test(port));
let invalid = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap_err();
assert_eq!(invalid.code, "bridge_disconnected");
assert!(
client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap()["recovered"]
.as_bool()
.unwrap()
);
server.await.unwrap();
assert_eq!(auth_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn empty_response_invalidates_the_connection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let auth_count = Arc::new(AtomicUsize::new(0));
let server_count = auth_count.clone();
let server = tokio::spawn(async move {
let (first, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(first, &server_count).await;
let call = request(&mut lines).await;
let mut bytes = serde_json::to_vec(&RpcResponse {
id: call.id,
result: None,
error: None,
})
.unwrap();
bytes.push(b'\n');
writer.write_all(&bytes).await.unwrap();
drop(writer);
let (second, _) = listener.accept().await.unwrap();
let (mut lines, mut writer) = authenticate(second, &server_count).await;
let call = request(&mut lines).await;
respond(&mut writer, call.id, json!({ "recovered": true })).await;
});
let client = BridgeClient::new(BridgeEndpoint::for_test(port));
let empty = client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap_err();
assert_eq!(empty.code, "bridge_disconnected");
assert_eq!(
client
.call("get_environment", json!({}), CancellationToken::new())
.await
.unwrap()["recovered"],
true
);
server.await.unwrap();
assert_eq!(auth_count.load(Ordering::SeqCst), 2);
}
}
+148 -91
View File
@@ -4,9 +4,9 @@ use std::sync::Arc;
use bridge::{BridgeClient, BridgeEndpoint, endpoint_from_environment_or_discovery};
use nyaterm_mcp_protocol::{
EmptyArgs, OutputReadArgs, PathArgs, SessionArgs, SftpChmodArgs, SftpMkdirArgs,
SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, TerminalExecuteArgs,
TerminalRecentOutputArgs, tool,
EmptyArgs, MCP_TOOL_REGISTRY, McpToolDefinition, OutputReadArgs, PathArgs, SessionArgs,
SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs,
SessionOpenArgs, TerminalExecuteArgs, TerminalRecentOutputArgs, tool,
};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListToolsResult,
@@ -39,7 +39,7 @@ impl ServerHandler for NyaTermMcp {
"nyaterm-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions("Operate sessions already opened in NyaTerm. NyaTerm enforces session scope and approvals.")
.with_instructions("Discover saved terminal connections, open sessions in NyaTerm, and operate scoped sessions. NyaTerm enforces session scope and approvals.")
}
async fn call_tool(
@@ -89,94 +89,39 @@ impl ServerHandler for NyaTermMcp {
}
fn build_tools() -> Vec<Tool> {
vec![
tool_def::<EmptyArgs>(
tool::GET_ENVIRONMENT,
"Return scoped NyaTerm sessions and the optional default session.",
true,
false,
),
tool_def::<SessionArgs>(
tool::SESSION_GET,
"Return safe metadata and capability availability for a scoped session.",
true,
false,
),
tool_def::<TerminalExecuteArgs>(
tool::TERMINAL_EXECUTE,
"Execute a command in an existing scoped NyaTerm terminal session.",
false,
false,
),
tool_def::<TerminalRecentOutputArgs>(
tool::TERMINAL_RECENT_OUTPUT,
"Read recent ANSI-free terminal output for a scoped session.",
true,
false,
),
tool_def::<SessionArgs>(
tool::SFTP_HOME,
"Return the remote home directory.",
true,
false,
),
tool_def::<PathArgs>(tool::SFTP_LIST, "List a remote directory.", true, false),
tool_def::<PathArgs>(tool::SFTP_STAT, "Read remote path metadata.", true, false),
tool_def::<SftpReadTextArgs>(
tool::SFTP_READ_TEXT,
"Read up to 64 KiB of a remote UTF-8 text file.",
true,
false,
),
tool_def::<SftpWriteTextArgs>(
tool::SFTP_WRITE_TEXT,
"Write a remote UTF-8 text file with optional conflict protection.",
false,
false,
),
tool_def::<SftpMkdirArgs>(tool::SFTP_MKDIR, "Create a remote directory.", false, false),
tool_def::<SftpRenameArgs>(
tool::SFTP_RENAME,
"Rename or move a remote path.",
false,
false,
),
tool_def::<PathArgs>(
tool::SFTP_DELETE,
"Delete a remote path using NyaTerm's existing delete semantics.",
false,
true,
),
tool_def::<SftpChmodArgs>(
tool::SFTP_CHMOD,
"Change remote path permissions.",
false,
false,
),
tool_def::<OutputReadArgs>(
tool::OUTPUT_READ,
"Read another chunk of a large result produced on this MCP connection.",
true,
false,
),
]
MCP_TOOL_REGISTRY
.iter()
.map(|definition| match definition.tool {
tool::GET_ENVIRONMENT => tool_def::<EmptyArgs>(definition),
tool::CONNECTION_LIST => tool_def::<EmptyArgs>(definition),
tool::SESSION_OPEN => tool_def::<SessionOpenArgs>(definition),
tool::SESSION_GET | tool::SFTP_HOME => tool_def::<SessionArgs>(definition),
tool::TERMINAL_EXECUTE => tool_def::<TerminalExecuteArgs>(definition),
tool::TERMINAL_RECENT_OUTPUT => tool_def::<TerminalRecentOutputArgs>(definition),
tool::SFTP_LIST | tool::SFTP_STAT | tool::SFTP_DELETE => {
tool_def::<PathArgs>(definition)
}
tool::SFTP_READ_TEXT => tool_def::<SftpReadTextArgs>(definition),
tool::SFTP_WRITE_TEXT => tool_def::<SftpWriteTextArgs>(definition),
tool::SFTP_MKDIR => tool_def::<SftpMkdirArgs>(definition),
tool::SFTP_RENAME => tool_def::<SftpRenameArgs>(definition),
tool::SFTP_CHMOD => tool_def::<SftpChmodArgs>(definition),
tool::OUTPUT_READ => tool_def::<OutputReadArgs>(definition),
_ => unreachable!("registry contains an unknown MCP tool"),
})
.collect()
}
fn tool_def<T: JsonSchema>(
name: &'static str,
description: &'static str,
read_only: bool,
destructive: bool,
) -> Tool {
fn tool_def<T: JsonSchema>(definition: &McpToolDefinition) -> Tool {
let schema = serde_json::to_value(schemars::schema_for!(T))
.unwrap_or_else(|_| json!({ "type": "object" }));
let object = schema.as_object().cloned().unwrap_or_else(Map::new);
let mut item = Tool::new(name, description, object);
let mut item = Tool::new(definition.tool, definition.description, object);
item.annotations = Some(
ToolAnnotations::new()
.read_only(read_only)
.destructive(destructive)
.open_world(false),
.read_only(definition.read_only_hint)
.destructive(definition.destructive_hint)
.open_world(definition.open_world_hint),
);
item
}
@@ -193,7 +138,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(test)]
mod tests {
use nyaterm_mcp_protocol::{RpcRequest, RpcResponse};
use nyaterm_mcp_protocol::{MCP_TOOL_REGISTRY, RpcRequest, RpcResponse};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf},
net::TcpListener,
@@ -201,6 +146,54 @@ mod tests {
use super::*;
#[test]
fn listed_tool_annotations_come_from_the_shared_registry() {
let tools = build_tools();
assert_eq!(tools.len(), MCP_TOOL_REGISTRY.len());
for definition in MCP_TOOL_REGISTRY {
let tool = tools
.iter()
.find(|tool| tool.name == definition.tool)
.unwrap();
let value = serde_json::to_value(tool).unwrap();
assert_eq!(
value["annotations"]["readOnlyHint"],
definition.read_only_hint
);
assert_eq!(
value["annotations"]["destructiveHint"],
definition.destructive_hint
);
assert_eq!(
value["annotations"]["openWorldHint"],
definition.open_world_hint
);
}
let connection_list = tools
.iter()
.find(|item| item.name == tool::CONNECTION_LIST)
.expect("connection_list tool");
let connection_list = serde_json::to_value(connection_list).unwrap();
assert_eq!(connection_list["inputSchema"]["type"], "object");
assert_eq!(connection_list["annotations"]["readOnlyHint"], true);
let session_open = tools
.iter()
.find(|item| item.name == tool::SESSION_OPEN)
.expect("session_open tool");
let session_open = serde_json::to_value(session_open).unwrap();
assert_eq!(
session_open["inputSchema"]["required"],
json!(["connectionId"])
);
assert_eq!(
session_open["inputSchema"]["properties"]["connectionId"]["type"],
"string"
);
assert_eq!(session_open["annotations"]["readOnlyHint"], false);
}
async fn send_client_message(writer: &mut WriteHalf<tokio::io::DuplexStream>, raw: &str) {
let value = serde_json::from_str::<Value>(raw).expect("valid MCP client message");
writer
@@ -247,10 +240,33 @@ mod tests {
assert_eq!(request.params["name"], "integration-test");
json!({ "identified": true })
}
"capability.execute" => {
assert_eq!(request.params["tool"], tool::GET_ENVIRONMENT);
json!({ "defaultSessionId": "session-1", "sessions": [] })
}
"capability.execute" => match request.params["tool"].as_str().unwrap() {
tool::GET_ENVIRONMENT => {
json!({ "defaultSessionId": "session-1", "sessions": [] })
}
tool::CONNECTION_LIST => json!({
"connections": [{
"id": "connection-1",
"name": "Local shell",
"type": "local_terminal",
"groupPath": []
}]
}),
tool::SESSION_OPEN => {
assert_eq!(
request.params["arguments"]["connectionId"],
"connection-1"
);
json!({
"sessionId": "session-2",
"connectionId": "connection-1",
"name": "Local shell",
"type": "local",
"connected": true
})
}
other => panic!("unexpected tool: {other}"),
},
other => panic!("unexpected bridge method: {other}"),
};
let response = RpcResponse {
@@ -303,7 +319,7 @@ mod tests {
)
.await;
let listed = receive_response(&mut client_lines, 2).await;
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 14);
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 16);
send_client_message(
&mut client_writer,
@@ -321,6 +337,47 @@ mod tests {
"session-1"
);
send_client_message(
&mut client_writer,
r#"{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": { "name": "connection_list", "arguments": {} }
}"#,
)
.await;
let connections = receive_response(&mut client_lines, 4).await;
assert_eq!(
connections["result"]["structuredContent"]["connections"][0]["id"],
"connection-1"
);
send_client_message(
&mut client_writer,
r#"{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "session_open",
"arguments": { "connectionId": "connection-1" }
}
}"#,
)
.await;
let opened = receive_response(&mut client_lines, 5).await;
assert_eq!(
opened["result"]["structuredContent"],
json!({
"sessionId": "session-2",
"connectionId": "connection-1",
"name": "Local shell",
"type": "local",
"connected": true
})
);
drop(client_writer);
drop(client_lines);
server_task.abort();
+42 -1
View File
@@ -427,6 +427,12 @@ async fn file_entry_from_path(path: &Path, name: String) -> AppResult<FileEntry>
async fn file_properties_from_path(path: &Path) -> AppResult<FileProperties> {
let symlink_metadata = tokio::fs::symlink_metadata(path).await?;
let is_symlink = symlink_metadata.file_type().is_symlink();
let symlink_target = if is_symlink {
Some(path_to_string(tokio::fs::read_link(path).await?))
} else {
None
};
let metadata = tokio::fs::metadata(path)
.await
.unwrap_or_else(|_| symlink_metadata.clone());
@@ -438,7 +444,8 @@ async fn file_properties_from_path(path: &Path) -> AppResult<FileProperties> {
Ok(FileProperties {
name,
is_dir: metadata.is_dir(),
is_symlink: symlink_metadata.file_type().is_symlink(),
is_symlink,
symlink_target,
size: if metadata.is_dir() { 0 } else { metadata.len() },
permissions: permissions_string(&metadata, metadata.is_dir()),
owner: owner_string(&metadata),
@@ -782,6 +789,40 @@ mod tests {
cleanup(&root).await;
}
#[tokio::test]
async fn regular_file_properties_have_no_symlink_target() {
let root = temp_test_dir("regular-properties");
tokio::fs::create_dir_all(&root).await.unwrap();
let file = root.join("file.txt");
tokio::fs::write(&file, b"hello").await.unwrap();
let properties = file_properties_from_path(&file).await.unwrap();
assert!(!properties.is_symlink);
assert_eq!(properties.symlink_target, None);
cleanup(&root).await;
}
#[cfg(unix)]
#[tokio::test]
async fn dangling_symlink_properties_preserve_relative_target() {
use std::os::unix::fs::symlink;
let root = temp_test_dir("dangling-symlink-properties");
tokio::fs::create_dir_all(&root).await.unwrap();
let link = root.join("current");
symlink("../missing-release", &link).unwrap();
let properties = file_properties_from_path(&link).await.unwrap();
assert!(properties.is_symlink);
assert_eq!(
properties.symlink_target.as_deref(),
Some("../missing-release")
);
cleanup(&root).await;
}
#[tokio::test]
async fn ensure_local_session_rejects_non_local_sessions() {
let manager = SessionManager::new();
+45 -3
View File
@@ -39,17 +39,27 @@ pub async fn set_external_mcp_enabled(
.inner()
.configure_external(settings, &owner_window_label)
.await?;
crate::storage::update_settings_doc(
if let Err(error) = crate::storage::update_settings_doc(
crate::storage::SettingsDocKey::AppSettings,
|stored: &mut crate::config::AppSettings| {
stored.ai.external_mcp.enabled = true;
Ok(())
},
)?;
) {
let _ = manager.disable_external(false).await;
return Err(error);
}
let _ = app.emit("settings-changed", ());
Ok(status)
} else {
manager.disable_external(true).await?;
if let Err(error) = manager.disable_external(true).await {
settings.enabled = true;
let _ = manager
.inner()
.configure_external(settings, &owner_window_label)
.await;
return Err(error);
}
Ok(manager.status().await)
}
}
@@ -69,6 +79,38 @@ pub async fn respond_external_mcp_approval(
manager.respond_approval(&request_id, decision).await
}
#[tauri::command]
pub async fn report_mcp_active_session(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<McpManager>>,
session_id: Option<String>,
) -> AppResult<()> {
if !crate::window_state::is_main_window_label(window.label()) {
return Err(AppError::Config(
"Only a NyaTerm main window can report its active MCP session.".into(),
));
}
manager.set_active_session(window.label(), session_id).await
}
#[tauri::command]
pub async fn respond_mcp_session_open(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<McpManager>>,
request_id: String,
session_id: Option<String>,
error: Option<String>,
) -> AppResult<()> {
if !crate::window_state::is_main_window_label(window.label()) {
return Err(AppError::Config(
"Only a NyaTerm main window can complete an MCP session-open request.".into(),
));
}
manager
.respond_session_open(window.label(), &request_id, session_id, error)
.await
}
#[tauri::command]
pub fn get_external_mcp_client_configs(
manager: tauri::State<'_, Arc<McpManager>>,
+1
View File
@@ -22,6 +22,7 @@ pub mod rdp;
pub mod session;
pub mod settings;
pub mod sftp;
pub mod ssh_config;
pub mod stats;
pub mod translate;
pub mod tunnel;
-5
View File
@@ -74,11 +74,6 @@ pub async fn save_app_settings(
allow_master_password_change: Option<bool>,
owner_window_label: Option<String>,
) -> AppResult<()> {
if !(1..=120).contains(&settings.ai.external_mcp.idle_timeout_minutes) {
return Err(AppError::Config(
"External MCP idle timeout must be between 1 and 120 minutes.".into(),
));
}
let previous_mcp = config::load_app_settings(&app)?.ai.external_mcp;
let next_mcp = settings.ai.external_mcp.clone();
let external_owner = if previous_mcp != next_mcp && next_mcp.enabled {
+18
View File
@@ -229,6 +229,24 @@ pub async fn create_remote_symlink(
sftp::create_remote_symlink(state.inner().clone(), &session_id, &link_path, &target_path).await
}
#[tauri::command]
pub async fn update_remote_symlink_target(
state: tauri::State<'_, Arc<SessionManager>>,
session_id: String,
path: String,
raw_path_token: Option<String>,
target_path: String,
) -> AppResult<()> {
sftp::update_remote_symlink_target(
state.inner().clone(),
&session_id,
&path,
raw_path_token.as_deref(),
&target_path,
)
.await
}
#[tauri::command]
pub async fn chmod_remote_file(
state: tauri::State<'_, Arc<SessionManager>>,
+33
View File
@@ -0,0 +1,33 @@
use crate::core::ssh_config::{SshConfig, SshConfigEntry};
use crate::error::AppResult;
/// Lists all concrete host entries from `~/.ssh/config` with resolved ProxyJump chains.
#[tauri::command]
pub fn list_ssh_config_hosts() -> AppResult<Vec<SshConfigEntry>> {
let config = SshConfig::load_default()?;
config.to_entries()
}
/// Returns the raw parsed `Host` blocks from `~/.ssh/config`.
#[tauri::command]
pub fn get_ssh_config() -> AppResult<SshConfig> {
SshConfig::load_default()
}
/// Resolves a single host alias into a fully-resolved entry with hops.
#[tauri::command]
pub fn resolve_ssh_host(alias: String) -> AppResult<SshConfigEntry> {
let config = SshConfig::load_default()?;
config.resolve(&alias)
}
/// Imports all concrete SSH config hosts as saved connections.
/// Returns the number of connections imported (skips duplicates by name).
#[tauri::command]
pub fn import_ssh_config_hosts(app: tauri::AppHandle) -> AppResult<usize> {
let count = crate::core::ssh_config::import_ssh_config_connections(&app)?;
tauri::async_runtime::spawn(async move {
crate::core::cloud_sync::notify_config_changed(&app).await;
});
Ok(count)
}
+75 -2
View File
@@ -435,6 +435,12 @@ pub struct SftpSettings {
pub shell_detection_timeout_ms: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub filename_encoding: String,
#[serde(
default,
deserialize_with = "deserialize_sftp_pipeline_depth",
skip_serializing_if = "Option::is_none"
)]
pub pipeline_depth: Option<u32>,
}
impl Default for SftpSettings {
@@ -444,6 +450,7 @@ impl Default for SftpSettings {
cwd_follow_mode: SftpCwdFollowMode::ShellIntegration,
shell_detection_timeout_ms: default_sftp_shell_detection_timeout_ms(),
filename_encoding: String::new(),
pipeline_depth: None,
}
}
}
@@ -497,6 +504,34 @@ pub fn resolve_ssh_terminal_type(
pub const MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS: u64 = 100;
pub const MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS: u64 = 60_000;
pub const MIN_SFTP_PIPELINE_DEPTH: u32 = 4;
pub const MAX_SFTP_PIPELINE_DEPTH: u32 = 64;
fn deserialize_sftp_pipeline_depth<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum PipelineDepthValue {
Signed(i64),
Unsigned(u64),
Invalid(serde::de::IgnoredAny),
}
let value = Option::<PipelineDepthValue>::deserialize(deserializer)?;
Ok(match value {
Some(PipelineDepthValue::Signed(value)) => Some(value.clamp(
i64::from(MIN_SFTP_PIPELINE_DEPTH),
i64::from(MAX_SFTP_PIPELINE_DEPTH),
) as u32),
Some(PipelineDepthValue::Unsigned(value)) => Some(value.clamp(
u64::from(MIN_SFTP_PIPELINE_DEPTH),
u64::from(MAX_SFTP_PIPELINE_DEPTH),
) as u32),
Some(PipelineDepthValue::Invalid(_)) | None => None,
})
}
pub fn default_sftp_shell_detection_timeout_ms() -> u64 {
3000
@@ -1377,8 +1412,9 @@ pub fn save_config(app: &AppHandle, config: &AppConfig) -> AppResult<()> {
mod tests {
use super::{
AssetAcceleratorType, AssetDeviceType, AssetDiskKind, AssetDiskPurpose, ConnectionType,
MAX_SSH_AGENT_ENVIRONMENT_VARIABLE_LEN, MAX_SSH_AGENT_FORWARDING_ENDPOINTS,
MAX_SSH_AGENT_FORWARDING_IDENTITIES, MAX_SSH_AGENT_UNIX_SOCKET_PATH_LEN, SavedConnection,
MAX_SFTP_PIPELINE_DEPTH, MAX_SSH_AGENT_ENVIRONMENT_VARIABLE_LEN,
MAX_SSH_AGENT_FORWARDING_ENDPOINTS, MAX_SSH_AGENT_FORWARDING_IDENTITIES,
MAX_SSH_AGENT_UNIX_SOCKET_PATH_LEN, MIN_SFTP_PIPELINE_DEPTH, SavedConnection,
SftpCwdFollowMode, SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig,
SshAgentForwardingPolicy, SshAgentForwardingSources, SshAlgorithmMode, SshProfile,
SshTerminalType, effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile,
@@ -1934,6 +1970,7 @@ mod tests {
SftpCwdFollowMode::ShellIntegration
);
assert_eq!(connection.sftp.shell_detection_timeout_ms, 3000);
assert_eq!(connection.sftp.pipeline_depth, None);
}
#[test]
@@ -1956,6 +1993,42 @@ mod tests {
assert_eq!(connection.sftp.shell_detection_timeout_ms, 5000);
}
#[test]
fn sftp_pipeline_depth_roundtrips_and_automatic_is_omitted() {
let automatic = serde_json::to_value(SftpSettings::default()).expect("automatic settings");
assert!(automatic.get("pipeline_depth").is_none());
let settings = SftpSettings {
pipeline_depth: Some(32),
..SftpSettings::default()
};
let encoded = serde_json::to_value(&settings).expect("manual settings");
assert_eq!(encoded["pipeline_depth"], 32);
let decoded: SftpSettings = serde_json::from_value(encoded).expect("roundtrip settings");
assert_eq!(decoded.pipeline_depth, Some(32));
}
#[test]
fn sftp_pipeline_depth_normalizes_invalid_imported_values() {
let below_minimum: SftpSettings =
serde_json::from_value(serde_json::json!({ "pipeline_depth": 0 }))
.expect("below-minimum settings");
let above_maximum: SftpSettings =
serde_json::from_value(serde_json::json!({ "pipeline_depth": 999 }))
.expect("above-maximum settings");
let negative: SftpSettings =
serde_json::from_value(serde_json::json!({ "pipeline_depth": -10 }))
.expect("negative settings");
let invalid_type: SftpSettings =
serde_json::from_value(serde_json::json!({ "pipeline_depth": "fast" }))
.expect("invalid-type settings");
assert_eq!(below_minimum.pipeline_depth, Some(MIN_SFTP_PIPELINE_DEPTH));
assert_eq!(above_maximum.pipeline_depth, Some(MAX_SFTP_PIPELINE_DEPTH));
assert_eq!(negative.pipeline_depth, Some(MIN_SFTP_PIPELINE_DEPTH));
assert_eq!(invalid_type.pipeline_depth, None);
}
#[test]
fn effective_cwd_follow_is_off_when_sftp_disabled_without_mutating_setting() {
let settings = SftpSettings {
+6 -7
View File
@@ -82,13 +82,12 @@ pub use settings::{
AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile,
AiReasoningEffort, AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings,
CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings,
ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings,
InteractionSettings, KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel,
SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings,
ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings,
ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings,
load_app_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings,
save_app_settings,
ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings,
KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine,
SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig,
ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential,
ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings,
mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings,
};
#[allow(unused_imports)]
pub use tunnel::{
+33 -25
View File
@@ -72,6 +72,7 @@ pub enum AiPermissionMode {
Observer,
Confirm,
Auto,
FullAccess,
}
impl Default for AiPermissionMode {
@@ -93,19 +94,6 @@ impl Default for ExternalMcpSessionScope {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExternalMcpServerMode {
Temporary,
Persistent,
}
impl Default for ExternalMcpServerMode {
fn default() -> Self {
Self::Temporary
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalMcpSettings {
#[serde(default)]
@@ -114,10 +102,6 @@ pub struct ExternalMcpSettings {
pub permission_mode: AiPermissionMode,
#[serde(default)]
pub session_scope: ExternalMcpSessionScope,
#[serde(default)]
pub server_mode: ExternalMcpServerMode,
#[serde(default = "default_external_mcp_idle_timeout_minutes")]
pub idle_timeout_minutes: u16,
}
impl Default for ExternalMcpSettings {
@@ -126,16 +110,10 @@ impl Default for ExternalMcpSettings {
enabled: false,
permission_mode: AiPermissionMode::Confirm,
session_scope: ExternalMcpSessionScope::CurrentWindow,
server_mode: ExternalMcpServerMode::Temporary,
idle_timeout_minutes: default_external_mcp_idle_timeout_minutes(),
}
}
}
fn default_external_mcp_idle_timeout_minutes() -> u16 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AiMode {
@@ -738,8 +716,6 @@ pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool {
let original = serde_json::to_string(settings).unwrap_or_default();
settings.schema_version = 6;
settings.external_mcp.idle_timeout_minutes =
settings.external_mcp.idle_timeout_minutes.clamp(1, 120);
if settings.request_user_agent.trim().is_empty() {
settings.request_user_agent = default_request_user_agent();
}
@@ -879,6 +855,38 @@ fn migrate_legacy_ollama_base_url(base_url: &mut Option<String>) {
mod tests {
use super::*;
#[test]
fn legacy_external_mcp_mode_fields_are_read_but_not_written() {
let settings: ExternalMcpSettings = serde_json::from_value(serde_json::json!({
"enabled": true,
"permission_mode": "confirm",
"session_scope": "current_window",
"server_mode": "temporary",
"idle_timeout_minutes": 10
}))
.expect("legacy External MCP settings");
assert!(settings.enabled);
assert_eq!(settings.permission_mode, AiPermissionMode::Confirm);
assert_eq!(
settings.session_scope,
ExternalMcpSessionScope::CurrentWindow
);
let serialized = serde_json::to_value(settings).expect("serialized External MCP settings");
assert!(serialized.get("server_mode").is_none());
assert!(serialized.get("idle_timeout_minutes").is_none());
}
#[test]
fn full_access_permission_mode_roundtrips_as_snake_case() {
let serialized = serde_json::to_string(&AiPermissionMode::FullAccess)
.expect("serialized permission mode");
assert_eq!(serialized, "\"full_access\"");
let parsed: AiPermissionMode =
serde_json::from_str(&serialized).expect("parsed permission mode");
assert_eq!(parsed, AiPermissionMode::FullAccess);
}
fn ollama_profile(settings: &AiSettings) -> &AiProviderProfile {
settings
.provider_profiles
+85 -4
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize};
pub struct InteractionSettings {
pub copy_on_select: bool,
pub allow_osc52_clipboard_write: bool,
pub right_click_paste: bool,
pub terminal_right_click_action: String,
pub terminal_zoom_enabled: bool,
pub command_suggestions_enabled: bool,
pub command_suggestion_min_chars: usize,
@@ -23,6 +23,7 @@ pub struct InteractionSettings {
struct InteractionSettingsWire {
copy_on_select: Option<bool>,
allow_osc52_clipboard_write: Option<bool>,
terminal_right_click_action: Option<String>,
right_click_paste: Option<bool>,
terminal_zoom_enabled: Option<bool>,
command_suggestions_enabled: Option<bool>,
@@ -58,6 +59,28 @@ fn default_encoding() -> String {
"UTF-8".to_string()
}
fn default_terminal_right_click_action() -> String {
"menu".to_string()
}
fn normalize_terminal_right_click_action(
action: Option<String>,
legacy_right_click_paste: Option<bool>,
) -> String {
if let Some(action) = action {
return match action.as_str() {
"none" | "menu" | "paste" => action,
_ => default_terminal_right_click_action(),
};
}
match legacy_right_click_paste {
Some(true) => "paste".to_string(),
Some(false) => "menu".to_string(),
None => "paste".to_string(),
}
}
fn default_tab_double_click_action() -> String {
"disconnect_session".to_string()
}
@@ -79,7 +102,7 @@ impl Default for InteractionSettings {
Self {
copy_on_select: false,
allow_osc52_clipboard_write: false,
right_click_paste: false,
terminal_right_click_action: default_terminal_right_click_action(),
terminal_zoom_enabled: true,
command_suggestions_enabled: true,
command_suggestion_min_chars: default_command_suggestion_min_chars(),
@@ -109,7 +132,10 @@ impl<'de> Deserialize<'de> for InteractionSettings {
allow_osc52_clipboard_write: wire
.allow_osc52_clipboard_write
.unwrap_or(defaults.allow_osc52_clipboard_write),
right_click_paste: wire.right_click_paste.unwrap_or_else(default_true),
terminal_right_click_action: normalize_terminal_right_click_action(
wire.terminal_right_click_action,
wire.right_click_paste,
),
terminal_zoom_enabled: wire.terminal_zoom_enabled.unwrap_or_else(default_true),
command_suggestions_enabled: wire
.command_suggestions_enabled
@@ -154,6 +180,7 @@ mod tests {
assert_eq!(settings.tab_double_click_action, "disconnect_session");
assert_eq!(settings.tab_middle_click_action, "rename_tab");
assert_eq!(settings.tab_right_click_action, "none");
assert_eq!(settings.terminal_right_click_action, "menu");
assert!(!settings.allow_osc52_clipboard_write);
assert!(!settings.alt_as_meta);
assert!(!settings.ime_compatibility);
@@ -177,6 +204,7 @@ mod tests {
assert_eq!(settings.tab_middle_click_action, "rename_tab");
assert_eq!(settings.tab_right_click_action, "none");
assert_eq!(settings.duplicate_session_command_delay_ms, 1000);
assert_eq!(settings.terminal_right_click_action, "menu");
assert!(!settings.allow_osc52_clipboard_write);
assert!(!settings.alt_as_meta);
assert!(!settings.ime_compatibility);
@@ -188,12 +216,65 @@ mod tests {
let settings: InteractionSettings = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(settings.copy_on_select);
assert!(settings.right_click_paste);
assert_eq!(settings.terminal_right_click_action, "paste");
assert!(settings.terminal_zoom_enabled);
assert!(settings.command_suggestions_enabled);
assert!(!settings.ime_compatibility);
}
#[test]
fn terminal_right_click_action_accepts_all_supported_values() {
for action in ["none", "menu", "paste"] {
let settings: InteractionSettings = serde_json::from_value(serde_json::json!({
"terminal_right_click_action": action
}))
.unwrap();
assert_eq!(settings.terminal_right_click_action, action);
}
}
#[test]
fn legacy_right_click_paste_migrates_to_terminal_right_click_action() {
let menu: InteractionSettings = serde_json::from_value(serde_json::json!({
"right_click_paste": false
}))
.unwrap();
let paste: InteractionSettings = serde_json::from_value(serde_json::json!({
"right_click_paste": true
}))
.unwrap();
assert_eq!(menu.terminal_right_click_action, "menu");
assert_eq!(paste.terminal_right_click_action, "paste");
}
#[test]
fn terminal_right_click_action_takes_precedence_and_normalizes_invalid_values() {
let explicit: InteractionSettings = serde_json::from_value(serde_json::json!({
"terminal_right_click_action": "none",
"right_click_paste": true
}))
.unwrap();
let invalid: InteractionSettings = serde_json::from_value(serde_json::json!({
"terminal_right_click_action": "invalid",
"right_click_paste": true
}))
.unwrap();
assert_eq!(explicit.terminal_right_click_action, "none");
assert_eq!(invalid.terminal_right_click_action, "menu");
}
#[test]
fn terminal_right_click_action_serialization_omits_legacy_field() {
let settings = InteractionSettings::default();
let value = serde_json::to_value(settings).unwrap();
assert_eq!(value["terminal_right_click_action"], "menu");
assert!(value.get("right_click_paste").is_none());
}
#[test]
fn legacy_mac_ime_compatibility_migrates_to_ime_compatibility() {
let settings: InteractionSettings = serde_json::from_value(serde_json::json!({
+10 -3
View File
@@ -16,9 +16,9 @@ pub use ai::{
AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, AiModelSource,
AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort,
AiSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode,
ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel,
ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings,
mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings,
ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, ai_model_id_for_credential,
ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, mask_ai_settings,
merge_masked_ai_settings, normalize_ai_settings,
};
pub use appearance::{AppearanceSettings, TerminalColorsConfig, ThemeColorsConfig, ThemeConfig};
pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings};
@@ -95,6 +95,10 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult<AppSettings> {
interaction.contains_key("mac_ime_compatibility")
&& !interaction.contains_key("ime_compatibility")
});
let has_legacy_terminal_right_click_action = raw_settings
.get("interaction")
.and_then(|interaction| interaction.as_object())
.is_some_and(|interaction| !interaction.contains_key("terminal_right_click_action"));
let mut migrated = false;
let mut secrets_ready_for_persist = true;
@@ -151,6 +155,9 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult<AppSettings> {
if has_legacy_mac_ime_compatibility {
migrated = true;
}
if has_legacy_terminal_right_click_action {
migrated = true;
}
for list in [
&mut settings.ui.activity_bar_layout.left_top,
@@ -9,6 +9,8 @@ pub struct TransferSettings {
pub editor_type: String,
#[serde(default = "default_internal_editor_display")]
pub internal_editor_display: String,
#[serde(default = "default_internal_editor_font_size")]
pub internal_editor_font_size: u32,
#[serde(default = "default_transfer_threads")]
pub download_threads: u32,
#[serde(default = "default_transfer_threads")]
@@ -54,6 +56,9 @@ fn default_editor_type() -> String {
fn default_internal_editor_display() -> String {
"workspace".to_string()
}
fn default_internal_editor_font_size() -> u32 {
13
}
fn default_duplicate_strategy() -> String {
"ask".to_string()
}
@@ -75,6 +80,7 @@ impl Default for TransferSettings {
Self {
editor_type: default_editor_type(),
internal_editor_display: default_internal_editor_display(),
internal_editor_font_size: default_internal_editor_font_size(),
download_threads: default_transfer_threads(),
upload_threads: default_transfer_threads(),
duplicate_strategy: default_duplicate_strategy(),
@@ -113,5 +119,6 @@ mod tests {
assert!(!settings.recording_auto_start);
assert_eq!(settings.editor_type, "external");
assert_eq!(settings.internal_editor_display, "workspace");
assert_eq!(settings.internal_editor_font_size, 13);
}
}
+38 -9
View File
@@ -368,6 +368,7 @@ fn safe_command_preview(command: &str) -> String {
struct RiskAssessment {
model_risk: RiskLevel,
local_risk: RiskLevel,
local_auto_executable: bool,
effective_risk: RiskLevel,
risk_reason: Option<String>,
}
@@ -574,14 +575,14 @@ fn risk_label(risk: &RiskLevel) -> &'static str {
}
}
fn assess_local_command_risk(command: &str) -> (RiskLevel, String) {
fn assess_local_command_risk(command: &str) -> (RiskLevel, String, bool) {
let risk = crate::core::capabilities::assess_command_risk(command);
(risk.level, risk.reason)
(risk.level, risk.reason, risk.auto_executable)
}
fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAssessment {
let model_risk = parsed.risk_level.clone().unwrap_or(RiskLevel::Medium);
let (local_risk, local_reason) = assess_local_command_risk(command);
let (local_risk, local_reason, local_auto_executable) = assess_local_command_risk(command);
let effective_risk = max_risk(model_risk.clone(), local_risk.clone());
let risk_reason = parsed
.risk_reason
@@ -593,6 +594,7 @@ fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAs
RiskAssessment {
model_risk,
local_risk,
local_auto_executable,
effective_risk,
risk_reason,
}
@@ -636,13 +638,23 @@ fn decide_agent_command_execution(
fn decide_external_agent_command_execution(
mode: &AiPermissionMode,
assessment: &RiskAssessment,
) -> (ApprovalDecision, Option<String>) {
match mode {
AiPermissionMode::Observer | AiPermissionMode::Confirm => (
ApprovalDecision::NeedsApproval,
Some("external agent permission mode requires confirmation".to_string()),
),
AiPermissionMode::Auto => (ApprovalDecision::Auto, None),
AiPermissionMode::Auto
if assessment.local_auto_executable && assessment.effective_risk < RiskLevel::High =>
{
(ApprovalDecision::Auto, None)
}
AiPermissionMode::Auto => (
ApprovalDecision::NeedsApproval,
Some("safe auto requires confirmation for unknown or high-risk commands".to_string()),
),
AiPermissionMode::FullAccess => (ApprovalDecision::Auto, None),
}
}
@@ -743,7 +755,8 @@ fn append_agent_command_audit(
client: None,
capability: None,
session_id: None,
permission_mode: None,
permission_mode: (request.agent_kind != AiAgentKind::Nyaterm)
.then(|| request.permission_mode.clone()),
approval_decision: None,
success: None,
duration_ms: None,
@@ -782,7 +795,7 @@ pub(super) async fn run_external_agent_command_step(
let (decision, approval_reason) = if request.agent_kind == AiAgentKind::Nyaterm {
decide_agent_command_execution(settings, &assessment)
} else {
decide_external_agent_command_execution(&request.permission_mode)
decide_external_agent_command_execution(&request.permission_mode, &assessment)
};
if request.agent_kind != AiAgentKind::Nyaterm
@@ -1412,16 +1425,32 @@ mod tests {
#[test]
fn external_agent_permission_modes_are_explicit() {
let safe = assess_agent_command_risk(&parsed_response(None), "ls -la");
let high = assess_agent_command_risk(&parsed_response(None), "sudo reboot");
let unknown = assess_agent_command_risk(&parsed_response(None), "custom-deploy production");
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::Observer).0,
decide_external_agent_command_execution(&AiPermissionMode::Observer, &safe).0,
ApprovalDecision::NeedsApproval
);
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::Confirm).0,
decide_external_agent_command_execution(&AiPermissionMode::Confirm, &safe).0,
ApprovalDecision::NeedsApproval
);
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::Auto).0,
decide_external_agent_command_execution(&AiPermissionMode::Auto, &safe).0,
ApprovalDecision::Auto
);
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::Auto, &high).0,
ApprovalDecision::NeedsApproval
);
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::Auto, &unknown).0,
ApprovalDecision::NeedsApproval
);
assert_eq!(
decide_external_agent_command_execution(&AiPermissionMode::FullAccess, &high).0,
ApprovalDecision::Auto
);
}
+18
View File
@@ -419,6 +419,9 @@ fn claude_permission_mode(mode: &AiPermissionMode) -> &'static str {
AiPermissionMode::Observer => "plan",
AiPermissionMode::Confirm => "manual",
AiPermissionMode::Auto => "auto",
// Full access only bypasses NyaTerm's own capability approvals. Do not
// widen Claude Code's native local-tool permissions.
AiPermissionMode::FullAccess => "auto",
}
}
@@ -895,6 +898,21 @@ mod tests {
);
}
#[test]
fn full_access_does_not_enable_claude_native_permission_bypass() {
let mut request = test_request();
request.permission_mode = AiPermissionMode::FullAccess;
let invocation =
build_claude_invocation(&request, &AiSettings::default(), "prompt".to_string());
assert_eq!(
arg_value(&invocation.args, "--permission-mode"),
Some("auto")
);
assert!(!invocation.args.iter().any(|arg| arg == "bypassPermissions"));
}
#[test]
fn extracts_delta_from_partial_message() {
let mut last = String::new();
+15 -123
View File
@@ -1,136 +1,28 @@
use nyaterm_mcp_protocol::{capability, tool};
pub use nyaterm_mcp_protocol::CapabilityAccess;
use nyaterm_mcp_protocol::{McpToolDefinition, definition_for_tool};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilityAccess {
Read,
SensitiveRead,
Write,
DestructiveWrite,
}
#[derive(Debug, Clone, Copy)]
pub struct CapabilityDefinition {
pub id: &'static str,
pub mcp_tool: Option<&'static str>,
pub access: CapabilityAccess,
pub requires_session: bool,
}
pub const CATALOG: &[CapabilityDefinition] = &[
CapabilityDefinition {
id: capability::ENVIRONMENT,
mcp_tool: Some(tool::GET_ENVIRONMENT),
access: CapabilityAccess::Read,
requires_session: false,
},
CapabilityDefinition {
id: capability::SESSION_GET,
mcp_tool: Some(tool::SESSION_GET),
access: CapabilityAccess::Read,
requires_session: true,
},
CapabilityDefinition {
id: capability::TERMINAL_EXECUTE,
mcp_tool: Some(tool::TERMINAL_EXECUTE),
access: CapabilityAccess::Write,
requires_session: true,
},
CapabilityDefinition {
id: capability::TERMINAL_RECENT_OUTPUT,
mcp_tool: Some(tool::TERMINAL_RECENT_OUTPUT),
access: CapabilityAccess::SensitiveRead,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_HOME,
mcp_tool: Some(tool::SFTP_HOME),
access: CapabilityAccess::SensitiveRead,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_LIST,
mcp_tool: Some(tool::SFTP_LIST),
access: CapabilityAccess::SensitiveRead,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_STAT,
mcp_tool: Some(tool::SFTP_STAT),
access: CapabilityAccess::SensitiveRead,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_READ,
mcp_tool: Some(tool::SFTP_READ_TEXT),
access: CapabilityAccess::SensitiveRead,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_WRITE,
mcp_tool: Some(tool::SFTP_WRITE_TEXT),
access: CapabilityAccess::Write,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_MKDIR,
mcp_tool: Some(tool::SFTP_MKDIR),
access: CapabilityAccess::Write,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_RENAME,
mcp_tool: Some(tool::SFTP_RENAME),
access: CapabilityAccess::Write,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_DELETE,
mcp_tool: Some(tool::SFTP_DELETE),
access: CapabilityAccess::DestructiveWrite,
requires_session: true,
},
CapabilityDefinition {
id: capability::SFTP_CHMOD,
mcp_tool: Some(tool::SFTP_CHMOD),
access: CapabilityAccess::Write,
requires_session: true,
},
CapabilityDefinition {
id: capability::OUTPUT_READ,
mcp_tool: Some(tool::OUTPUT_READ),
access: CapabilityAccess::SensitiveRead,
requires_session: false,
},
];
#[cfg(test)]
fn capability_by_id(id: &str) -> Option<&'static CapabilityDefinition> {
CATALOG.iter().find(|definition| definition.id == id)
}
pub fn capability_for_tool(name: &str) -> Option<&'static CapabilityDefinition> {
CATALOG
.iter()
.find(|definition| definition.mcp_tool == Some(name))
pub fn capability_for_tool(name: &str) -> Option<&'static McpToolDefinition> {
definition_for_tool(name)
}
#[cfg(test)]
mod tests {
use nyaterm_mcp_protocol::{CapabilityAccess, MCP_TOOL_REGISTRY, capability, tool};
use super::*;
#[test]
fn delete_is_destructive_and_all_tools_are_unique() {
fn every_registered_tool_has_a_host_capability() {
for definition in MCP_TOOL_REGISTRY {
assert_eq!(capability_for_tool(definition.tool), Some(definition));
}
assert_eq!(
capability_by_id(capability::SFTP_DELETE).unwrap().access,
capability_for_tool(tool::SFTP_DELETE).unwrap().capability,
capability::SFTP_DELETE
);
assert_eq!(
capability_for_tool(tool::SFTP_DELETE).unwrap().access,
CapabilityAccess::DestructiveWrite
);
let mut tools = CATALOG
.iter()
.filter_map(|item| item.mcp_tool)
.collect::<Vec<_>>();
let before = tools.len();
tools.sort_unstable();
tools.dedup();
assert_eq!(tools.len(), before);
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ mod terminal;
pub use catalog::{CapabilityAccess, capability_for_tool};
pub use output_store::OutputStore;
pub use policy::{PolicyDecision, assess_command_risk, decide_policy};
pub use policy::{PolicyDecision, RiskAssessment, assess_command_risk, decide_policy};
pub use recent_output::RecentOutputStore;
pub use scope::McpScope;
pub use scope::{McpScope, McpScopeSnapshot};
pub use terminal::{
TerminalExecuteRequest, TerminalExecutionPresentation, execute_terminal_command,
};
+623 -135
View File
@@ -10,16 +10,20 @@ pub enum PolicyDecision {
}
#[derive(Debug, Clone)]
pub struct CommandRisk {
pub struct RiskAssessment {
pub level: RiskLevel,
pub reason: String,
pub auto_executable: bool,
}
pub fn decide_policy(
mode: &AiPermissionMode,
access: CapabilityAccess,
command_risk: Option<&RiskLevel>,
assessment: Option<&RiskAssessment>,
) -> PolicyDecision {
if *mode == AiPermissionMode::FullAccess {
return PolicyDecision::Allow;
}
if matches!(
access,
CapabilityAccess::Write | CapabilityAccess::DestructiveWrite
@@ -30,10 +34,14 @@ pub fn decide_policy(
if access == CapabilityAccess::DestructiveWrite {
return PolicyDecision::RequireApproval;
}
if command_risk.is_some_and(|risk| *risk >= RiskLevel::High) {
if assessment.is_some_and(|risk| risk.level >= RiskLevel::High) {
return PolicyDecision::RequireApproval;
}
if *mode == AiPermissionMode::Auto && assessment.is_some_and(|risk| !risk.auto_executable) {
return PolicyDecision::RequireApproval;
}
match (mode, access) {
(AiPermissionMode::FullAccess, _) => PolicyDecision::Allow,
(_, CapabilityAccess::Read) => PolicyDecision::Allow,
(AiPermissionMode::Auto, CapabilityAccess::SensitiveRead | CapabilityAccess::Write) => {
PolicyDecision::Allow
@@ -45,110 +53,93 @@ pub fn decide_policy(
}
}
pub fn assess_command_risk(command: &str) -> CommandRisk {
let normalized = command
.trim()
.replace("\r\n", "\n")
.replace('\n', " ")
.to_ascii_lowercase();
let compact = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.is_empty() {
return risk(RiskLevel::Medium, "empty command");
pub fn assess_command_risk(command: &str) -> RiskAssessment {
let normalized = command.trim().replace("\r\n", "\n").replace('\r', "\n");
if normalized.is_empty() {
return risk(RiskLevel::Medium, "empty command", false);
}
if is_root_rm_command(&compact)
|| (compact.starts_with("dd ") && compact.contains("of=/dev/"))
|| contains_any(
&compact,
&[
"mkfs",
"wipefs",
":(){",
"shutdown",
"poweroff",
"reboot",
"halt",
"systemctl stop ssh",
"systemctl stop sshd",
"service ssh stop",
"service sshd stop",
],
)
if normalized
.split_whitespace()
.collect::<String>()
.contains(":(){:|:&};:")
{
return risk(
RiskLevel::Critical,
"matches irreversible or system-disruptive command pattern",
false,
);
}
if compact.starts_with("sudo ")
|| contains_any(
&compact,
&[
"rm -r",
"rm -f",
" rmdir ",
" chmod -r",
" chown -r",
"systemctl restart",
"systemctl stop",
"service ",
"apt install",
"apt remove",
"apt purge",
"yum install",
"yum remove",
"dnf install",
"dnf remove",
"pacman -s",
"pacman -r",
"brew install",
"brew uninstall",
"npm install -g",
"pip install",
"docker rm",
"docker rmi",
"docker system prune",
"kubectl delete",
"kubectl drain",
"kubectl apply",
"kubectl replace",
"git reset --hard",
"git clean -fd",
],
)
let tokens = tokenize_shell(&normalized.to_ascii_lowercase());
if tokens.is_empty() {
return risk(RiskLevel::Medium, "command could not be classified", false);
}
let stages = command_stages(&tokens);
if stages.is_empty() {
return risk(RiskLevel::Medium, "command could not be classified", false);
}
if stages.iter().any(|stage| is_critical_stage(stage)) {
return risk(
RiskLevel::Critical,
"matches irreversible or system-disruptive command pattern",
false,
);
}
if is_download_pipe_to_shell(&tokens)
|| has_sensitive_write_redirection(&tokens)
|| stages.iter().any(|stage| is_high_risk_stage(stage))
{
return risk(
RiskLevel::High,
"matches privileged, destructive, restart, package, container, or cluster mutation pattern",
"matches privileged or high-impact mutation pattern",
false,
);
}
if contains_any(
&format!(" {compact} "),
&[
" > ",
">>",
" tee ",
" touch ",
" mkdir ",
" cp ",
" mv ",
" chmod ",
" chown ",
" setfacl ",
" export ",
"git checkout",
"git switch",
"git pull",
"git merge",
"npm run",
"make install",
],
) {
return risk(
let has_redirection = tokens
.iter()
.any(|token| matches!(token.as_str(), ">" | ">>"));
let mut saw_write = has_redirection;
for stage in &stages {
match classify_stage(stage) {
StageClass::ReadOnly => {}
StageClass::Write => saw_write = true,
StageClass::Unknown => {
return risk(
RiskLevel::Medium,
"command is not explicitly classified as safe for automatic execution",
false,
);
}
}
}
if saw_write {
risk(
RiskLevel::Medium,
"matches local write or state-changing command pattern",
);
"matches a known ordinary state-changing command pattern",
true,
)
} else {
risk(
RiskLevel::Low,
"matches read-only diagnostic command patterns",
true,
)
}
let readonly = [
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StageClass {
ReadOnly,
Write,
Unknown,
}
fn classify_stage(stage: &[String]) -> StageClass {
let Some((command, args)) = executable_and_args(stage) else {
return StageClass::Unknown;
};
let read_only = [
"ls",
"pwd",
"whoami",
@@ -165,65 +156,439 @@ pub fn assess_command_risk(command: &str) -> CommandRisk {
"du",
"free",
"top",
"htop",
"ps",
"ss",
"netstat",
"ip ",
"journalctl",
"systemctl status",
"docker ps",
"docker logs",
"kubectl get",
"kubectl describe",
"git status",
"git log",
"git diff",
"printenv",
"which",
"whereis",
];
if readonly
.iter()
.any(|prefix| compact == prefix.trim() || compact.starts_with(&format!("{prefix} ")))
{
return risk(RiskLevel::Low, "matches read-only diagnostic pattern");
if read_only.contains(&command) {
return StageClass::ReadOnly;
}
risk(
RiskLevel::Medium,
"no explicit read-only pattern matched; defaulting to medium",
)
if command == "env" && args.is_empty() {
return StageClass::ReadOnly;
}
if command == "ip"
&& (args.is_empty()
|| args.first().is_some_and(|arg| {
matches!(
arg.as_str(),
"a" | "addr" | "address" | "link" | "route" | "neigh" | "rule"
)
}))
{
return StageClass::ReadOnly;
}
if command == "systemctl" && args.first().is_some_and(|arg| arg == "status") {
return StageClass::ReadOnly;
}
if command == "docker"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "ps" | "logs" | "inspect"))
{
return StageClass::ReadOnly;
}
if command == "kubectl"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "get" | "describe" | "logs" | "explain"))
{
return StageClass::ReadOnly;
}
if command == "git"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "status" | "log" | "diff" | "show"))
{
return StageClass::ReadOnly;
}
let ordinary_write = [
"touch", "mkdir", "cp", "mv", "chmod", "chown", "setfacl", "export",
];
if ordinary_write.contains(&command) {
return StageClass::Write;
}
if command == "git"
&& args.first().is_some_and(|arg| {
matches!(
arg.as_str(),
"checkout" | "switch" | "pull" | "merge" | "add" | "commit"
)
})
{
return StageClass::Write;
}
if command == "make" && args.iter().any(|arg| arg == "install") {
return StageClass::Write;
}
StageClass::Unknown
}
fn risk(level: RiskLevel, reason: &str) -> CommandRisk {
CommandRisk {
fn is_critical_stage(stage: &[String]) -> bool {
let Some((command, args)) = executable_and_args(stage) else {
return false;
};
if command == "rm" && is_root_rm_args(args) {
return true;
}
if command == "dd" && args.iter().any(|arg| arg.starts_with("of=/dev/")) {
return true;
}
if matches!(
command,
"mkfs" | "wipefs" | "shutdown" | "poweroff" | "reboot" | "halt"
) || command.starts_with("mkfs.")
{
return true;
}
(command == "systemctl"
&& args.first().is_some_and(|arg| arg == "stop")
&& args.iter().skip(1).any(|arg| {
matches!(
arg.as_str(),
"ssh" | "sshd" | "ssh.service" | "sshd.service"
)
}))
|| (command == "service"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "ssh" | "sshd"))
&& args.get(1).is_some_and(|arg| arg == "stop"))
|| stage.join("").contains(":(){:|:&};:")
}
fn is_high_risk_stage(stage: &[String]) -> bool {
let Some((command, args)) = executable_and_args(stage) else {
return false;
};
if matches!(command, "sudo" | "doas" | "su") {
return true;
}
if matches!(
command,
"rm" | "rmdir"
| "truncate"
| "iptables"
| "ip6tables"
| "nft"
| "ufw"
| "useradd"
| "userdel"
| "usermod"
| "passwd"
| "visudo"
) {
return true;
}
if command == "find"
&& args
.iter()
.any(|arg| matches!(arg.as_str(), "-delete" | "-exec" | "-execdir"))
{
return true;
}
if command == "ip"
&& args.iter().any(|arg| {
matches!(
arg.as_str(),
"add" | "delete" | "del" | "replace" | "change" | "set" | "flush"
)
})
{
return true;
}
if matches!(command, "chmod" | "chown")
&& args
.iter()
.any(|arg| arg.starts_with('-') && (arg.contains('r') || arg.contains('R')))
{
return true;
}
if matches!(command, "cp" | "mv" | "chmod" | "chown")
&& args.iter().any(|arg| is_sensitive_terminal_path(arg))
{
return true;
}
if command == "sed" && args.iter().any(|arg| arg == "-i" || arg.starts_with("-i")) {
return true;
}
if command == "perl"
&& args.iter().any(|arg| {
arg.starts_with('-')
&& arg.chars().any(|flag| flag == 'p')
&& arg.chars().any(|flag| flag == 'i')
})
{
return true;
}
if command == "systemctl"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "restart" | "stop" | "disable" | "mask"))
{
return true;
}
if command == "service" {
return true;
}
if matches!(
command,
"apt" | "apt-get" | "yum" | "dnf" | "pacman" | "brew" | "pip" | "pip3"
) && args.iter().any(|arg| {
matches!(
arg.as_str(),
"install" | "remove" | "purge" | "uninstall" | "-s" | "-r"
)
}) {
return true;
}
if command == "npm" && args.iter().any(|arg| arg == "-g" || arg == "--global") {
return true;
}
if command == "docker" {
return matches!(args.first().map(String::as_str), Some("rm" | "rmi"))
|| args.starts_with(&["system".into(), "prune".into()])
|| args.starts_with(&["compose".into(), "down".into()]);
}
if command == "kubectl"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "delete" | "drain" | "apply" | "replace"))
{
return true;
}
if command == "terraform"
&& args
.first()
.is_some_and(|arg| matches!(arg.as_str(), "apply" | "destroy"))
{
return true;
}
if command == "helm" && args.first().is_some_and(|arg| arg == "uninstall") {
return true;
}
if command == "git"
&& ((args.first().is_some_and(|arg| arg == "reset")
&& args.iter().any(|arg| arg == "--hard"))
|| (args.first().is_some_and(|arg| arg == "clean")
&& args
.iter()
.any(|arg| arg.starts_with('-') && arg.contains('f'))))
{
return true;
}
if matches!(command, "mysql" | "psql") && contains_sql_word(args, "drop") {
return true;
}
command == "redis-cli"
&& args
.iter()
.any(|arg| matches!(arg.as_str(), "flushall" | "flushdb"))
}
fn is_download_pipe_to_shell(tokens: &[String]) -> bool {
tokens.iter().enumerate().any(|(index, token)| {
if token != "|" {
return false;
}
let left_start = tokens[..index]
.iter()
.rposition(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|"))
.map_or(0, |position| position + 1);
let right_end = tokens[index + 1..]
.iter()
.position(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|"))
.map_or(tokens.len(), |position| index + 1 + position);
let left = executable_and_args(&tokens[left_start..index]).map(|value| value.0);
let right = executable_and_args(&tokens[index + 1..right_end]).map(|value| value.0);
matches!(left, Some("curl" | "wget"))
&& matches!(right, Some("sh" | "bash" | "zsh" | "fish"))
})
}
fn has_sensitive_write_redirection(tokens: &[String]) -> bool {
tokens
.windows(2)
.any(|pair| matches!(pair[0].as_str(), ">" | ">>") && is_sensitive_terminal_path(&pair[1]))
}
fn executable_and_args(stage: &[String]) -> Option<(&str, &[String])> {
let mut index = 0;
while stage
.get(index)
.is_some_and(|token| token.contains('=') && !token.starts_with('='))
{
index += 1;
}
let mut command = stage.get(index)?;
if basename(command) == "env" {
index += 1;
while stage.get(index).is_some_and(|token| {
token.starts_with('-') || (token.contains('=') && !token.starts_with('='))
}) {
index += 1;
}
let Some(nested) = stage.get(index) else {
return Some(("env", &[]));
};
command = nested;
}
Some((basename(command), &stage[index + 1..]))
}
fn is_sensitive_terminal_path(value: &str) -> bool {
[
"/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root",
]
.iter()
.any(|root| value == *root || value.starts_with(&format!("{root}/")))
|| value == "~/.ssh"
|| value.starts_with("~/.ssh/")
|| value.contains("/.ssh/")
}
fn basename(command: &str) -> &str {
command.rsplit(['/', '\\']).next().unwrap_or(command)
}
fn is_root_rm_args(args: &[String]) -> bool {
let recursive_force = args.iter().any(|arg| {
arg.starts_with('-')
&& arg.chars().any(|flag| flag == 'r')
&& arg.chars().any(|flag| flag == 'f')
});
recursive_force
&& args
.iter()
.any(|arg| matches!(arg.as_str(), "/" | "/*" | "--no-preserve-root"))
}
fn contains_sql_word(args: &[String], needle: &str) -> bool {
args.iter().any(|arg| {
arg.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
.any(|word| word == needle)
})
}
fn command_stages(tokens: &[String]) -> Vec<Vec<String>> {
let mut stages = Vec::new();
let mut current = Vec::new();
for token in tokens {
if matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|") {
if !current.is_empty() {
stages.push(std::mem::take(&mut current));
}
} else if !matches!(token.as_str(), ">" | ">>") {
current.push(token.clone());
}
}
if !current.is_empty() {
stages.push(current);
}
stages
}
fn tokenize_shell(command: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
let mut chars = command.chars().peekable();
while let Some(character) = chars.next() {
if escaped {
current.push(character);
escaped = false;
continue;
}
if character == '\\' && quote != Some('\'') {
escaped = true;
continue;
}
if matches!(character, '\'' | '"') {
if quote == Some(character) {
quote = None;
} else if quote.is_none() {
quote = Some(character);
} else {
current.push(character);
}
continue;
}
if quote.is_none() && character == '\n' {
push_token(&mut tokens, &mut current);
tokens.push(";".into());
continue;
}
if quote.is_none() && character.is_whitespace() {
push_token(&mut tokens, &mut current);
continue;
}
if quote.is_none() && matches!(character, ';' | '|' | '&' | '>') {
push_token(&mut tokens, &mut current);
let mut operator = character.to_string();
if chars.peek() == Some(&character) && matches!(character, '|' | '&' | '>') {
operator.push(chars.next().unwrap());
}
tokens.push(operator);
continue;
}
current.push(character);
}
push_token(&mut tokens, &mut current);
tokens
}
fn push_token(tokens: &mut Vec<String>, current: &mut String) {
if !current.is_empty() {
tokens.push(std::mem::take(current));
}
}
pub(crate) fn risk(level: RiskLevel, reason: &str, auto_executable: bool) -> RiskAssessment {
RiskAssessment {
level,
reason: reason.to_string(),
auto_executable,
}
}
fn contains_any(command: &str, patterns: &[&str]) -> bool {
patterns.iter().any(|pattern| command.contains(pattern))
}
fn is_root_rm_command(command: &str) -> bool {
let tokens = command.split_whitespace().collect::<Vec<_>>();
tokens.first() == Some(&"rm")
&& tokens
.iter()
.any(|token| token.starts_with('-') && token.contains('r') && token.contains('f'))
&& tokens
.iter()
.skip(1)
.any(|token| matches!(*token, "/" | "/*" | "--no-preserve-root"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_matrix_is_conservative() {
let safe = risk(RiskLevel::Medium, "known write", true);
let unknown = risk(RiskLevel::Medium, "unknown", false);
let high = risk(RiskLevel::High, "high", false);
for mode in [
AiPermissionMode::Observer,
AiPermissionMode::Confirm,
AiPermissionMode::Auto,
AiPermissionMode::FullAccess,
] {
assert_eq!(
decide_policy(&mode, CapabilityAccess::Read, None),
PolicyDecision::Allow
);
}
assert_eq!(
decide_policy(&AiPermissionMode::Observer, CapabilityAccess::Write, None),
PolicyDecision::Deny
);
assert_eq!(
decide_policy(
&AiPermissionMode::Observer,
CapabilityAccess::SensitiveRead,
None
),
PolicyDecision::RequireApproval
);
assert_eq!(
decide_policy(
&AiPermissionMode::Confirm,
@@ -235,8 +600,24 @@ mod tests {
assert_eq!(
decide_policy(
&AiPermissionMode::Auto,
CapabilityAccess::SensitiveRead,
None
),
PolicyDecision::Allow
);
assert_eq!(
decide_policy(
&AiPermissionMode::Confirm,
CapabilityAccess::Write,
Some(&RiskLevel::Low)
Some(&safe)
),
PolicyDecision::RequireApproval
);
assert_eq!(
decide_policy(
&AiPermissionMode::Auto,
CapabilityAccess::Write,
Some(&safe)
),
PolicyDecision::Allow
);
@@ -244,7 +625,15 @@ mod tests {
decide_policy(
&AiPermissionMode::Auto,
CapabilityAccess::Write,
Some(&RiskLevel::High)
Some(&unknown)
),
PolicyDecision::RequireApproval
);
assert_eq!(
decide_policy(
&AiPermissionMode::Auto,
CapabilityAccess::Write,
Some(&high)
),
PolicyDecision::RequireApproval
);
@@ -256,15 +645,114 @@ mod tests {
),
PolicyDecision::RequireApproval
);
assert_eq!(
decide_policy(
&AiPermissionMode::Observer,
CapabilityAccess::DestructiveWrite,
None
),
PolicyDecision::Deny
);
assert_eq!(
decide_policy(
&AiPermissionMode::Confirm,
CapabilityAccess::DestructiveWrite,
None
),
PolicyDecision::RequireApproval
);
assert_eq!(
decide_policy(
&AiPermissionMode::FullAccess,
CapabilityAccess::DestructiveWrite,
Some(&high)
),
PolicyDecision::Allow
);
assert_eq!(
decide_policy(
&AiPermissionMode::FullAccess,
CapabilityAccess::Write,
Some(&unknown)
),
PolicyDecision::Allow
);
}
#[test]
fn risk_matches_existing_protections() {
assert_eq!(assess_command_risk("ls -la").level, RiskLevel::Low);
fn terminal_risk_covers_safe_unknown_and_high_impact_commands() {
let readonly = assess_command_risk("ls -la | grep src");
assert_eq!(readonly.level, RiskLevel::Low);
assert!(readonly.auto_executable);
let ordinary_write = assess_command_risk("mkdir build && cp app build/app");
assert_eq!(ordinary_write.level, RiskLevel::Medium);
assert!(ordinary_write.auto_executable);
let unknown = assess_command_risk("custom-deploy production");
assert_eq!(unknown.level, RiskLevel::Medium);
assert!(!unknown.auto_executable);
for command in [
"sudo ls",
"doas cat /etc/hosts",
"systemctl restart nginx",
"truncate -s 0 important.db",
"iptables -F",
"nft flush ruleset",
"ufw disable",
"userdel alice",
"passwd root",
"visudo",
"sed -i s/a/b/ config",
"perl -pi -e s/a/b/ config",
"curl https://example.test/install | sh",
"wget -qO- https://example.test/install | bash",
"docker compose down -v",
"terraform apply",
"terraform destroy",
"helm uninstall production",
"mysql -e 'DROP DATABASE production'",
"psql -c 'DROP TABLE users'",
"redis-cli FLUSHALL",
"redis-cli flushdb",
"find /tmp -delete",
"ip link set eth0 down",
"env MODE=prod rm -f app.db",
"chmod -R 777 /etc/app",
] {
assert!(
assess_command_risk(command).level >= RiskLevel::High,
"expected high risk: {command}"
);
}
assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical);
assert_eq!(
assess_command_risk("systemctl restart nginx").level,
assess_command_risk("ls\nrm -rf /").level,
RiskLevel::Critical
);
assert_eq!(
assess_command_risk("ls & rm -rf /").level,
RiskLevel::Critical
);
assert_eq!(
assess_command_risk("echo replacement > /etc/hosts").level,
RiskLevel::High
);
assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical);
}
#[test]
fn risk_detection_uses_command_boundaries() {
assert_eq!(
assess_command_risk("echo 'sudo and terraform destroy'").level,
RiskLevel::Medium
);
assert!(!assess_command_risk("echo 'sudo and terraform destroy'").auto_executable);
assert_eq!(
assess_command_risk("systemctl status sshd").level,
RiskLevel::Low
);
assert_eq!(assess_command_risk("ip route show").level, RiskLevel::Low);
assert!(!assess_command_risk("npm run deploy").auto_executable);
}
}
+126 -10
View File
@@ -1,26 +1,80 @@
use std::collections::HashSet;
use crate::core::session::SessionInfo;
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone)]
pub struct McpScope {
pub enum McpScope {
Explicit {
session_ids: HashSet<String>,
default_session_id: Option<String>,
},
CurrentWindow {
owner_window_label: String,
},
AllSessions,
}
#[derive(Debug, Clone)]
pub struct McpScopeSnapshot {
pub session_ids: HashSet<String>,
pub default_session_id: Option<String>,
}
impl McpScope {
pub fn new(
pub fn explicit(
session_ids: impl IntoIterator<Item = String>,
default_session_id: Option<String>,
) -> Self {
let session_ids = session_ids.into_iter().collect::<HashSet<_>>();
let default_session_id = default_session_id.filter(|id| session_ids.contains(id));
Self {
Self::Explicit {
session_ids,
default_session_id,
}
}
pub fn current_window(owner_window_label: impl Into<String>) -> Self {
Self::CurrentWindow {
owner_window_label: owner_window_label.into(),
}
}
pub fn resolve(&self, sessions: &[SessionInfo]) -> McpScopeSnapshot {
match self {
Self::Explicit {
session_ids,
default_session_id,
} => {
let live_ids = sessions
.iter()
.map(|session| session.id.as_str())
.collect::<HashSet<_>>();
let session_ids = session_ids
.iter()
.filter(|id| live_ids.contains(id.as_str()))
.cloned()
.collect::<HashSet<_>>();
let default_session_id = default_session_id
.as_ref()
.filter(|id| session_ids.contains(*id))
.cloned();
McpScopeSnapshot {
session_ids,
default_session_id,
}
}
Self::CurrentWindow { owner_window_label } => {
dynamic_snapshot(sessions.iter().filter(|session| {
session.owner_window_label.as_deref() == Some(owner_window_label.as_str())
}))
}
Self::AllSessions => dynamic_snapshot(sessions.iter()),
}
}
}
impl McpScopeSnapshot {
pub fn require(&self, session_id: &str) -> AppResult<()> {
if self.session_ids.contains(session_id) {
Ok(())
@@ -44,20 +98,82 @@ impl McpScope {
}
}
fn dynamic_snapshot<'a>(sessions: impl Iterator<Item = &'a SessionInfo>) -> McpScopeSnapshot {
let session_ids = sessions
.map(|session| session.id.clone())
.collect::<HashSet<_>>();
let default_session_id = (session_ids.len() == 1)
.then(|| session_ids.iter().next().cloned())
.flatten();
McpScopeSnapshot {
session_ids,
default_session_id,
}
}
#[cfg(test)]
mod tests {
use crate::config::AiExecutionProfile;
use crate::core::session::{DynamicTitleCapabilities, SessionType};
use super::*;
fn session(id: &str, owner: &str) -> SessionInfo {
SessionInfo {
id: id.into(),
name: id.into(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: Some(owner.into()),
ai_execution_profile: AiExecutionProfile::Auto,
injection_active: true,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: true,
remote_stats_enabled: true,
ssh_profile: None,
}
}
#[test]
fn resolves_only_scoped_default() {
let scope = McpScope::new(["a".into(), "b".into()], Some("a".into()));
assert_eq!(scope.resolve_terminal_session(None).unwrap(), "a");
assert!(scope.resolve_terminal_session(Some("c")).is_err());
fn explicit_scope_is_frozen_and_drops_closed_sessions() {
let scope = McpScope::explicit(["a".into(), "b".into()], Some("a".into()));
let initial = scope.resolve(&[session("a", "main"), session("b", "main")]);
assert_eq!(initial.resolve_terminal_session(None).unwrap(), "a");
assert!(initial.resolve_terminal_session(Some("c")).is_err());
let changed = scope.resolve(&[session("b", "main"), session("c", "main")]);
assert_eq!(changed.session_ids, HashSet::from(["b".into()]));
assert!(changed.default_session_id.is_none());
}
#[test]
fn current_window_scope_resolves_new_sessions_and_excludes_other_windows() {
let scope = McpScope::current_window("main");
let initial = scope.resolve(&[session("a", "main"), session("x", "main-2")]);
assert_eq!(initial.session_ids, HashSet::from(["a".into()]));
assert_eq!(initial.default_session_id.as_deref(), Some("a"));
let changed = scope.resolve(&[
session("a", "main"),
session("b", "main"),
session("x", "main-2"),
]);
assert_eq!(changed.session_ids, HashSet::from(["a".into(), "b".into()]));
assert!(changed.default_session_id.is_none());
}
#[test]
fn multiple_sessions_without_default_require_explicit_id() {
let scope = McpScope::new(["a".into(), "b".into()], None);
assert!(scope.resolve_terminal_session(None).is_err());
fn all_sessions_scope_resolves_new_sessions() {
let scope = McpScope::AllSessions;
assert_eq!(scope.resolve(&[session("a", "main")]).session_ids.len(), 1);
assert_eq!(
scope
.resolve(&[session("a", "main"), session("b", "main-2")])
.session_ids
.len(),
2
);
}
}
+232
View File
@@ -4,6 +4,160 @@ use crate::core::session::{SessionInfo, SessionManager, SessionType};
use crate::core::sftp::{self, FileEntry, FileProperties, RemoteTextFile, WriteRemoteTextResult};
use crate::error::{AppError, AppResult};
use super::RiskAssessment;
use super::policy::risk;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SftpRiskOperation {
Read,
Write,
Mkdir,
Rename,
Delete,
Chmod,
}
pub fn assess_sftp_risk(
operation: SftpRiskOperation,
path: &str,
destination_path: Option<&str>,
force: bool,
mode: Option<&str>,
) -> RiskAssessment {
if operation == SftpRiskOperation::Delete {
return risk(
crate::config::RiskLevel::High,
"remote path deletion is destructive",
false,
);
}
if operation == SftpRiskOperation::Read {
return risk(
crate::config::RiskLevel::Medium,
"remote file access may expose sensitive data",
true,
);
}
let (path, path_has_parent_traversal) = normalize_remote_path(path);
let (destination, destination_has_parent_traversal) = destination_path
.map(normalize_remote_path)
.unwrap_or_default();
let sensitive =
is_sensitive_path(&path) || (!destination.is_empty() && is_sensitive_path(&destination));
if force {
return risk(
crate::config::RiskLevel::High,
"force write bypasses optimistic concurrency protection",
false,
);
}
if sensitive || path_has_parent_traversal || destination_has_parent_traversal {
return risk(
crate::config::RiskLevel::High,
"mutation targets a sensitive or ambiguously resolved remote path",
false,
);
}
if operation == SftpRiskOperation::Chmod && mode.is_some_and(is_dangerous_mode) {
return risk(
crate::config::RiskLevel::Medium,
"permission change grants broad remote access",
true,
);
}
risk(
crate::config::RiskLevel::Medium,
"ordinary remote filesystem mutation",
true,
)
}
fn normalize_remote_path(path: &str) -> (String, bool) {
let path = path.trim().replace('\\', "/");
let absolute = path.starts_with('/');
let home = path == "~" || path.starts_with("~/");
let mut parent_traversal = false;
let mut parts = Vec::new();
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
parent_traversal = true;
if parts.last().is_some_and(|part| *part != "~") {
parts.pop();
}
}
_ => parts.push(part),
}
}
let joined = parts.join("/");
let normalized = if absolute && joined.is_empty() {
"/".to_string()
} else if absolute {
format!("/{joined}")
} else if home && !joined.starts_with('~') {
format!("~/{joined}")
} else {
joined
};
let normalized = if normalized == "/" {
normalized
} else {
normalized.trim_end_matches('/').to_string()
};
(normalized, parent_traversal)
}
fn is_sensitive_path(path: &str) -> bool {
const SYSTEM_ROOTS: &[&str] = &[
"/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root",
];
if path == "/"
|| SYSTEM_ROOTS
.iter()
.any(|root| path == *root || path.starts_with(&format!("{root}/")))
{
return true;
}
let components = path.split('/').collect::<Vec<_>>();
if components.contains(&".ssh")
|| path == "~/.ssh"
|| path.starts_with("~/.ssh/")
|| path == ".ssh"
|| path.starts_with(".ssh/")
|| path.contains("/.ssh/")
{
return true;
}
let basename = components.last().copied().unwrap_or_default();
matches!(
basename,
"authorized_keys" | "sshd_config" | "sudoers" | "passwd" | "shadow" | "group" | "crontab"
) || components.iter().any(|part| {
matches!(
*part,
"sudoers.d"
| "systemd"
| "nginx"
| "cron"
| "cron.d"
| "cron.daily"
| "cron.hourly"
| "cron.monthly"
| "cron.weekly"
)
}) || matches!(
path.rsplit_once('.').map(|(_, extension)| extension),
Some("service" | "socket" | "timer" | "target")
)
}
fn is_dangerous_mode(mode: &str) -> bool {
let mode = mode.trim().strip_prefix("0o").unwrap_or(mode.trim());
u32::from_str_radix(mode.trim_start_matches('0'), 8)
.is_ok_and(|value| matches!(value, 0o666 | 0o777))
}
pub fn is_available(info: &SessionInfo) -> bool {
info.connected && info.session_type == SessionType::SSH && info.remote_file_browser_enabled
}
@@ -114,3 +268,81 @@ pub async fn chmod(
require_available(&manager, session_id).await?;
sftp::chmod_remote_file(manager, session_id, path, mode).await
}
#[cfg(test)]
mod tests {
use crate::config::RiskLevel;
use super::*;
#[test]
fn assesses_remote_filesystem_mutations_dynamically() {
let ordinary = assess_sftp_risk(
SftpRiskOperation::Write,
"/home/alice/notes.txt",
None,
false,
None,
);
assert_eq!(ordinary.level, RiskLevel::Medium);
assert!(ordinary.auto_executable);
for path in [
"/etc/nginx/nginx.conf",
"/home/alice/.ssh/authorized_keys",
"/home/alice/.ssh",
"~/.ssh/config",
"/var/lib/app/state",
"/tmp/example.service",
] {
let assessment = assess_sftp_risk(SftpRiskOperation::Write, path, None, false, None);
assert_eq!(assessment.level, RiskLevel::High, "path: {path}");
assert!(!assessment.auto_executable);
}
assert_eq!(
assess_sftp_risk(
SftpRiskOperation::Write,
"/home/alice/notes.txt",
None,
true,
None,
)
.level,
RiskLevel::High
);
assert_eq!(
assess_sftp_risk(
SftpRiskOperation::Rename,
"/home/alice/config",
Some("/etc/app.conf"),
false,
None,
)
.level,
RiskLevel::High
);
assert_eq!(
assess_sftp_risk(
SftpRiskOperation::Chmod,
"~/.ssh/authorized_keys",
None,
false,
Some("0777"),
)
.level,
RiskLevel::High
);
assert_eq!(
assess_sftp_risk(
SftpRiskOperation::Delete,
"/home/alice/notes.txt",
None,
false,
None,
)
.level,
RiskLevel::High
);
}
}
+2
View File
@@ -36,6 +36,8 @@ pub struct ApprovalRequestEvent {
pub capability: String,
pub session_id: Option<String>,
pub session_name: Option<String>,
pub connection_id: Option<String>,
pub connection_name: Option<String>,
pub parameter_summary: String,
pub risk: RiskLevel,
}
+49 -53
View File
@@ -117,59 +117,7 @@ fn set_private_file_permissions(path: &Path) -> AppResult<()> {
#[cfg(windows)]
fn set_windows_current_user_acl(path: &Path, directory: bool) -> AppResult<()> {
use std::os::windows::process::CommandExt;
// Build a protected DACL from the current token SID instead of a user name. Starting
// from a fresh ACL also removes explicit grants that may have existed on a stale runtime
// directory, so another local account cannot inherit or retain access to the credential.
const SCRIPT: &str = r#"
$ErrorActionPreference = 'Stop'
$target = $args[0]
$isDirectory = $args[1] -eq '1'
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = if ($isDirectory) {
[System.Security.AccessControl.DirectorySecurity]::new()
} else {
[System.Security.AccessControl.FileSecurity]::new()
}
$acl.SetOwner($sid)
$acl.SetAccessRuleProtection($true, $false)
$inheritance = if ($isDirectory) {
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
} else {
[System.Security.AccessControl.InheritanceFlags]::None
}
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
$sid,
[System.Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow
)
$acl.SetAccessRule($rule)
Set-Acl -LiteralPath $target -AclObject $acl
"#;
let output = std::process::Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
SCRIPT,
])
.arg(path)
.arg(if directory { "1" } else { "0" })
.creation_flags(0x0800_0000)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(AppError::Config(
"Failed to apply a current-user-only ACL to MCP discovery data.".into(),
))
}
super::windows_acl::set_current_user_only(path, directory)
}
#[cfg(not(any(unix, windows)))]
@@ -194,4 +142,52 @@ mod tests {
store.remove().unwrap();
let _ = std::fs::remove_dir_all(root);
}
#[cfg(windows)]
#[test]
fn discovery_replacement_preserves_private_acl_and_removes_temporary_files() {
let root = std::env::temp_dir().join(format!(
"nyaterm-mcp-discovery-test-{}",
uuid::Uuid::new_v4()
));
let store = DiscoveryStore::new(&root);
let first = discovery_document("first-token", "first-generation");
let second = discovery_document("second-token", "second-generation");
store.write(&first).unwrap();
store.write(&second).unwrap();
let actual: DiscoveryDocument =
serde_json::from_slice(&std::fs::read(&store.file).unwrap()).unwrap();
assert_eq!(actual.token, second.token);
assert_eq!(actual.generation, second.generation);
super::super::windows_acl::assert_current_user_only(&store.directory, true);
super::super::windows_acl::assert_current_user_only(&store.file, false);
let temporary_files = std::fs::read_dir(&store.directory)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
name.starts_with(".discovery-") && name.ends_with(".tmp")
})
.count();
assert_eq!(temporary_files, 0);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(windows)]
fn discovery_document(token: &str, generation: &str) -> DiscoveryDocument {
DiscoveryDocument {
version: 1,
pid: std::process::id(),
host: "127.0.0.1".into(),
port: 47_123,
token: token.into(),
generation: generation.into(),
permission_mode: "read-only".into(),
}
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,8 @@
mod approval;
mod discovery;
mod host;
#[cfg(windows)]
mod windows_acl;
pub use approval::ApprovalDecision;
pub use host::{EphemeralMcpCredential, McpClientConfigs, McpManager, McpRuntimeStatus};
+315
View File
@@ -0,0 +1,315 @@
use std::ffi::c_void;
use std::mem::size_of;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree,
};
use windows_sys::Win32::Security::Authorization::{
EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
};
use windows_sys::Win32::Security::{
ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, GetTokenInformation, IsValidSid,
NO_INHERITANCE, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION,
PROTECTED_DACL_SECURITY_INFORMATION, PSID, TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
use crate::error::{AppError, AppResult};
pub(super) fn set_current_user_only(path: &Path, directory: bool) -> AppResult<()> {
let user = current_user_sid(path)?;
let acl = create_private_acl(path, user.sid(), directory)?;
let path_wide = wide_path(path)?;
let security_information = OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION;
let status = unsafe {
SetNamedSecurityInfoW(
path_wide.as_ptr(),
SE_FILE_OBJECT,
security_information,
user.sid(),
null_mut(),
acl.as_ptr(),
null(),
)
};
if status != ERROR_SUCCESS {
return Err(status_error("SetNamedSecurityInfoW", path, status));
}
Ok(())
}
fn current_user_sid(path: &Path) -> AppResult<CurrentUserSid> {
let mut raw_token: HANDLE = null_mut();
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw_token) } == 0 {
return Err(last_error("OpenProcessToken", path));
}
let token = OwnedHandle(raw_token);
let mut required_length = 0;
let initial_result =
unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut required_length) };
if initial_result != 0 {
return Err(AppError::Config(format!(
"Failed to query the current user SID for MCP discovery data during \
GetTokenInformation(size) for '{}': unexpected result",
path.display()
)));
}
let error = unsafe { GetLastError() };
if error != ERROR_INSUFFICIENT_BUFFER || required_length == 0 {
return Err(status_error("GetTokenInformation(size)", path, error));
}
// TOKEN_USER contains pointer-sized fields. A usize buffer keeps the allocation aligned
// while still providing the variable-length storage required for the trailing SID.
let word_count = (required_length as usize).div_ceil(size_of::<usize>());
let mut buffer = vec![0usize; word_count];
let buffer_length = required_length;
if unsafe {
GetTokenInformation(
token.0,
TokenUser,
buffer.as_mut_ptr().cast(),
buffer_length,
&mut required_length,
)
} == 0
{
return Err(last_error("GetTokenInformation(TokenUser)", path));
}
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
let sid = token_user.User.Sid;
if sid.is_null() || unsafe { IsValidSid(sid) } == 0 {
return Err(AppError::Config(format!(
"Failed to query the current user SID for MCP discovery data during \
GetTokenInformation(TokenUser) for '{}': Windows returned an invalid SID",
path.display()
)));
}
Ok(CurrentUserSid {
_buffer: buffer,
sid,
})
}
fn create_private_acl(path: &Path, sid: PSID, directory: bool) -> AppResult<LocalAllocation<ACL>> {
let inheritance = if directory {
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
} else {
NO_INHERITANCE
};
let trustee = TRUSTEE_W {
pMultipleTrustee: null_mut(),
MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_USER,
ptstrName: sid.cast(),
};
let access = EXPLICIT_ACCESS_W {
grfAccessPermissions: FILE_ALL_ACCESS,
grfAccessMode: SET_ACCESS,
grfInheritance: inheritance,
Trustee: trustee,
};
let mut raw_acl: *mut ACL = null_mut();
let status = unsafe { SetEntriesInAclW(1, &access, null(), &mut raw_acl) };
if status != ERROR_SUCCESS {
if !raw_acl.is_null() {
drop(LocalAllocation(raw_acl));
}
return Err(status_error("SetEntriesInAclW", path, status));
}
if raw_acl.is_null() {
return Err(AppError::Config(format!(
"Failed to build a current-user-only ACL for MCP discovery data during \
SetEntriesInAclW for '{}': Windows returned a null ACL",
path.display()
)));
}
Ok(LocalAllocation(raw_acl))
}
fn wide_path(path: &Path) -> AppResult<Vec<u16>> {
let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
if wide.contains(&0) {
return Err(AppError::Config(format!(
"Failed to apply a current-user-only ACL to MCP discovery data: path contains an \
embedded NUL: '{}'",
path.display()
)));
}
wide.push(0);
Ok(wide)
}
fn last_error(operation: &str, path: &Path) -> AppError {
status_error(operation, path, unsafe { GetLastError() })
}
fn status_error(operation: &str, path: &Path, status: u32) -> AppError {
let source = std::io::Error::from_raw_os_error(status as i32);
AppError::Config(format!(
"Failed to apply a current-user-only ACL to MCP discovery data during {operation} for \
'{}': win32_error={status} ({source})",
path.display()
))
}
struct CurrentUserSid {
_buffer: Vec<usize>,
sid: PSID,
}
impl CurrentUserSid {
const fn sid(&self) -> PSID {
self.sid
}
}
struct OwnedHandle(HANDLE);
impl Drop for OwnedHandle {
fn drop(&mut self) {
if !self.0.is_null() {
let _ = unsafe { CloseHandle(self.0) };
}
}
}
struct LocalAllocation<T>(*mut T);
impl<T> LocalAllocation<T> {
const fn as_ptr(&self) -> *mut T {
self.0
}
}
impl<T> Drop for LocalAllocation<T> {
fn drop(&mut self) {
if !self.0.is_null() {
let _ = unsafe { LocalFree(self.0.cast::<c_void>()) };
}
}
}
#[cfg(test)]
pub(super) fn assert_current_user_only(path: &Path, directory: bool) {
use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW;
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION, AclSizeInformation, EqualSid, GetAce,
GetAclInformation, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED,
};
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
let user = current_user_sid(path).unwrap();
let path_wide = wide_path(path).unwrap();
let mut owner: PSID = null_mut();
let mut dacl: *mut ACL = null_mut();
let mut security_descriptor: PSECURITY_DESCRIPTOR = null_mut();
let status = unsafe {
GetNamedSecurityInfoW(
path_wide.as_ptr(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&mut owner,
null_mut(),
&mut dacl,
null_mut(),
&mut security_descriptor,
)
};
assert_eq!(
status,
ERROR_SUCCESS,
"{}",
status_error("GetNamedSecurityInfoW", path, status)
);
let _security_descriptor = LocalAllocation(security_descriptor);
assert!(!security_descriptor.is_null());
assert!(!owner.is_null());
assert!(!dacl.is_null());
assert_ne!(unsafe { EqualSid(owner, user.sid()) }, 0);
let mut control = 0;
let mut revision = 0;
assert_ne!(
unsafe { GetSecurityDescriptorControl(security_descriptor, &mut control, &mut revision) },
0
);
assert_ne!(control & SE_DACL_PROTECTED, 0);
let mut acl_info = ACL_SIZE_INFORMATION::default();
assert_ne!(
unsafe {
GetAclInformation(
dacl,
(&raw mut acl_info).cast(),
size_of::<ACL_SIZE_INFORMATION>() as u32,
AclSizeInformation,
)
},
0
);
assert_eq!(acl_info.AceCount, 1);
let mut raw_ace: *mut c_void = null_mut();
assert_ne!(unsafe { GetAce(dacl, 0, &mut raw_ace) }, 0);
let ace = raw_ace.cast::<ACCESS_ALLOWED_ACE>();
assert_eq!(unsafe { (*ace).Header.AceType }, ACCESS_ALLOWED_ACE_TYPE);
assert_eq!(unsafe { (*ace).Mask }, FILE_ALL_ACCESS);
let expected_flags = if directory {
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
} else {
NO_INHERITANCE
};
assert_eq!(u32::from(unsafe { (*ace).Header.AceFlags }), expected_flags);
let ace_sid = unsafe {
std::ptr::addr_of!((*ace).SidStart)
.cast_mut()
.cast::<c_void>()
};
assert_ne!(unsafe { EqualSid(ace_sid, user.sid()) }, 0);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applies_current_user_only_acl_to_directory_and_file() {
let root =
std::env::temp_dir().join(format!("nyaterm-windows-acl-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
set_current_user_only(&root, true).unwrap();
let file = root.join("discovery.json");
std::fs::write(&file, b"{}").unwrap();
set_current_user_only(&file, false).unwrap();
assert_current_user_only(&root, true);
assert_current_user_only(&file, false);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn missing_path_returns_contextual_win32_error() {
let path = std::env::temp_dir()
.join(format!("nyaterm-missing-acl-test-{}", uuid::Uuid::new_v4()))
.join("discovery.json");
let error = set_current_user_only(&path, false).unwrap_err();
let message = error.to_string();
assert!(message.contains("SetNamedSecurityInfoW"));
assert!(message.contains("win32_error="));
assert!(message.contains(&path.display().to_string()));
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod remote_exec;
mod session;
pub mod sftp;
pub mod ssh;
pub mod ssh_config;
pub(crate) mod terminal_session;
pub mod translate;
pub mod vnc;
+46 -3
View File
@@ -92,6 +92,7 @@ pub(crate) struct AutoRemoteFs {
ssh_handle: Arc<SshConnectionHandles>,
cache_key: String,
sftp_encoding: String,
sftp_pipeline_depth_override: Option<u32>,
}
impl AutoRemoteFs {
@@ -101,12 +102,14 @@ impl AutoRemoteFs {
port: u16,
username: &str,
sftp_encoding: &str,
sftp_pipeline_depth_override: Option<u32>,
) -> Self {
Self {
inner: RwLock::new(None),
ssh_handle,
cache_key: cache_key(host, port, username),
sftp_encoding: sftp_encoding.to_string(),
sftp_pipeline_depth_override,
}
}
@@ -151,6 +154,7 @@ impl AutoRemoteFs {
return Ok(Box::new(SftpBackend::new(
self.ssh_handle.clone(),
&self.sftp_encoding,
self.sftp_pipeline_depth_override,
)));
}
Err(e) => {
@@ -198,6 +202,7 @@ impl AutoRemoteFs {
Box::new(SftpBackend::new(
self.ssh_handle.clone(),
&self.sftp_encoding,
self.sftp_pipeline_depth_override,
))
})
}
@@ -237,6 +242,7 @@ async fn get_ssh_info(
String,
String,
String,
Option<u32>,
)> {
let sessions = manager.sessions.lock().await;
let session = sessions
@@ -251,7 +257,7 @@ async fn get_ssh_info(
.downcast::<SshConnectionHandles>()
.map_err(|_| AppError::Config("Failed to get SSH handle".to_string()))?;
let (host, port, username, encoding, sftp_encoding) =
let (host, port, username, encoding, sftp_encoding, sftp_pipeline_depth_override) =
if let Some(ref cfg_any) = session.ssh_config {
if let Some(cfg) = cfg_any.downcast_ref::<crate::core::ssh::SshConfig>() {
let sftp_encoding = if cfg.sftp.filename_encoding.trim().is_empty() {
@@ -265,6 +271,7 @@ async fn get_ssh_info(
cfg.username.clone(),
cfg.encoding.clone(),
sftp_encoding,
cfg.sftp.pipeline_depth,
)
} else {
(
@@ -273,6 +280,7 @@ async fn get_ssh_info(
"unknown".to_string(),
"UTF-8".to_string(),
"UTF-8".to_string(),
None,
)
}
} else {
@@ -282,10 +290,19 @@ async fn get_ssh_info(
"unknown".to_string(),
"UTF-8".to_string(),
"UTF-8".to_string(),
None,
)
};
Ok((ssh_handle, host, port, username, encoding, sftp_encoding))
Ok((
ssh_handle,
host,
port,
username,
encoding,
sftp_encoding,
sftp_pipeline_depth_override,
))
}
async fn get_or_create_auto_fs(
@@ -307,7 +324,7 @@ async fn get_or_create_auto_fs(
}
}
let (ssh_handle, host, port, username, _encoding, sftp_encoding) =
let (ssh_handle, host, port, username, _encoding, sftp_encoding, sftp_pipeline_depth_override) =
get_ssh_info(manager, session_id).await?;
let auto_fs = Arc::new(AutoRemoteFs::new(
ssh_handle,
@@ -315,6 +332,7 @@ async fn get_or_create_auto_fs(
port,
&username,
&sftp_encoding,
sftp_pipeline_depth_override,
));
{
@@ -2319,6 +2337,31 @@ pub async fn create_remote_symlink(
Ok(())
}
pub async fn update_remote_symlink_target(
manager: Arc<SessionManager>,
session_id: &str,
path: &str,
raw_path_token: Option<&str>,
target_path: &str,
) -> AppResult<()> {
let auto_fs = get_or_create_auto_fs(&manager, session_id).await?;
let guard = auto_fs.backend().await?;
let fs = guard.as_ref().unwrap();
let path_ref = RemotePathRef::new(path, raw_path_token)?;
fs.update_symlink_target_ref(&path_ref, target_path).await?;
tracing::debug!(
target: "user_action",
action = "update",
entity = "remote_symlink",
session_id = %session_id,
remote_path = path,
"User changed remote symbolic link target"
);
Ok(())
}
pub async fn chmod_remote_file(
manager: Arc<SessionManager>,
session_id: &str,
+38
View File
@@ -18,6 +18,10 @@ struct ExecResult {
stderr: Vec<u8>,
}
fn parse_find_symlink_target(output: &[u8]) -> String {
String::from_utf8_lossy(output.strip_suffix(&[0]).unwrap_or(output)).into_owned()
}
impl ScpEnhancedBackend {
pub(crate) fn new(ssh_handle: Arc<SshConnectionHandles>) -> Self {
Self { ssh_handle }
@@ -731,6 +735,17 @@ impl RemoteFs for ScpEnhancedBackend {
let is_dir = file_type.contains("directory");
let is_symlink = file_type.contains("symbolic link") || file_type.contains("symlink");
let symlink_target = if is_symlink {
let output = self
.exec_ok(&format!(
"LC_ALL=C find {} -maxdepth 0 -printf '%l\\0'",
sh_quote(path)
))
.await?;
Some(parse_find_symlink_target(&output))
} else {
None
};
let is_symlink_to_dir = is_symlink
&& self
.exec(&format!("test -d {}", sh_quote(path)))
@@ -752,6 +767,7 @@ impl RemoteFs for ScpEnhancedBackend {
name,
is_dir,
is_symlink,
symlink_target,
size,
permissions,
owner,
@@ -1323,3 +1339,25 @@ impl RemoteFs for ScpEnhancedBackend {
.map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}")))
}
}
#[cfg(test)]
mod tests {
use super::parse_find_symlink_target;
#[test]
fn find_target_parser_preserves_relative_and_trailing_spaces() {
assert_eq!(
parse_find_symlink_target(b"../release/v2\0"),
"../release/v2"
);
assert_eq!(parse_find_symlink_target(b"release v3 \0"), "release v3 ");
}
#[test]
fn find_target_parser_accepts_dangling_target_text() {
assert_eq!(
parse_find_symlink_target(b"missing-release\0"),
"missing-release"
);
}
}
+143 -29
View File
@@ -300,16 +300,44 @@ async fn exec_command_with_stdin(
})
}
fn split_ls_fields(line: &str) -> Option<(Vec<&str>, &str)> {
let bytes = line.as_bytes();
let mut fields = Vec::with_capacity(8);
let mut cursor = 0;
while fields.len() < 8 {
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let start = cursor;
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if start == cursor {
return None;
}
fields.push(&line[start..cursor]);
}
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
(cursor < bytes.len()).then(|| (fields, &line[cursor..]))
}
fn list_dir_command(path: &str) -> String {
format!("LC_ALL=C ls -la -n -- {}", sh_quote(path))
}
fn stat_command(path: &str) -> String {
format!("LC_ALL=C ls -lad -n -- {}", sh_quote(path))
}
fn parse_ls_line(line: &str) -> Option<FileEntry> {
let line = line.trim();
if line.is_empty() || line.starts_with("total ") {
let line = line.trim_start();
if line.trim_end().is_empty() || line.starts_with("total ") {
return None;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
return None;
}
let (parts, raw_name) = split_ls_fields(line)?;
let perms = parts[0];
if perms.len() < 10 {
@@ -323,8 +351,6 @@ fn parse_ls_line(line: &str) -> Option<FileEntry> {
let group = parts[3].to_string();
let size: u64 = parts[4].parse().unwrap_or(0);
// parts[5..8] are month/day/time-or-year; everything from index 8 onward is the name
let raw_name = parts[8..].join(" ");
if raw_name.is_empty() {
return None;
}
@@ -336,7 +362,7 @@ fn parse_ls_line(line: &str) -> Option<FileEntry> {
raw_name.to_string()
}
} else {
raw_name
raw_name.to_string()
};
if name == "." || name == ".." {
@@ -365,14 +391,13 @@ fn remote_child_path(parent: &str, name: &str) -> String {
}
fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult<FileProperties> {
let line = line.trim();
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
let line = line.trim_start();
let Some((parts, raw_name)) = split_ls_fields(line) else {
return Err(AppError::Channel(format!(
"Failed to parse stat output for '{}'",
path
)));
}
};
let perms = parts[0];
if perms.len() < 10 {
@@ -389,21 +414,27 @@ fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult<FileProperti
let group = parts[3].to_string();
let size: u64 = parts[4].parse().unwrap_or(0);
let raw_name = parts[8..].join(" ");
let name = if is_symlink {
let (name, symlink_target) = if is_symlink {
if let Some(pos) = raw_name.find(" -> ") {
raw_name[..pos].to_string()
(
raw_name[..pos].to_string(),
Some(raw_name[pos + " -> ".len()..].to_string()),
)
} else {
raw_name.to_string()
return Err(AppError::Channel(format!(
"Symbolic link target was missing from stat output for '{}'",
path
)));
}
} else {
raw_name
(raw_name.to_string(), None)
};
Ok(FileProperties {
name,
is_dir,
is_symlink,
symlink_target,
size,
permissions: perms.to_string(),
owner: owner.clone(),
@@ -419,11 +450,7 @@ async fn stat_remote_properties(
ssh_handle: &Arc<SshConnectionHandles>,
path: &str,
) -> AppResult<FileProperties> {
let result = exec_command(
ssh_handle,
&format!("LC_ALL=C ls -lad -- {}", sh_quote(path)),
)
.await?;
let result = exec_command(ssh_handle, &stat_command(path)).await?;
if result.exit_code != Some(0) {
let stderr_text = String::from_utf8_lossy(&result.stderr);
return Err(AppError::Channel(format!(
@@ -865,9 +892,7 @@ impl RemoteFs for ScpNormalBackend {
async fn list_dir(&self, path: &str) -> AppResult<Vec<FileEntry>> {
let listing_path = remote_dir_listing_path(path);
let output = self
.exec_ok(&format!("LC_ALL=C ls -la -- {}", sh_quote(&listing_path)))
.await?;
let output = self.exec_ok(&list_dir_command(&listing_path)).await?;
let mut entries = Vec::new();
for line in output.lines() {
if let Some(mut entry) = parse_ls_line(line) {
@@ -885,9 +910,7 @@ impl RemoteFs for ScpNormalBackend {
}
async fn stat(&self, path: &str) -> AppResult<FileProperties> {
let output = self
.exec_ok(&format!("LC_ALL=C ls -lad -- {}", sh_quote(path)))
.await?;
let output = self.exec_ok(&stat_command(path)).await?;
let line = output
.lines()
.find(|l| !l.trim().is_empty() && !l.starts_with("total "))
@@ -1532,3 +1555,94 @@ impl RemoteFs for ScpNormalBackend {
.map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}")))
}
}
#[cfg(test)]
mod tests {
use super::{list_dir_command, parse_ls_line, parse_ls_line_to_properties, stat_command};
#[test]
fn ls_parser_handles_numeric_owner_and_group() {
let entry = parse_ls_line("-rw-r--r-- 1 1000 100513 203 Aug 3 16:37 .zshrc").unwrap();
assert_eq!(entry.name, ".zshrc");
assert_eq!(entry.owner, "1000");
assert_eq!(entry.group, "100513");
assert_eq!(entry.size, 203);
assert!(!entry.is_dir);
}
#[test]
fn ls_parser_keeps_hidden_directory_name() {
let entry = parse_ls_line("drwx------ 4 1000 100513 4096 Sep 1 09:31 .copilot").unwrap();
assert_eq!(entry.name, ".copilot");
assert!(entry.is_dir);
}
#[test]
fn ls_parser_keeps_spaces_in_file_name() {
let entry =
parse_ls_line("-rw-r--r-- 1 1000 100513 123 Sep 1 09:31 hello world.txt").unwrap();
assert_eq!(entry.name, "hello world.txt");
assert!(!entry.is_dir);
}
#[test]
fn ls_parser_keeps_spaces_in_directory_name() {
let entry = parse_ls_line("drwxr-xr-x 2 1000 100513 4096 Sep 1 09:31 My Folder").unwrap();
assert_eq!(entry.name, "My Folder");
assert!(entry.is_dir);
}
#[test]
fn ls_parser_strips_symlink_target_from_name() {
let entry =
parse_ls_line("lrwxrwxrwx 1 1000 100513 11 Aug 29 12:00 current -> releases/v2")
.unwrap();
assert_eq!(entry.name, "current");
assert!(entry.is_symlink);
}
#[test]
fn ls_commands_request_numeric_owner_and_group() {
assert_eq!(
list_dir_command("/home/user/My Folder"),
"LC_ALL=C ls -la -n -- '/home/user/My Folder'"
);
assert_eq!(
stat_command("/home/user/My Folder"),
"LC_ALL=C ls -lad -n -- '/home/user/My Folder'"
);
}
#[test]
fn ls_properties_parser_keeps_relative_symlink_target() {
let props = parse_ls_line_to_properties(
"lrwxrwxrwx 1 root root 11 Aug 29 12:00 current -> releases/v2",
"/opt/app/current",
)
.unwrap();
assert_eq!(props.name, "current");
assert!(props.is_symlink);
assert_eq!(props.symlink_target.as_deref(), Some("releases/v2"));
}
#[test]
fn ls_properties_parser_keeps_dangling_and_spaced_target() {
let props = parse_ls_line_to_properties(
"lrwxrwxrwx 1 root root 19 Aug 29 12:00 current -> missing release ",
"/opt/app/current",
)
.unwrap();
assert_eq!(props.symlink_target.as_deref(), Some("missing release "));
}
#[test]
fn ls_properties_parser_sets_no_target_for_regular_file() {
let props = parse_ls_line_to_properties(
"-rw-r--r-- 1 root root 12 Aug 29 12:00 config.toml",
"/opt/app/config.toml",
)
.unwrap();
assert!(!props.is_symlink);
assert_eq!(props.symlink_target, None);
}
}
+2
View File
@@ -51,6 +51,8 @@ pub(crate) struct SftpBackend {
path_cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
/// Encoding for this connection (e.g., "UTF-8", "GBK")
encoding: String,
/// Optional per-session override for single-file SFTP request pipelining.
pipeline_depth_override: Option<u32>,
}
#[derive(Default)]
+13 -5
View File
@@ -7,7 +7,8 @@ pub(super) const SFTP_MAX_REQUEST_KIB: usize = 256;
pub(super) const SFTP_PIPELINE_TARGET_KIB: usize = 1024;
pub(super) const SFTP_WRITE_PIPELINE_TARGET_KIB: usize = 2048;
pub(super) const SFTP_MIN_PIPELINE_DEPTH: usize = 4;
pub(super) const SFTP_MAX_PIPELINE_DEPTH: usize = 16;
pub(super) const SFTP_AUTO_MAX_PIPELINE_DEPTH: usize = 16;
pub(super) const SFTP_USER_MAX_PIPELINE_DEPTH: usize = 64;
pub(super) const SFTP_MIN_CONCURRENT_WRITES: usize = 8;
pub(super) const SFTP_MAX_CONCURRENT_WRITES: usize = 16;
pub(super) const SFTP_PACKET_OVERHEAD_RESERVE: usize = 1024;
@@ -27,15 +28,22 @@ pub(super) const SFTP_CHANNEL_OPEN_RETRY_DELAYS: [Duration; 3] = [
Duration::from_millis(300),
];
pub(super) fn sftp_pipeline_config(ts: &crate::config::TransferSettings) -> (usize, usize, usize) {
pub(super) fn sftp_pipeline_config(
ts: &crate::config::TransferSettings,
pipeline_depth_override: Option<u32>,
) -> (usize, usize, usize) {
let request_kib =
(ts.transfer_buffer_size as usize).clamp(SFTP_MIN_REQUEST_KIB, SFTP_MAX_REQUEST_KIB);
let pipeline_depth = SFTP_PIPELINE_TARGET_KIB
let automatic_pipeline_depth = SFTP_PIPELINE_TARGET_KIB
.div_ceil(request_kib)
.clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_MAX_PIPELINE_DEPTH);
let max_concurrent_writes = SFTP_WRITE_PIPELINE_TARGET_KIB
.clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_AUTO_MAX_PIPELINE_DEPTH);
let automatic_max_concurrent_writes = SFTP_WRITE_PIPELINE_TARGET_KIB
.div_ceil(request_kib)
.clamp(SFTP_MIN_CONCURRENT_WRITES, SFTP_MAX_CONCURRENT_WRITES);
let pipeline_depth_override = pipeline_depth_override
.map(|value| (value as usize).clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_USER_MAX_PIPELINE_DEPTH));
let pipeline_depth = pipeline_depth_override.unwrap_or(automatic_pipeline_depth);
let max_concurrent_writes = pipeline_depth_override.unwrap_or(automatic_max_concurrent_writes);
(request_kib, pipeline_depth, max_concurrent_writes)
}
+80 -36
View File
@@ -480,7 +480,8 @@ impl SftpBackend {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let settings = copy_transfer_settings(app);
let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&settings, self.pipeline_depth_override);
let chunk_size = sftp_payload_size(request_kib);
let started = Instant::now();
let controller = create_child_file_transfer_controller(
@@ -632,6 +633,9 @@ impl SftpBackend {
) -> AppResult<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let settings = copy_transfer_settings(app);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&settings, self.pipeline_depth_override);
let controller = create_child_file_transfer_controller(
transfer_id,
source_session_id,
@@ -656,50 +660,88 @@ impl SftpBackend {
})?;
}
let sftp = self.open_sftp().await?;
let total_size = sftp
let sftp = self
.open_sftp_with_client_config(sftp_client_config(
request_kib,
max_concurrent_writes,
))
.await?;
let remote_size = sftp
.metadata(source_path)
.await
.ok()
.and_then(|attrs| attrs.size)
.unwrap_or(0);
.and_then(|attrs| attrs.size);
let total_size = remote_size.unwrap_or(0);
controller.update_progress(0, total_size);
let mut source_file = sftp.open(source_path).await.map_err(|error| {
AppError::Channel(format!("Source connection read open failed: {error}"))
})?;
let mut temp_file = tokio::fs::File::create(&temp).await.map_err(|error| {
AppError::Channel(format!("Failed to create temporary target file: {error}"))
})?;
let mut buffer = vec![0_u8; 512 * 1024];
let mut bytes_written = 0_u64;
let mut last_progress = Instant::now();
let temp_path = temp.to_string_lossy().to_string();
loop {
wait_for_transfer_ready(&controller).await?;
let read = source_file.read(&mut buffer).await.map_err(|error| {
AppError::Channel(format!(
"Source connection disconnected or read failed for {source_path}: {error}"
))
let bytes_written = if let Some(total_size) = remote_size {
if total_size > 0 {
let _ = temp_file.set_len(total_size).await;
}
download_known_size_to_local_file(
&sftp,
source_path,
&temp_path,
&mut temp_file,
total_size,
request_kib,
pipeline_depth,
pipeline_depth,
&controller,
None,
&self.path_cache,
|current, _delta| controller.update_progress(current, total_size),
|current| {
controller.update_progress(current, total_size);
let _ = app.emit(
"transfer-event",
&controller.build_event("progress", total_size, None),
);
},
)
.await?
} else {
let mut source_file = sftp.open(source_path).await.map_err(|error| {
AppError::Channel(format!("Source connection read open failed: {error}"))
})?;
if read == 0 {
break;
}
temp_file
.write_all(&buffer[..read])
.await
.map_err(|error| {
AppError::Channel(format!("Failed to write temporary target file: {error}"))
let mut buffer = vec![0_u8; sftp_payload_size(request_kib)];
let mut bytes_written = 0_u64;
let mut last_progress = Instant::now();
loop {
wait_for_transfer_ready(&controller).await?;
let read = source_file.read(&mut buffer).await.map_err(|error| {
AppError::Channel(format!(
"Source connection disconnected or read failed for {source_path}: {error}"
))
})?;
bytes_written = bytes_written.saturating_add(read as u64);
controller.update_progress(bytes_written, total_size);
if last_progress.elapsed() >= TRANSFER_PROGRESS_INTERVAL {
last_progress = Instant::now();
let _ = app.emit(
"transfer-event",
&controller.build_event("progress", total_size, None),
);
if read == 0 {
break;
}
temp_file
.write_all(&buffer[..read])
.await
.map_err(|error| {
AppError::Channel(format!(
"Failed to write temporary target file: {error}"
))
})?;
bytes_written = bytes_written.saturating_add(read as u64);
controller.update_progress(bytes_written, 0);
if last_progress.elapsed() >= TRANSFER_PROGRESS_INTERVAL {
last_progress = Instant::now();
let _ = app.emit(
"transfer-event",
&controller.build_event("progress", 0, None),
);
}
}
}
close_download_remote_file(source_file, source_path, &temp_path, None).await?;
bytes_written
};
temp_file.flush().await.map_err(|error| {
AppError::Channel(format!("Failed to flush temporary target file: {error}"))
})?;
@@ -945,7 +987,8 @@ impl SftpBackend {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let settings = copy_transfer_settings(app);
let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&settings, self.pipeline_depth_override);
let chunk_size = sftp_payload_size(request_kib);
let started = Instant::now();
let controller = create_directory_transfer_controller(
@@ -1123,7 +1166,8 @@ impl SftpBackend {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let settings = copy_transfer_settings(app);
let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&settings, self.pipeline_depth_override);
let started = Instant::now();
let controller = create_directory_transfer_controller(
transfer_id,
@@ -371,7 +371,8 @@ impl SftpBackend {
directory_controller: &Arc<TransferController>,
transfer_settings: &crate::config::TransferSettings,
) -> AppResult<LocalDirectoryInventory> {
let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings);
let (request_kib, _, max_concurrent_writes) =
sftp_pipeline_config(transfer_settings, self.pipeline_depth_override);
let sftp = self
.open_sftp_with_client_config(sftp_client_config(request_kib, max_concurrent_writes))
.await?;
@@ -479,7 +480,8 @@ impl SftpBackend {
});
}
let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(transfer_settings, self.pipeline_depth_override);
let pool = SftpSessionPool::new(
self,
concurrency.session_pool_size,
@@ -492,6 +494,8 @@ impl SftpBackend {
inventory,
directory_controller,
transfer_settings,
request_kib,
pipeline_depth,
concurrency,
self.path_cache.clone(),
)
@@ -517,7 +521,8 @@ impl SftpBackend {
});
}
let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings);
let (request_kib, _, max_concurrent_writes) =
sftp_pipeline_config(transfer_settings, self.pipeline_depth_override);
let pool = SftpSessionPool::new(
self,
concurrency.session_pool_size,
@@ -530,6 +535,7 @@ impl SftpBackend {
inventory,
directory_controller.clone(),
transfer_settings,
request_kib,
concurrency,
)
.await;
@@ -653,11 +659,12 @@ pub(super) async fn run_download_directory_workers(
inventory: RemoteDirectoryInventory,
directory_controller: Arc<TransferController>,
transfer_settings: &crate::config::TransferSettings,
request_kib: usize,
requested_pipeline_depth: usize,
concurrency: SftpDirectoryConcurrency,
path_cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
) -> AppResult<DirectoryTransferSummary> {
let worker_count = sftp_directory_file_concurrency(inventory.files.len(), concurrency);
let (_, requested_pipeline_depth, _) = sftp_pipeline_config(transfer_settings);
let effective_pipeline_depth = sftp_directory_download_pipeline_cap(
inventory.max_open_handles,
concurrency.session_pool_size,
@@ -730,6 +737,8 @@ pub(super) async fn run_download_directory_workers(
&transfer_settings,
&completed_bytes,
total_size,
request_kib,
requested_pipeline_depth,
effective_pipeline_depth,
&path_cache,
)
@@ -809,6 +818,7 @@ pub(super) async fn run_upload_directory_workers(
inventory: LocalDirectoryInventory,
directory_controller: Arc<TransferController>,
transfer_settings: &crate::config::TransferSettings,
request_kib: usize,
concurrency: SftpDirectoryConcurrency,
) -> AppResult<DirectoryTransferSummary> {
let worker_count = sftp_directory_file_concurrency(inventory.files.len(), concurrency);
@@ -855,6 +865,7 @@ pub(super) async fn run_upload_directory_workers(
&transfer_settings,
&completed_bytes,
total_size,
request_kib,
)
.await?;
let completed = completed_count.fetch_add(1, Ordering::SeqCst) + 1;
@@ -934,6 +945,8 @@ pub(super) async fn download_directory_file_with_session(
transfer_settings: &crate::config::TransferSettings,
completed_bytes: &Arc<AtomicU64>,
total_size: u64,
request_kib: usize,
pipeline_depth: usize,
max_pipeline_depth: usize,
path_cache: &RwLock<HashMap<String, Vec<u8>>>,
) -> AppResult<u64> {
@@ -959,7 +972,6 @@ pub(super) async fn download_directory_file_with_session(
}
let mut bytes_transferred = 0u64;
let (request_kib, pipeline_depth, _) = sftp_pipeline_config(transfer_settings);
let payload_bytes = sftp_payload_size(request_kib);
if file.size > 0 {
let app_for_progress = app.clone();
@@ -1029,6 +1041,7 @@ pub(super) async fn upload_directory_file_with_session(
transfer_settings: &crate::config::TransferSettings,
completed_bytes: &Arc<AtomicU64>,
total_size: u64,
request_kib: usize,
) -> AppResult<u64> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -1046,7 +1059,6 @@ pub(super) async fn upload_directory_file_with_session(
))
})?;
let (request_kib, _, _) = sftp_pipeline_config(transfer_settings);
let mut buf = vec![0u8; sftp_payload_size(request_kib)];
let mut bytes_transferred = 0u64;
let mut last_progress = Instant::now();
@@ -355,7 +355,8 @@ pub(super) async fn download_remote_file_inner_with_controller(
&controller.build_event("started", 0, None),
);
let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(ts);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(ts, backend.pipeline_depth_override);
let chunk_size = sftp_payload_size(request_kib) as u64;
let transfer_started = Instant::now();
@@ -550,7 +551,8 @@ pub(super) async fn upload_local_file_inner_with_controller(
&controller.build_event("started", 0, None),
);
let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(ts);
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(ts, backend.pipeline_depth_override);
let chunk_size = sftp_payload_size(request_kib);
let transfer_started = Instant::now();
+28 -3
View File
@@ -166,6 +166,17 @@ impl RemoteFs for SftpBackend {
let raw_path = self.remote_path_bytes(path);
let attrs = sftp.symlink_metadata_bytes(raw_path.clone()).await?;
let is_symlink = sftp_attrs_is_symlink(&attrs);
let symlink_target = if is_symlink {
match sftp.read_link_bytes(raw_path.clone()).await {
Ok(target) => Some(self.decode_path_from_sftp(&target)),
Err(error) => {
let _ = sftp.close().await;
return Err(error.into());
}
}
} else {
None
};
let target_attrs = if is_symlink {
sftp.metadata_bytes(raw_path).await.ok()
} else {
@@ -213,6 +224,7 @@ impl RemoteFs for SftpBackend {
name,
is_dir,
is_symlink,
symlink_target,
size: attrs.size.unwrap_or(0),
permissions,
owner,
@@ -326,8 +338,21 @@ impl RemoteFs for SftpBackend {
}
async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()> {
let link_path = RemotePathRef::new(link_path, None)?;
self.create_symlink_ref(&link_path, target_path).await
}
async fn create_symlink_ref(
&self,
link_path: &RemotePathRef,
target_path: &str,
) -> AppResult<()> {
let sftp = self.open_sftp().await?;
sftp.symlink_openssh(target_path, link_path).await?;
sftp.symlink_openssh_bytes(
self.encode_path_for_sftp(target_path),
self.remote_path_bytes(link_path),
)
.await?;
let _ = sftp.close().await;
Ok(())
}
@@ -802,7 +827,7 @@ impl RemoteFs for SftpBackend {
.map(|s| s.transfer)
.unwrap_or_default();
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&transfer_settings);
sftp_pipeline_config(&transfer_settings, self.pipeline_depth_override);
let transfer_started = Instant::now();
let directory_controller = create_directory_transfer_controller(
transfer_id,
@@ -901,7 +926,7 @@ impl RemoteFs for SftpBackend {
let _ = sftp_for_check.close().await;
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(transfer_settings);
sftp_pipeline_config(transfer_settings, self.pipeline_depth_override);
let transfer_started = Instant::now();
let directory_controller = create_directory_transfer_controller(
transfer_id,
+6 -1
View File
@@ -3,12 +3,17 @@
use super::*;
impl SftpBackend {
pub(crate) fn new(ssh_handle: Arc<SshConnectionHandles>, encoding: &str) -> Self {
pub(crate) fn new(
ssh_handle: Arc<SshConnectionHandles>,
encoding: &str,
pipeline_depth_override: Option<u32>,
) -> Self {
Self {
ssh_handle,
identity_cache: Arc::new(RwLock::new(RemoteIdentityCache::default())),
path_cache: Arc::new(RwLock::new(HashMap::new())),
encoding: encoding.to_string(),
pipeline_depth_override,
}
}
@@ -193,6 +193,48 @@ fn write_text_permission_restore_skips_missing_permission_attrs() {
assert_eq!(permissions_to_preserve_after_write(None), None);
}
fn transfer_settings_with_request_kib(request_kib: u32) -> crate::config::TransferSettings {
crate::config::TransferSettings {
transfer_buffer_size: request_kib,
..crate::config::TransferSettings::default()
}
}
#[test]
fn automatic_pipeline_config_keeps_existing_depths() {
let cases = [(64, 16, 16), (128, 8, 16), (256, 4, 8)];
for (request_kib, expected_download_depth, expected_concurrent_writes) in cases {
let settings = transfer_settings_with_request_kib(request_kib);
assert_eq!(
sftp_pipeline_config(&settings, None),
(
request_kib as usize,
expected_download_depth,
expected_concurrent_writes,
)
);
}
}
#[test]
fn manual_pipeline_depth_overrides_downloads_and_uploads() {
for request_kib in [64, 128, 256] {
let settings = transfer_settings_with_request_kib(request_kib);
assert_eq!(
sftp_pipeline_config(&settings, Some(32)),
(request_kib as usize, 32, 32)
);
}
}
#[test]
fn manual_pipeline_depth_is_defensively_clamped() {
let settings = transfer_settings_with_request_kib(128);
assert_eq!(sftp_pipeline_config(&settings, Some(1)), (128, 4, 4));
assert_eq!(sftp_pipeline_config(&settings, Some(128)), (128, 64, 64));
}
#[test]
fn directory_concurrency_keeps_at_least_one_worker() {
let concurrency = sftp_directory_concurrency(Some(2));
@@ -215,6 +257,14 @@ fn directory_pipeline_respects_server_handle_budget() {
);
}
#[test]
fn directory_pipeline_caps_manual_depth_to_server_handle_budget() {
assert_eq!(
sftp_directory_download_pipeline_cap(Some(128), 2, 16, 64),
15
);
}
#[test]
fn directory_pipeline_never_returns_zero() {
assert_eq!(sftp_directory_download_pipeline_cap(Some(2), 2, 16, 16), 1);
+377
View File
@@ -43,6 +43,21 @@ pub(crate) trait RemoteFs: Send + Sync {
}
async fn create_file(&self, path: &str, mode: Option<String>) -> AppResult<()>;
async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()>;
async fn create_symlink_ref(
&self,
link_path: &RemotePathRef,
target_path: &str,
) -> AppResult<()> {
self.create_symlink(link_path.display_path(), target_path)
.await
}
async fn update_symlink_target_ref(
&self,
path: &RemotePathRef,
target_path: &str,
) -> AppResult<()> {
replace_symlink_target(&RemoteFsSymlinkOps(self), path, target_path).await
}
async fn update_attrs(&self, path: &str, update: &RemoteFileAttributeUpdate) -> AppResult<()>;
async fn update_attrs_ref(
&self,
@@ -124,3 +139,365 @@ pub(crate) trait RemoteFs: Send + Sync {
parent_controller: Option<Arc<TransferController>>,
) -> AppResult<u64>;
}
#[async_trait::async_trait]
trait SymlinkReplacementOps: Send + Sync {
async fn stat(&self, path: &RemotePathRef) -> AppResult<FileProperties>;
async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()>;
async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()>;
async fn remove(&self, path: &RemotePathRef) -> AppResult<()>;
}
struct RemoteFsSymlinkOps<'a, T: RemoteFs + ?Sized>(&'a T);
#[async_trait::async_trait]
impl<T: RemoteFs + ?Sized> SymlinkReplacementOps for RemoteFsSymlinkOps<'_, T> {
async fn stat(&self, path: &RemotePathRef) -> AppResult<FileProperties> {
self.0.stat_ref(path).await
}
async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> {
self.0.create_symlink_ref(path, target_path).await
}
async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()> {
self.0.rename_ref(old_path, new_path).await
}
async fn remove(&self, path: &RemotePathRef) -> AppResult<()> {
self.0.remove_file_ref(path).await
}
}
async fn ensure_symlink(
fs: &(impl SymlinkReplacementOps + ?Sized),
path: &RemotePathRef,
) -> AppResult<()> {
let properties = fs.stat(path).await.map_err(|error| {
crate::error::AppError::Channel(format!(
"Failed to verify symbolic link '{}': {error}",
path.display_path()
))
})?;
if properties.is_symlink {
Ok(())
} else {
Err(crate::error::AppError::Config(format!(
"Remote path '{}' is no longer a symbolic link; refusing to replace it",
path.display_path()
)))
}
}
async fn cleanup_after_failure(
fs: &(impl SymlinkReplacementOps + ?Sized),
path: &RemotePathRef,
primary_error: crate::error::AppError,
) -> crate::error::AppError {
match fs.remove(path).await {
Ok(()) => primary_error,
Err(cleanup_error) => crate::error::AppError::Channel(format!(
"{primary_error}; cleanup of '{}' also failed: {cleanup_error}",
path.display_path()
)),
}
}
async fn replace_symlink_target(
fs: &(impl SymlinkReplacementOps + ?Sized),
original: &RemotePathRef,
target_path: &str,
) -> AppResult<()> {
if target_path.trim().is_empty() {
return Err(crate::error::AppError::Config(
"Symbolic link target cannot be empty".to_string(),
));
}
ensure_symlink(fs, original).await?;
let suffix = uuid::Uuid::new_v4().simple().to_string();
let temp = original.sibling(&format!(".nyaterm-link-{suffix}"));
let backup = original.sibling(&format!(".nyaterm-backup-{suffix}"));
fs.create(&temp, target_path).await.map_err(|error| {
crate::error::AppError::Channel(format!(
"Failed to create temporary symbolic link '{}': {error}",
temp.display_path()
))
})?;
if let Err(error) = ensure_symlink(fs, original).await {
return Err(cleanup_after_failure(fs, &temp, error).await);
}
if let Err(error) = fs.rename(original, &backup).await {
let error = crate::error::AppError::Channel(format!(
"Failed to move original symbolic link '{}' to backup: {error}",
original.display_path()
));
return Err(cleanup_after_failure(fs, &temp, error).await);
}
if let Err(replacement_error) = fs.rename(&temp, original).await {
return match fs.rename(&backup, original).await {
Ok(()) => {
let error = crate::error::AppError::Channel(format!(
"Failed to replace symbolic link '{}': {replacement_error}; the original link was restored",
original.display_path()
));
Err(cleanup_after_failure(fs, &temp, error).await)
}
Err(rollback_error) => Err(crate::error::AppError::Channel(format!(
"Failed to replace symbolic link '{}': {replacement_error}; rollback from '{}' also failed: {rollback_error}",
original.display_path(),
backup.display_path()
))),
};
}
if let Err(error) = fs.remove(&backup).await {
tracing::warn!(
original_path = original.display_path(),
backup_path = backup.display_path(),
error = %error,
"Symbolic link target was updated, but backup cleanup failed"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::AppError;
use std::collections::HashSet;
use std::sync::Mutex;
struct RecordingOps {
actions: Mutex<Vec<String>>,
failures: Mutex<HashSet<String>>,
is_symlink: bool,
}
impl Default for RecordingOps {
fn default() -> Self {
Self {
actions: Mutex::new(Vec::new()),
failures: Mutex::new(HashSet::new()),
is_symlink: true,
}
}
}
impl RecordingOps {
fn failing(actions: &[&str]) -> Self {
Self {
actions: Mutex::new(Vec::new()),
failures: Mutex::new(actions.iter().map(|value| (*value).to_string()).collect()),
is_symlink: true,
}
}
fn regular_file() -> Self {
Self {
is_symlink: false,
..Self::default()
}
}
fn record(&self, action: String, failure_key: &str) -> AppResult<()> {
self.actions.lock().unwrap().push(action);
if self.failures.lock().unwrap().contains(failure_key) {
Err(AppError::Channel(format!("injected {failure_key} failure")))
} else {
Ok(())
}
}
fn actions(&self) -> Vec<String> {
self.actions.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl SymlinkReplacementOps for RecordingOps {
async fn stat(&self, path: &RemotePathRef) -> AppResult<FileProperties> {
self.record(format!("stat:{}", path.display_path()), "stat")?;
Ok(FileProperties {
name: "current".to_string(),
is_dir: false,
is_symlink: self.is_symlink,
symlink_target: self.is_symlink.then(|| "releases/v2".to_string()),
size: 0,
permissions: "lrwxrwxrwx".to_string(),
owner: "root".to_string(),
group: "root".to_string(),
uid: "0".to_string(),
gid: "0".to_string(),
mtime: 0,
atime: 0,
})
}
async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> {
self.record(
format!("create:{}->{target_path}", path.display_path()),
"create",
)
}
async fn rename(
&self,
old_path: &RemotePathRef,
new_path: &RemotePathRef,
) -> AppResult<()> {
let failure_key = if old_path.display_path().contains("nyaterm-link") {
"commit"
} else if old_path.display_path().contains("nyaterm-backup") {
"rollback"
} else {
"backup"
};
self.record(
format!(
"rename:{}->{}",
old_path.display_path(),
new_path.display_path()
),
failure_key,
)
}
async fn remove(&self, path: &RemotePathRef) -> AppResult<()> {
let failure_key = if path.display_path().contains("nyaterm-backup") {
"remove_backup"
} else {
"remove_temp"
};
self.record(format!("remove:{}", path.display_path()), failure_key)
}
}
fn original() -> RemotePathRef {
RemotePathRef::new("/opt/app/current", None).unwrap()
}
#[tokio::test]
async fn replacement_creates_temp_before_moving_original() {
let ops = RecordingOps::default();
replace_symlink_target(&ops, &original(), " releases/v3 ")
.await
.unwrap();
let actions = ops.actions();
let create = actions
.iter()
.position(|action| action.starts_with("create:"))
.unwrap();
let backup = actions
.iter()
.position(|action| action.starts_with("rename:/opt/app/current->"))
.unwrap();
assert!(create < backup);
assert!(actions[create].ends_with("-> releases/v3 "));
assert!(
actions
.last()
.unwrap()
.starts_with("remove:/opt/app/.nyaterm-backup-")
);
}
#[tokio::test]
async fn missing_or_non_symlink_original_is_never_recreated() {
let missing = RecordingOps::failing(&["stat"]);
replace_symlink_target(&missing, &original(), "releases/v3")
.await
.unwrap_err();
assert_eq!(missing.actions(), ["stat:/opt/app/current"]);
let regular = RecordingOps::regular_file();
let error = replace_symlink_target(&regular, &original(), "releases/v3")
.await
.unwrap_err();
assert!(error.to_string().contains("no longer a symbolic link"));
assert_eq!(regular.actions(), ["stat:/opt/app/current"]);
}
#[tokio::test]
async fn temporary_link_failure_leaves_original_untouched() {
let ops = RecordingOps::failing(&["create"]);
replace_symlink_target(&ops, &original(), "releases/v3")
.await
.unwrap_err();
let actions = ops.actions();
assert_eq!(actions.len(), 2);
assert_eq!(actions[0], "stat:/opt/app/current");
assert!(actions[1].starts_with("create:/opt/app/.nyaterm-link-"));
}
#[tokio::test]
async fn replacement_failure_rolls_back_original() {
let ops = RecordingOps::failing(&["commit"]);
let error = replace_symlink_target(&ops, &original(), "releases/v3")
.await
.unwrap_err();
let actions = ops.actions();
assert!(error.to_string().contains("original link was restored"));
assert!(actions.iter().any(|action| {
action.starts_with("rename:/opt/app/.nyaterm-backup-")
&& action.ends_with("->/opt/app/current")
}));
assert!(
actions
.last()
.unwrap()
.starts_with("remove:/opt/app/.nyaterm-link-")
);
}
#[tokio::test]
async fn replacement_and_rollback_failures_are_both_reported() {
let ops = RecordingOps::failing(&["commit", "rollback"]);
let error = replace_symlink_target(&ops, &original(), "releases/v3")
.await
.unwrap_err()
.to_string();
assert!(error.contains("injected commit failure"));
assert!(error.contains("injected rollback failure"));
}
#[tokio::test]
async fn failed_backup_move_cleans_temp_without_committing() {
let ops = RecordingOps::failing(&["backup"]);
replace_symlink_target(&ops, &original(), "releases/v3")
.await
.unwrap_err();
let actions = ops.actions();
assert!(
actions
.last()
.unwrap()
.starts_with("remove:/opt/app/.nyaterm-link-")
);
assert!(!actions.iter().any(|action| {
action.starts_with("rename:/opt/app/.nyaterm-link-")
&& action.ends_with("->/opt/app/current")
}));
}
#[tokio::test]
async fn backup_cleanup_failure_does_not_undo_successful_replacement() {
let ops = RecordingOps::failing(&["remove_backup"]);
replace_symlink_target(&ops, &original(), "missing-release")
.await
.unwrap();
assert!(
ops.actions()
.last()
.unwrap()
.starts_with("remove:/opt/app/.nyaterm-backup-")
);
}
}
+57
View File
@@ -59,6 +59,43 @@ impl RemotePathRef {
pub(crate) fn raw_path(&self) -> Option<&[u8]> {
self.raw_path.as_deref()
}
pub(crate) fn sibling(&self, file_name: &str) -> Self {
let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes())
.map_or_else(
|| file_name.to_string(),
|bytes| String::from_utf8_lossy(&bytes).into_owned(),
);
let raw_path = self
.raw_path()
.and_then(|path| sibling_path(path, file_name.as_bytes()));
Self {
display_path,
raw_path,
}
}
}
fn sibling_path(path: &[u8], file_name: &[u8]) -> Option<Vec<u8>> {
if file_name.is_empty() {
return None;
}
let parent = path
.iter()
.rposition(|byte| *byte == b'/')
.map(|index| &path[..index])
.unwrap_or_default();
let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1);
if parent.is_empty() {
if path.starts_with(b"/") {
sibling.push(b'/');
}
} else {
sibling.extend_from_slice(parent);
sibling.push(b'/');
}
sibling.extend_from_slice(file_name);
Some(sibling)
}
pub(crate) fn raw_path_token(raw_path: &[u8]) -> String {
@@ -76,6 +113,7 @@ pub struct FileProperties {
pub name: String,
pub is_dir: bool,
pub is_symlink: bool,
pub symlink_target: Option<String>,
pub size: u64,
pub permissions: String,
pub owner: String,
@@ -533,6 +571,24 @@ mod tests {
assert_eq!(path_ref.raw_path().unwrap(), raw_path);
}
#[test]
fn remote_path_sibling_preserves_raw_parent_bytes() {
let raw_path = b"/remote/\x80dir/\x81link";
let token = raw_path_token(raw_path);
let path_ref =
RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap();
let sibling = path_ref.sibling(".nyaterm-link-test");
assert_eq!(
sibling.raw_path().unwrap(),
b"/remote/\x80dir/.nyaterm-link-test"
);
assert_eq!(
sibling.display_path(),
"/remote/display-dir/.nyaterm-link-test"
);
}
#[test]
fn percent_encodes_windows_invalid_characters() {
assert_eq!(
@@ -674,6 +730,7 @@ mod tests {
name: "file".to_string(),
is_dir: false,
is_symlink: false,
symlink_target: None,
size: 0,
permissions: permissions.to_string(),
owner: owner.to_string(),
File diff suppressed because it is too large Load Diff
+7
View File
@@ -167,6 +167,8 @@ pub fn run() {
cmd::mcp::notify_mcp_session_restore_complete,
cmd::mcp::set_external_mcp_enabled,
cmd::mcp::respond_external_mcp_approval,
cmd::mcp::report_mcp_active_session,
cmd::mcp::respond_mcp_session_open,
cmd::mcp::get_external_mcp_client_configs,
cmd::ai::detect_claude_code_cli,
cmd::ai::get_claude_code_account_status,
@@ -280,6 +282,7 @@ pub fn run() {
cmd::sftp::create_remote_file,
cmd::sftp::create_remote_dir,
cmd::sftp::create_remote_symlink,
cmd::sftp::update_remote_symlink_target,
cmd::sftp::chmod_remote_file,
cmd::sftp::update_remote_file_attributes,
cmd::sftp::download_remote_directory,
@@ -350,6 +353,10 @@ pub fn run() {
cmd::translate::translate_text,
cmd::importer::import_sessions,
cmd::importer::import_termius_sessions,
cmd::ssh_config::list_ssh_config_hosts,
cmd::ssh_config::get_ssh_config,
cmd::ssh_config::resolve_ssh_host,
cmd::ssh_config::import_ssh_config_hosts,
cmd::backup::export_config,
cmd::backup::import_config,
cmd::stats::get_remote_stats,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "NyaTerm",
"version": "1.2.5",
"version": "1.2.6",
"identifier": "com.kang.nyaterm",
"build": {
"beforeDevCommand": "pnpm build:mcp-sidecar && pnpm dev",
+48
View File
@@ -935,6 +935,25 @@ impl RawSftpSession {
ReadLink {
id,
path: path.into(),
path_bytes: None,
}
.into(),
)
.await?;
into_with_status!(result, Name)
}
pub async fn readlink_bytes(&self, path_bytes: Vec<u8>) -> SftpResult<Name> {
let id = self.use_next_id();
let path = String::from_utf8_lossy(&path_bytes).into_owned();
let result = self
.request(
Some(id),
ReadLink {
id,
path,
path_bytes: Some(path_bytes),
}
.into(),
)
@@ -956,6 +975,8 @@ impl RawSftpSession {
id,
linkpath: path.into(),
targetpath: target.into(),
linkpath_bytes: None,
targetpath_bytes: None,
}
.into(),
)
@@ -977,6 +998,33 @@ impl RawSftpSession {
id,
linkpath: target.into(),
targetpath: link.into(),
linkpath_bytes: None,
targetpath_bytes: None,
}
.into(),
)
.await?;
into_status!(result)
}
pub async fn symlink_openssh_bytes(
&self,
target_bytes: Vec<u8>,
link_bytes: Vec<u8>,
) -> SftpResult<Status> {
let id = self.use_next_id();
let linkpath = String::from_utf8_lossy(&target_bytes).into_owned();
let targetpath = String::from_utf8_lossy(&link_bytes).into_owned();
let result = self
.request(
Some(id),
Symlink {
id,
linkpath,
targetpath,
linkpath_bytes: Some(target_bytes),
targetpath_bytes: Some(link_bytes),
}
.into(),
)
+21
View File
@@ -311,6 +311,15 @@ impl SftpSession {
}
}
/// Reads a symbolic link using raw bytes for the link path and target.
pub async fn read_link_bytes(&self, path_bytes: Vec<u8>) -> SftpResult<Vec<u8>> {
let name = self.session.readlink_bytes(path_bytes).await?;
match name.files.first() {
Some(file) => Ok(file.filename_bytes.clone()),
None => Err(Error::UnexpectedBehavior("no file".to_owned())),
}
}
/// Removes the specified folder.
pub async fn remove_dir<P: Into<String>>(&self, path: P) -> SftpResult<()> {
self.session.rmdir(path).await.map(|_| ())
@@ -373,6 +382,18 @@ impl SftpSession {
self.session.symlink_openssh(target, link).await.map(|_| ())
}
/// Creates an OpenSSH-compatible symlink using raw bytes for both paths.
pub async fn symlink_openssh_bytes(
&self,
target_bytes: Vec<u8>,
link_bytes: Vec<u8>,
) -> SftpResult<()> {
self.session
.symlink_openssh_bytes(target_bytes, link_bytes)
.await
.map(|_| ())
}
/// Queries metadata about the remote file.
pub async fn metadata<P: Into<String>>(&self, path: P) -> SftpResult<Metadata> {
Ok(self.session.stat(path).await?.attrs)
+78 -1
View File
@@ -1,11 +1,88 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::{impl_packet_for, impl_request_id, Packet, RequestId};
/// Implementation for `SSH_FXP_READLINK`
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug)]
pub struct ReadLink {
pub id: u32,
pub path: String,
/// Raw bytes of the path, preserving its original encoding.
pub path_bytes: Option<Vec<u8>>,
}
impl Serialize for ReadLink {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("ReadLink", 2)?;
state.serialize_field("id", &self.id)?;
match &self.path_bytes {
Some(bytes) => state.serialize_field("path", bytes)?,
None => state.serialize_field("path", &self.path)?,
}
state.end()
}
}
impl<'de> Deserialize<'de> for ReadLink {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, SeqAccess, Visitor};
use std::fmt;
struct ReadLinkVisitor;
impl<'de> Visitor<'de> for ReadLinkVisitor {
type Value = ReadLink;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("struct ReadLink")
}
fn visit_seq<A>(self, mut seq: A) -> Result<ReadLink, A::Error>
where
A: SeqAccess<'de>,
{
let id = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let path_bytes: Vec<u8> = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(1, &self))?;
Ok(ReadLink {
id,
path: String::from_utf8_lossy(&path_bytes).into_owned(),
path_bytes: Some(path_bytes),
})
}
}
deserializer.deserialize_struct("ReadLink", &["id", "path"], ReadLinkVisitor)
}
}
impl_request_id!(ReadLink);
impl_packet_for!(ReadLink);
#[cfg(test)]
mod tests {
use super::ReadLink;
#[test]
fn serializes_raw_path_bytes_without_lossy_conversion() {
let raw_path = b"/remote/\x80link".to_vec();
let bytes = crate::ser::to_bytes(&ReadLink {
id: 7,
path: "/remote/display-link".to_string(),
path_bytes: Some(raw_path.clone()),
})
.unwrap();
assert!(bytes.ends_with(&raw_path));
assert!(!bytes.ends_with(b"/remote/display-link"));
}
}
+103 -1
View File
@@ -1,12 +1,114 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::{impl_packet_for, impl_request_id, Packet, RequestId};
/// Implementation for `SSH_FXP_SYMLINK`
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug)]
pub struct Symlink {
pub id: u32,
pub linkpath: String,
pub targetpath: String,
/// Raw bytes of `linkpath`, preserving its original encoding.
pub linkpath_bytes: Option<Vec<u8>>,
/// Raw bytes of `targetpath`, preserving its original encoding.
pub targetpath_bytes: Option<Vec<u8>>,
}
impl Serialize for Symlink {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("Symlink", 3)?;
state.serialize_field("id", &self.id)?;
match &self.linkpath_bytes {
Some(bytes) => state.serialize_field("linkpath", bytes)?,
None => state.serialize_field("linkpath", &self.linkpath)?,
}
match &self.targetpath_bytes {
Some(bytes) => state.serialize_field("targetpath", bytes)?,
None => state.serialize_field("targetpath", &self.targetpath)?,
}
state.end()
}
}
impl<'de> Deserialize<'de> for Symlink {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, SeqAccess, Visitor};
use std::fmt;
struct SymlinkVisitor;
impl<'de> Visitor<'de> for SymlinkVisitor {
type Value = Symlink;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("struct Symlink")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Symlink, A::Error>
where
A: SeqAccess<'de>,
{
let id = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let linkpath_bytes: Vec<u8> = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(1, &self))?;
let targetpath_bytes: Vec<u8> = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(2, &self))?;
Ok(Symlink {
id,
linkpath: String::from_utf8_lossy(&linkpath_bytes).into_owned(),
targetpath: String::from_utf8_lossy(&targetpath_bytes).into_owned(),
linkpath_bytes: Some(linkpath_bytes),
targetpath_bytes: Some(targetpath_bytes),
})
}
}
deserializer.deserialize_struct(
"Symlink",
&["id", "linkpath", "targetpath"],
SymlinkVisitor,
)
}
}
impl_request_id!(Symlink);
impl_packet_for!(Symlink);
#[cfg(test)]
mod tests {
use super::Symlink;
#[test]
fn serializes_raw_target_and_link_bytes() {
let raw_target = b"../release/\x81".to_vec();
let raw_link = b"/remote/\x80current".to_vec();
let bytes = crate::ser::to_bytes(&Symlink {
id: 9,
linkpath: "display-target".to_string(),
targetpath: "display-link".to_string(),
linkpath_bytes: Some(raw_target.clone()),
targetpath_bytes: Some(raw_link.clone()),
})
.unwrap();
let target_pos = bytes
.windows(raw_target.len())
.position(|window| window == raw_target)
.unwrap();
let link_pos = bytes
.windows(raw_link.len())
.position(|window| window == raw_link)
.unwrap();
assert!(target_pos < link_pos);
}
}
+102 -12
View File
@@ -6,8 +6,8 @@ import { toast } from "sonner";
import AppLayout from "./components/app/AppLayout";
import AppPanelContent from "./components/app/AppPanelContent";
import ActivityBarResetDialog from "./components/dialog/app/ActivityBarResetDialog";
import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost";
import AppOverlayDialogs from "./components/dialog/app/AppOverlayDialogs";
import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost";
import type { HostKeyVerifyRequest } from "./components/dialog/connections/HostKeyVerifyDialog";
import type { OtpRequest } from "./components/dialog/connections/OtpDialog";
import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog";
@@ -23,6 +23,7 @@ import { useFileDocumentCloseGuard } from "./hooks/useFileDocumentCloseGuard";
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useIdleLock } from "./hooks/useIdleLock";
import { useMacSelectionGuard } from "./hooks/useMacSelectionGuard";
import { useMcpActiveSession } from "./hooks/useMcpActiveSession";
import { useModalChildWindows } from "./hooks/useModalChildWindows";
import { useRemoteGpuOverview } from "./hooks/useRemoteGpuOverview";
import { useRemoteNpuOverview } from "./hooks/useRemoteNpuOverview";
@@ -30,6 +31,7 @@ import { useRemoteStats } from "./hooks/useRemoteStats";
import { useSecurityPromptQueue } from "./hooks/useSecurityPromptQueue";
import { useSessionRuntimeState } from "./hooks/useSessionRuntimeState";
import { resolveDisplayKeys } from "./hooks/useShortcutMap";
import { useFileEditorZoom } from "./hooks/useFileEditorZoom";
import { useTerminalZoom } from "./hooks/useTerminalZoom";
import { useTabStatusIndicators } from "./hooks/useUnreadTabs";
import { AI_OPEN_EVENT, type AIOpenIntent } from "./lib/aiEvents";
@@ -57,15 +59,15 @@ import {
} from "./lib/appSessionFactory";
import {
buildPanelOpenUpdate,
canUseFloatingPanel,
canCreateSessionFromPane,
canUseFloatingPanel,
clearUnavailableFloatingPanels,
collectActiveNonSerialSessionIds,
EXCLUSIVE_PANEL_IDS,
type FloatingPanelsState,
getItemSide,
getSideOpenPanels,
getSideOverlayPanel,
getItemSide,
getVisibleActivityIds,
hasLiveSession,
isActivityItemAvailable,
@@ -155,6 +157,8 @@ import type {
AppSettings,
AssetMetadata,
CloudConflictPreview,
McpSessionOpenCancel,
McpSessionOpenRequest,
PaneSplitDirection,
RecordingMode,
SavedConnection,
@@ -968,6 +972,9 @@ function App() {
options?: {
failureContext?: string;
runtimeModeOverride?: SshRuntimeMode;
propagateError?: boolean;
onPending?: (pending: { tabId: string; createRequestId: string }) => void;
onSuccess?: (sessionId: string) => void;
},
) => {
const pending = addPendingTab(
@@ -979,6 +986,7 @@ function App() {
{ display: getRemoteDesktopPaneDisplay(connection) },
);
const { tabId, createRequestId } = pending;
options?.onPending?.({ tabId, createRequestId });
try {
const sessionId = await createSessionForConnection(
@@ -995,8 +1003,10 @@ function App() {
focusTerminalSession(sessionId);
recordRecentConnection(connection.id);
updateAutoIconForSessionStart(connection.id, sessionId);
options?.onSuccess?.(sessionId);
} catch (error) {
if (isSessionCreationCancelled(error) || !hasTab(tabId)) {
if (options?.propagateError) throw error;
return;
}
const errorMessage = getErrorMessage(error);
@@ -1012,6 +1022,7 @@ function App() {
sourceTabId: tabId,
});
toast.error(t("savedConnections.connectionFailed", { error: errorMessage }));
if (options?.propagateError) throw error;
}
},
[
@@ -1026,6 +1037,89 @@ function App() {
],
);
const mcpSessionOpenRequestsRef = useRef(
new Map<string, { tabId: string; createRequestId: string }>(),
);
const cancelledMcpSessionOpenRequestsRef = useRef(new Set<string>());
useEffect(() => {
let disposed = false;
let unlistenOpen: (() => void) | undefined;
let unlistenCancel: (() => void) | undefined;
void listen<McpSessionOpenRequest>("mcp-session-open-request", ({ payload }) => {
if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return;
void (async () => {
const connections = savedConnections.some((item) => item.id === payload.connectionId)
? savedConnections
: await invoke<SavedConnection[]>("get_saved_connections");
if (cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId)) return;
const connection = connections.find((item) => item.id === payload.connectionId);
if (!connection || connection.type === "rdp" || connection.type === "vnc") {
await invoke("respond_mcp_session_open", {
requestId: payload.requestId,
sessionId: null,
error: "The saved connection does not exist or is not a supported terminal connection.",
});
return;
}
let openedSessionId: string | null = null;
await connectSavedConnection(connection, {
failureContext: "MCP session open failed",
propagateError: true,
onPending: (pending) => {
mcpSessionOpenRequestsRef.current.set(payload.requestId, pending);
},
onSuccess: (sessionId) => {
openedSessionId = sessionId;
},
});
await invoke("respond_mcp_session_open", {
requestId: payload.requestId,
sessionId: openedSessionId,
error: openedSessionId ? null : "The MCP session-open request did not create a session.",
});
})()
.catch((error) => {
void invoke("respond_mcp_session_open", {
requestId: payload.requestId,
sessionId: null,
error: getErrorMessage(error),
}).catch(() => {});
})
.finally(() => {
mcpSessionOpenRequestsRef.current.delete(payload.requestId);
cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId);
});
}).then((dispose) => {
if (disposed) dispose();
else unlistenOpen = dispose;
});
void listen<McpSessionOpenCancel>("mcp-session-open-cancel", ({ payload }) => {
if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return;
const pending = mcpSessionOpenRequestsRef.current.get(payload.requestId);
if (!pending) {
cancelledMcpSessionOpenRequestsRef.current.add(payload.requestId);
return;
}
mcpSessionOpenRequestsRef.current.delete(payload.requestId);
closeTabs([pending.tabId]);
void invoke("cancel_session_creation", {
createRequestId: pending.createRequestId,
}).catch(() => {});
}).then((dispose) => {
if (disposed) dispose();
else unlistenCancel = dispose;
});
return () => {
disposed = true;
unlistenOpen?.();
unlistenCancel?.();
};
}, [closeTabs, connectSavedConnection, savedConnections]);
const connectTemporaryConnection = useCallback(
async (config: TemporaryLinkConfig) => {
const pending = addPendingTab(
@@ -1066,18 +1160,11 @@ function App() {
const connectExternalLocalSession = useCallback(
async (workingDir: string | null) => {
const pending = addPendingTab(
t("menu.newLocalTerminal"),
"Local",
undefined,
);
const pending = addPendingTab(t("menu.newLocalTerminal"), "Local", undefined);
const { tabId, createRequestId } = pending;
try {
const sessionId = await createExternalLocalSession(
workingDir,
createRequestId,
);
const sessionId = await createExternalLocalSession(workingDir, createRequestId);
if (!hasTab(tabId)) {
await closeStaleCreatedSession(sessionId);
return;
@@ -1963,6 +2050,8 @@ function App() {
appSettings.interaction.terminal_zoom_enabled,
);
useFileEditorZoom(updateAppSettings);
const handleOpenSettings = useCallback(() => {
openSettings();
}, []);
@@ -3088,6 +3177,7 @@ function App() {
!activePane.connectError
? activePane.sessionId
: null;
useMcpActiveSession(activeSessionId);
const activeSshSessionId =
activePane &&
activePane.paneKind === "terminal" &&
@@ -92,9 +92,13 @@ export function McpApprovalHost() {
</div>
<div>
<span className="text-muted-foreground">
{t("ai.externalMcpSession")}:
{t("ai.externalMcpTarget")}:
</span>{" "}
{current.sessionName ?? current.sessionId ?? "-"}
{current.connectionName ??
current.connectionId ??
current.sessionName ??
current.sessionId ??
"-"}
</div>
<div>
<span className="text-muted-foreground">
@@ -31,8 +31,9 @@ interface ImportSource {
icon: string | ComponentType<{ className?: string }>;
extensions?: string[];
hint?: string;
type: "backup" | "sessions";
type: "backup" | "sessions" | "ssh_config";
picker?: "file" | "directory";
labelKey?: string;
}
const IMPORT_SOURCES: ImportSource[] = [
@@ -108,6 +109,14 @@ const IMPORT_SOURCES: ImportSource[] = [
hint: ".json",
type: "sessions",
},
{
id: "ssh_config",
name: "SSH Config",
icon: MdTerminal,
hint: "~/.ssh/config",
type: "ssh_config",
labelKey: "savedConnections.sshConfigSource",
},
];
const SESSION_IMPORT_DOC_URLS = {
@@ -170,6 +179,27 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
return;
}
if (source.id === "ssh_config") {
try {
const count = await invoke<number>("import_ssh_config_hosts");
if (count > 0) {
toast.success(t("savedConnections.importSuccess", { count }));
} else {
toast.info(t("savedConnections.importSuccess", { count: 0 }));
}
refreshConnections();
} catch (e) {
logger.error({
domain: "settings.persistence",
event: "sessions.import_ssh_config_failed",
message: "Import SSH config failed",
error: e,
});
toast.error(t("savedConnections.importFailed", { error: e }));
}
return;
}
if (source.id === "termius") {
try {
const count = await invoke<number>("import_termius_sessions", { indexedDbPath: null });
@@ -268,7 +298,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
>
{renderSourceIcon(source)}
<span className="text-xs font-medium" style={{ color: "var(--df-text)" }}>
{source.name}
{source.labelKey ? t(source.labelKey) : source.name}
</span>
{source.hint && (
<span
@@ -0,0 +1,177 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { invoke } from "@/lib/invoke";
import type { FileProperties } from "@/types/global";
import PropertiesDialog, {
type PropertiesDialogData,
} from "./PropertiesDialog";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() }));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
}));
const symlinkProperties: FileProperties = {
name: "current",
is_dir: true,
is_symlink: true,
symlink_target: "releases/v2",
size: 11,
permissions: "lrwxrwxrwx",
owner: "root",
group: "root",
uid: "0",
gid: "0",
mtime: 0,
atime: 0,
};
const remoteData: PropertiesDialogData = {
sessionId: "session-1",
backend: "remote",
fullPath: "/opt/app/current",
rawPathToken: "raw-current",
name: "current",
is_dir: true,
};
describe("PropertiesDialog symlink target", () => {
beforeEach(() => {
vi.mocked(invoke).mockReset();
});
it("shows the original target and identifies links before directories", async () => {
vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties);
render(<PropertiesDialog data={remoteData} onClose={vi.fn()} />);
const targetInput = await screen.findByRole("textbox", {
name: "fileExplorer.symlinkTarget",
});
expect((targetInput as HTMLInputElement).value).toBe("releases/v2");
expect(screen.getByText("fileExplorer.symbolicLink")).not.toBeNull();
expect(screen.queryByText("fileExplorer.folder")).toBeNull();
});
it("preserves target whitespace and saves it before attribute changes", async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(symlinkProperties)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const onClose = vi.fn();
const onSuccess = vi.fn();
render(
<PropertiesDialog
data={remoteData}
onClose={onClose}
onSuccess={onSuccess}
/>,
);
fireEvent.change(
await screen.findByRole("textbox", {
name: "fileExplorer.symlinkTarget",
}),
{ target: { value: " ../releases/v3 " } },
);
fireEvent.change(screen.getAllByDisplayValue("root")[0], {
target: { value: "deploy" },
});
fireEvent.click(screen.getByRole("button", { name: "dialog.save" }));
await waitFor(() => expect(invoke).toHaveBeenCalledTimes(3));
expect(invoke).toHaveBeenNthCalledWith(2, "update_remote_symlink_target", {
sessionId: "session-1",
path: "/opt/app/current",
rawPathToken: "raw-current",
targetPath: " ../releases/v3 ",
});
expect(invoke).toHaveBeenNthCalledWith(3, "update_remote_file_attributes", {
sessionId: "session-1",
path: "/opt/app/current",
rawPathToken: "raw-current",
update: {
mode: null,
owner: "deploy",
group: null,
recursive: false,
},
});
expect(onSuccess).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("rejects blank targets without sending an update", async () => {
vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties);
const { toast } = await import("sonner");
render(<PropertiesDialog data={remoteData} onClose={vi.fn()} />);
fireEvent.change(
await screen.findByRole("textbox", {
name: "fileExplorer.symlinkTarget",
}),
{ target: { value: " " } },
);
fireEvent.click(screen.getByRole("button", { name: "dialog.save" }));
expect(toast.error).toHaveBeenCalledWith(
"fileExplorer.symlinkTargetRequired",
);
expect(invoke).toHaveBeenCalledTimes(1);
});
it("closes without an update when nothing changed", async () => {
vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties);
const onClose = vi.fn();
render(<PropertiesDialog data={remoteData} onClose={onClose} />);
await screen.findByRole("textbox", { name: "fileExplorer.symlinkTarget" });
fireEvent.click(screen.getByRole("button", { name: "dialog.save" }));
expect(invoke).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledOnce();
});
it("does not expose target editing for local links or regular remote files", async () => {
vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties);
const { unmount } = render(
<PropertiesDialog
data={{ ...remoteData, backend: "local" }}
onClose={vi.fn()}
/>,
);
await screen.findByText("fileExplorer.symbolicLink");
expect(
screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }),
).toBeNull();
unmount();
vi.mocked(invoke).mockResolvedValueOnce({
...symlinkProperties,
is_dir: false,
is_symlink: false,
symlink_target: null,
});
render(
<PropertiesDialog
data={{ ...remoteData, name: "config", is_dir: false }}
onClose={vi.fn()}
/>,
);
await screen.findByText("fileExplorer.file");
expect(
screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }),
).toBeNull();
});
});
@@ -90,7 +90,11 @@ function parsePermissionsToOctal(perms: string): string {
return `${special}${u}${g}${o}`;
}
export default function PropertiesDialog({ data, onClose, onSuccess }: PropertiesDialogProps) {
export default function PropertiesDialog({
data,
onClose,
onSuccess,
}: PropertiesDialogProps) {
const { t } = useTranslation();
const [properties, setProperties] = useState<FileProperties | null>(null);
const [loading, setLoading] = useState(true);
@@ -99,12 +103,18 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
const [octal, setOctal] = useState<string>("0644");
const [ownerInput, setOwnerInput] = useState("");
const [groupInput, setGroupInput] = useState("");
const [symlinkTarget, setSymlinkTarget] = useState("");
const [recursive, setRecursive] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const initialOctal = properties ? parsePermissionsToOctal(properties.permissions) : "0644";
const initialOctal = properties
? parsePermissionsToOctal(properties.permissions)
: "0644";
const initialOwner = properties?.owner || properties?.uid || "";
const initialGroup = properties?.group || properties?.gid || "";
const initialSymlinkTarget = properties?.symlink_target ?? "";
const canEditAttributes = data.backend === "remote";
const canEditSymlinkTarget =
data.backend === "remote" && properties?.is_symlink === true;
useEffect(() => {
let isMounted = true;
@@ -128,6 +138,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
setOctal(parsePermissionsToOctal(props.permissions));
setOwnerInput(props.owner || props.uid || "");
setGroupInput(props.group || props.gid || "");
setSymlinkTarget(props.symlink_target ?? "");
setRecursive(false);
}
})
@@ -147,6 +158,10 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
const nextOwner = ownerInput.trim();
const nextGroup = groupInput.trim();
if (canEditSymlinkTarget && !symlinkTarget.trim()) {
toast.error(t("fileExplorer.symlinkTargetRequired"));
return;
}
if (!nextOwner || !nextGroup) {
toast.error(t("fileExplorer.ownerGroupRequired"));
return;
@@ -158,21 +173,40 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
group: nextGroup !== initialGroup ? nextGroup : null,
recursive: data.is_dir && recursive,
};
const symlinkTargetChanged =
canEditSymlinkTarget && symlinkTarget !== initialSymlinkTarget;
const attributesChanged = !!(update.mode || update.owner || update.group);
if (!update.mode && !update.owner && !update.group) {
if (!attributesChanged && !symlinkTargetChanged) {
onClose();
return;
}
setIsSaving(true);
try {
await invoke("update_remote_file_attributes", {
sessionId: data.sessionId,
path: data.fullPath,
rawPathToken: data.rawPathToken,
update,
});
toast.success(t("fileExplorer.propertiesSaved"));
if (symlinkTargetChanged) {
await invoke("update_remote_symlink_target", {
sessionId: data.sessionId,
path: data.fullPath,
rawPathToken: data.rawPathToken,
targetPath: symlinkTarget,
});
}
if (attributesChanged) {
await invoke("update_remote_file_attributes", {
sessionId: data.sessionId,
path: data.fullPath,
rawPathToken: data.rawPathToken,
update,
});
}
toast.success(
t(
symlinkTargetChanged
? "fileExplorer.symlinkTargetSaved"
: "fileExplorer.propertiesSaved",
),
);
await onSuccess?.();
onClose();
} catch (e) {
@@ -201,6 +235,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
};
const getFileType = () => {
if (properties?.is_symlink) return t("fileExplorer.symbolicLink");
if (data.is_dir) return t("fileExplorer.folder");
const ext = data.name.split(".").pop()?.toLowerCase();
if (ext === "sh" || ext === "bash") return t("fileExplorer.shellScript");
@@ -212,19 +247,29 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
};
return (
<Dialog disablePointerDismissal open onOpenChange={(v) => !v && !isSaving && onClose()}>
<Dialog
disablePointerDismissal
open
onOpenChange={(v) => !v && !isSaving && onClose()}
>
<DialogContent className="w-[min(460px,calc(100vw-2rem))] sm:max-w-[460px] p-0 gap-0">
<DialogHeader className="pl-5 pr-12 py-3 border-b">
<DialogTitle className="text-sm flex items-center gap-2 min-w-0">
{data.is_dir ? (
<MdFolder className="text-lg shrink-0" style={{ color: "#eab308" }} />
<MdFolder
className="text-lg shrink-0"
style={{ color: "#eab308" }}
/>
) : (
<MdInsertDriveFile
className="text-lg shrink-0"
style={{ color: "var(--df-primary)" }}
/>
)}
<span className="truncate" title={t("fileExplorer.propertiesOf", { name: data.name })}>
<span
className="truncate"
title={t("fileExplorer.propertiesOf", { name: data.name })}
>
{t("fileExplorer.propertiesOf", { name: data.name })}
</span>
</DialogTitle>
@@ -253,16 +298,42 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
</h3>
<div className="space-y-2.5 text-xs text-left">
{[
{ key: "type", label: t("fileExplorer.type"), value: getFileType() },
{
key: "type",
label: t("fileExplorer.type"),
value: getFileType(),
},
{
key: "location",
label: t("fileExplorer.location"),
value: (
<span className="break-all select-all font-mono" title={getLocation()}>
<span
className="break-all select-all font-mono"
title={getLocation()}
>
{getLocation()}
</span>
),
},
...(canEditSymlinkTarget
? [
{
key: "symlinkTarget",
label: t("fileExplorer.symlinkTarget"),
value: (
<Input
aria-label={t("fileExplorer.symlinkTarget")}
className="h-8 min-w-0 w-full font-mono text-xs"
value={symlinkTarget}
disabled={isSaving}
onChange={(event) =>
setSymlinkTarget(event.target.value)
}
/>
),
},
]
: []),
{
key: "size",
label: t("fileExplorer.size"),
@@ -271,12 +342,20 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
{
key: "mtime",
label: t("fileExplorer.mtime"),
value: <span className="font-mono">{formatTime(properties.mtime)}</span>,
value: (
<span className="font-mono">
{formatTime(properties.mtime)}
</span>
),
},
{
key: "atime",
label: t("fileExplorer.atime"),
value: <span className="font-mono">{formatTime(properties.atime)}</span>,
value: (
<span className="font-mono">
{formatTime(properties.atime)}
</span>
),
},
{
key: "owner",
@@ -285,7 +364,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<span>
{properties.owner || "-"}{" "}
{properties.uid && (
<span className="font-mono opacity-70">[{properties.uid}]</span>
<span className="font-mono opacity-70">
[{properties.uid}]
</span>
)}
</span>
),
@@ -297,15 +378,21 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<span>
{properties.group || "-"}{" "}
{properties.gid && (
<span className="font-mono opacity-70">[{properties.gid}]</span>
<span className="font-mono opacity-70">
[{properties.gid}]
</span>
)}
</span>
),
},
].map((row) => (
<div key={row.key} className="flex min-w-0 items-start">
<span className="w-24 shrink-0 text-muted-foreground">{row.label}:</span>
<span className="min-w-0 break-words">{row.value}</span>
<span className="w-24 shrink-0 text-muted-foreground">
{row.label}:
</span>
<span className="min-w-0 flex-1 break-words">
{row.value}
</span>
</div>
))}
</div>
@@ -321,7 +408,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
</h3>
<div className="space-y-3 text-xs">
<label className="grid grid-cols-[5.5rem_1fr] items-center gap-3">
<span className="text-muted-foreground">{t("fileExplorer.owner")}:</span>
<span className="text-muted-foreground">
{t("fileExplorer.owner")}:
</span>
<Input
className="h-8 text-xs"
value={ownerInput}
@@ -331,7 +420,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
/>
</label>
<label className="grid grid-cols-[5.5rem_1fr] items-center gap-3">
<span className="text-muted-foreground">{t("fileExplorer.group")}:</span>
<span className="text-muted-foreground">
{t("fileExplorer.group")}:
</span>
<Input
className="h-8 text-xs"
value={groupInput}
@@ -357,9 +448,15 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="font-normal px-3 py-2 w-16"></th>
<th className="font-normal px-2 py-2 text-center w-14">R</th>
<th className="font-normal px-2 py-2 text-center w-14">W</th>
<th className="font-normal px-2 py-2 text-center w-14">X</th>
<th className="font-normal px-2 py-2 text-center w-14">
R
</th>
<th className="font-normal px-2 py-2 text-center w-14">
W
</th>
<th className="font-normal px-2 py-2 text-center w-14">
X
</th>
<th className="font-normal px-2 py-2 text-center">
{t("fileExplorer.special")}
</th>
@@ -392,11 +489,17 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
alt: true,
},
].map((row) => (
<tr key={row.idx} className={`border-t ${row.alt ? "bg-muted/30" : ""}`}>
<td className="px-3 py-2 text-muted-foreground">{row.label}</td>
<tr
key={row.idx}
className={`border-t ${row.alt ? "bg-muted/30" : ""}`}
>
<td className="px-3 py-2 text-muted-foreground">
{row.label}
</td>
<td className="px-2 py-2 text-center">
<Checkbox
checked={hasBit(row.idx, 4)}
disabled={isSaving}
onCheckedChange={(checked) =>
updateBit(row.idx, 4, checked === true)
}
@@ -405,6 +508,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<td className="px-2 py-2 text-center">
<Checkbox
checked={hasBit(row.idx, 2)}
disabled={isSaving}
onCheckedChange={(checked) =>
updateBit(row.idx, 2, checked === true)
}
@@ -413,6 +517,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<td className="px-2 py-2 text-center">
<Checkbox
checked={hasBit(row.idx, 1)}
disabled={isSaving}
onCheckedChange={(checked) =>
updateBit(row.idx, 1, checked === true)
}
@@ -422,8 +527,13 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
<label className="flex items-center justify-center gap-1.5 cursor-pointer text-[0.625rem]">
<Checkbox
checked={hasBit(row.sIdx, row.sBit)}
disabled={isSaving}
onCheckedChange={(checked) =>
updateBit(row.sIdx, row.sBit, checked === true)
updateBit(
row.sIdx,
row.sBit,
checked === true,
)
}
/>
{row.sLabel}
@@ -440,11 +550,14 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
{t("fileExplorer.octal")}:
</span>
<div className="flex items-center">
<span className="text-xs font-mono mr-2 opacity-50">0</span>
<span className="text-xs font-mono mr-2 opacity-50">
0
</span>
<Input
className="w-[60px] text-center font-mono text-xs h-7"
style={{ letterSpacing: "2px" }}
value={octal.substring(1)}
disabled={isSaving}
onChange={(e) => {
let val = e.target.value.replace(/[^0-7]/g, "");
if (val.length > 3) val = val.substring(0, 3);
@@ -460,7 +573,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
className="mt-0.5"
checked={recursive}
disabled={isSaving}
onCheckedChange={(checked) => setRecursive(checked === true)}
onCheckedChange={(checked) =>
setRecursive(checked === true)
}
/>
<span className="leading-5 text-muted-foreground">
{t("fileExplorer.applyRecursively")}
@@ -491,7 +606,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
onClick={handleSave}
disabled={isSaving || loading || !!error}
>
{isSaving && <MdRefresh className="text-[0.875rem] animate-spin" />}
{isSaving && (
<MdRefresh className="text-[0.875rem] animate-spin" />
)}
{t("dialog.save")}
</Button>
)}
+10 -2
View File
@@ -251,7 +251,10 @@ function TunnelRow({
<div className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-accent">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium" style={{ color: "var(--df-text)" }}>
<div
className="min-w-0 flex-1 truncate text-sm font-medium"
style={{ color: "var(--df-text)" }}
>
{tunnel.name || endpoint}
</div>
<TunnelRuntimeBadge state={runtimeState} enabled={tunnel.is_open} />
@@ -326,7 +329,12 @@ function TunnelRuntimeBadge({ state, enabled }: { state?: TunnelRuntimeState; en
error: "bg-destructive/10 text-destructive",
}[status] ?? "bg-muted text-muted-foreground";
const badge = (
<span className={cn("rounded-full px-2 py-0.5 text-[0.625rem] font-medium", className)}>
<span
className={cn(
"shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[0.625rem] font-medium",
className,
)}
>
{label}
</span>
);
@@ -37,6 +37,16 @@ vi.mock("@/lib/codeMirrorFileView", () => ({
vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/context/AppContext", () => ({
useApp: () => ({
appSettings: {
transfer: {
internal_editor_font_size: 13,
},
},
}),
}));
function pane(): FileDocumentPane {
return {
id: "pane-file",
@@ -15,7 +15,9 @@ import {
registerFileDocument,
updateFileDocumentState,
} from "@/lib/fileDocumentRegistry";
import { useApp } from "@/context/AppContext";
import { invoke } from "@/lib/invoke";
import { clampFileEditorFontSize } from "@/lib/fileEditorFontSize";
import { formatSize } from "@/lib/utils";
import type { FileDocumentPane } from "@/types/global";
import { languageFromFilename, type TextFileOpenResult } from "./model";
@@ -35,6 +37,10 @@ interface FileDocumentEditorProps {
export default function FileDocumentEditor({ pane, active }: FileDocumentEditorProps) {
const { t } = useTranslation();
const { appSettings } = useApp();
const editorFontSize = clampFileEditorFontSize(
appSettings.transfer.internal_editor_font_size,
);
const editorParentRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const suppressUpdateRef = useRef(false);
@@ -239,6 +245,7 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP
<div
className="flex h-full min-h-0 flex-col bg-background/60"
data-file-document-mode="edit"
data-file-editor-root="true"
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
@@ -285,7 +292,11 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP
{error}
</div>
) : null}
<div ref={editorParentRef} className="min-h-0 flex-1" />
<div
ref={editorParentRef}
className="min-h-0 flex-1"
style={{ fontSize: `${editorFontSize}px` }}
/>
<div className="flex h-7 shrink-0 items-center justify-between border-t px-3 text-[11px] text-muted-foreground">
<span>{languageFromFilename(pane.name || pane.file.path).toLocaleUpperCase()}</span>
<span className="flex items-center gap-2">
@@ -777,7 +777,13 @@ function ReadOnlyCodeMirror({
return () => view.destroy();
}, [content, language]);
return <div ref={parentRef} className="h-full min-h-0 bg-background/60" />;
return (
<div
ref={parentRef}
className="h-full min-h-0 bg-background/60"
style={{ fontSize: "13px" }}
/>
);
}
function PdfPreview({ file }: { file: RemoteBinaryFile }) {
+30
View File
@@ -1910,6 +1910,36 @@ export function SshForm({
{t("dialog.sftpFilenameEncodingDesc")}
</p>
</div>
<div className="mt-3 max-w-md">
<Label className="text-xs font-medium text-foreground/80">
{t("dialog.sftpPipelineDepth")}
</Label>
<Select
disabled={sftpDisabled}
value={sftpSettings.pipeline_depth?.toString() ?? "auto"}
onValueChange={(value) =>
setSftpSettings({
...sftpSettings,
pipeline_depth: value === "auto" ? undefined : Number(value),
})
}
>
<SelectTrigger className="mt-1 h-8 text-xs font-normal">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("dialog.sftpPipelineDepthAuto")}</SelectItem>
{[4, 8, 16, 32, 64].map((depth) => (
<SelectItem key={depth} value={depth.toString()}>
{depth}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-muted-foreground">
{t("dialog.sftpPipelineDepthDesc")}
</p>
</div>
</div>
</TabsContent>
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { AI_PERMISSION_MODES, requiresFullAccessConfirmation } from "./AiPermissionSelect";
describe("AI permission modes", () => {
it("exposes the four permission levels in increasing order", () => {
expect(AI_PERMISSION_MODES).toEqual(["observer", "confirm", "auto", "full_access"]);
});
it("only requires confirmation when entering full access", () => {
expect(requiresFullAccessConfirmation("confirm", "full_access")).toBe(true);
expect(requiresFullAccessConfirmation("auto", "full_access")).toBe(true);
expect(requiresFullAccessConfirmation("full_access", "full_access")).toBe(false);
expect(requiresFullAccessConfirmation("full_access", "confirm")).toBe(false);
});
});
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SelectItem } from "@/components/ui/select";
import type { AIPermissionMode } from "@/types/global";
import { SettingSelect } from "./SettingFormItems";
export const AI_PERMISSION_MODES = [
"observer",
"confirm",
"auto",
"full_access",
] as const satisfies readonly AIPermissionMode[];
const PERMISSION_MODE_LABEL_KEYS: Record<AIPermissionMode, string> = {
observer: "ai.permissionObserver",
confirm: "ai.permissionConfirm",
auto: "ai.permissionAuto",
full_access: "ai.permissionFullAccess",
};
const PERMISSION_MODE_DESCRIPTION_KEYS: Record<AIPermissionMode, string> = {
observer: "ai.permissionObserverDesc",
confirm: "ai.permissionConfirmDesc",
auto: "ai.permissionAutoDesc",
full_access: "ai.permissionFullAccessDesc",
};
export function requiresFullAccessConfirmation(current: AIPermissionMode, next: AIPermissionMode) {
return current !== "full_access" && next === "full_access";
}
interface AiPermissionSelectProps {
value: AIPermissionMode;
targetLabel: string;
onValueChange: (value: AIPermissionMode) => void;
}
export function AiPermissionSelect({ value, targetLabel, onValueChange }: AiPermissionSelectProps) {
const { t } = useTranslation();
const [confirmingFullAccess, setConfirmingFullAccess] = useState(false);
const handleValueChange = (nextValue: string) => {
const next = nextValue as AIPermissionMode;
if (requiresFullAccessConfirmation(value, next)) {
setConfirmingFullAccess(true);
return;
}
onValueChange(next);
};
const enableFullAccess = () => {
setConfirmingFullAccess(false);
onValueChange("full_access");
};
return (
<>
<SettingSelect
label={t("ai.permissionMode")}
desc={t(PERMISSION_MODE_DESCRIPTION_KEYS[value])}
value={value}
onValueChange={handleValueChange}
>
{AI_PERMISSION_MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(PERMISSION_MODE_LABEL_KEYS[mode])}
</SelectItem>
))}
</SettingSelect>
<AlertDialog open={confirmingFullAccess} onOpenChange={setConfirmingFullAccess}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{t("ai.fullAccessConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("ai.fullAccessConfirmDesc", { target: targetLabel })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={enableFullAccess}>
{t("ai.enableFullAccess")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+42 -54
View File
@@ -1,5 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -32,14 +32,14 @@ import {
supportsApiFormatSelection,
supportsCustomModelDiscovery,
} from "@/lib/aiSettings";
import { writeClipboardText } from "@/lib/clipboard";
import { getErrorMessage } from "@/lib/errors";
import { invoke } from "@/lib/invoke";
import { writeClipboardText } from "@/lib/clipboard";
import { getOwnerMainWindowLabel } from "@/lib/windowManager";
import type {
AICustomActionConfig,
AIApiFormat,
AICustomActionConfig,
AIModelConfigItem,
AIPermissionMode,
AIProviderCredential,
AIProviderKind,
AISettings,
@@ -48,6 +48,7 @@ import type {
ExternalMcpSettings,
McpRuntimeStatus,
} from "@/types/global";
import { AiPermissionSelect } from "./AiPermissionSelect";
import {
SettingFieldGrid,
SettingInput,
@@ -307,8 +308,6 @@ export function AiAgentsTab() {
enabled: false,
permission_mode: "confirm",
session_scope: "current_window",
server_mode: "temporary",
idle_timeout_minutes: 10,
};
const [mcpStatus, setMcpStatus] = useState<McpRuntimeStatus | null>(null);
const [cliStatus, setCliStatus] = useState<CodexCliStatus | null>(null);
@@ -341,12 +340,32 @@ export function AiAgentsTab() {
const updateExternalMcp = useCallback(
(patch: Partial<ExternalMcpSettings>) =>
updateAppSettings({ ai: { ...ai, external_mcp: { ...externalMcp, ...patch } } }),
updateAppSettings({
ai: { ...ai, external_mcp: { ...externalMcp, ...patch } },
}),
[ai, externalMcp, updateAppSettings],
);
const setExternalMcpEnabled = useCallback(
async (enabled: boolean) => {
try {
const status = await invoke<McpRuntimeStatus>("set_external_mcp_enabled", {
enabled,
ownerWindowLabel: getOwnerMainWindowLabel(),
});
setMcpStatus(status);
updateExternalMcp({ enabled });
} catch (error) {
toast.error(getErrorMessage(error));
}
},
[updateExternalMcp],
);
useEffect(() => {
void invoke<McpRuntimeStatus>("get_external_mcp_status").then(setMcpStatus).catch(() => {});
void invoke<McpRuntimeStatus>("get_external_mcp_status")
.then(setMcpStatus)
.catch(() => {});
let disposed = false;
let unlisten: (() => void) | undefined;
void listen<McpRuntimeStatus>("mcp-status-changed", (event) => {
@@ -597,19 +616,15 @@ export function AiAgentsTab() {
</SelectItem>
))}
</SettingSelect>
<SettingSelect
label={t("ai.permissionMode")}
<AiPermissionSelect
value={codex.permission_mode ?? "confirm"}
targetLabel="Codex"
onValueChange={(permission_mode) =>
updateCodex({
permission_mode: permission_mode as AIPermissionMode,
permission_mode,
})
}
>
<SelectItem value="observer">{t("ai.permissionObserver")}</SelectItem>
<SelectItem value="confirm">{t("ai.permissionConfirm")}</SelectItem>
<SelectItem value="auto">{t("ai.permissionAuto")}</SelectItem>
</SettingSelect>
/>
</SettingFieldGrid>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
@@ -722,19 +737,15 @@ export function AiAgentsTab() {
})
}
/>
<SettingSelect
label={t("ai.permissionMode")}
<AiPermissionSelect
value={claudeCode.permission_mode ?? "confirm"}
targetLabel="Claude Code"
onValueChange={(permission_mode) =>
updateClaudeCode({
permission_mode: permission_mode as AIPermissionMode,
permission_mode,
})
}
>
<SelectItem value="observer">{t("ai.permissionObserver")}</SelectItem>
<SelectItem value="confirm">{t("ai.permissionConfirm")}</SelectItem>
<SelectItem value="auto">{t("ai.permissionAuto")}</SelectItem>
</SettingSelect>
/>
</SettingFieldGrid>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
@@ -782,7 +793,7 @@ export function AiAgentsTab() {
</Badge>
<SettingSwitch
checked={externalMcp.enabled}
onChange={(enabled) => updateExternalMcp({ enabled })}
onChange={(enabled) => void setExternalMcpEnabled(enabled)}
/>
</div>
</SettingRow>
@@ -790,17 +801,15 @@ export function AiAgentsTab() {
<div className="text-xs text-destructive">{mcpStatus.error}</div>
) : null}
<SettingFieldGrid>
<SettingSelect
label={t("ai.permissionMode")}
<AiPermissionSelect
value={externalMcp.permission_mode}
targetLabel={t("ai.externalMcp")}
onValueChange={(permission_mode) =>
updateExternalMcp({ permission_mode: permission_mode as AIPermissionMode })
updateExternalMcp({
permission_mode,
})
}
>
<SelectItem value="observer">{t("ai.permissionObserver")}</SelectItem>
<SelectItem value="confirm">{t("ai.permissionConfirm")}</SelectItem>
<SelectItem value="auto">{t("ai.permissionAuto")}</SelectItem>
</SettingSelect>
/>
<SettingSelect
label={t("ai.externalMcpScope")}
value={externalMcp.session_scope}
@@ -813,27 +822,6 @@ export function AiAgentsTab() {
<SelectItem value="current_window">{t("ai.externalMcpCurrentWindow")}</SelectItem>
<SelectItem value="all_sessions">{t("ai.externalMcpAllSessions")}</SelectItem>
</SettingSelect>
<SettingSelect
label={t("ai.externalMcpServerMode")}
value={externalMcp.server_mode}
onValueChange={(server_mode) =>
updateExternalMcp({
server_mode: server_mode as ExternalMcpSettings["server_mode"],
})
}
>
<SelectItem value="temporary">{t("ai.externalMcpTemporary")}</SelectItem>
<SelectItem value="persistent">{t("ai.externalMcpPersistent")}</SelectItem>
</SettingSelect>
<SettingNumberInput
label={t("ai.externalMcpIdleTimeout")}
min={1}
max={120}
step={1}
disabled={externalMcp.server_mode === "persistent"}
value={externalMcp.idle_timeout_minutes}
onChange={(idle_timeout_minutes) => updateExternalMcp({ idle_timeout_minutes })}
/>
</SettingFieldGrid>
<div className="text-xs text-muted-foreground">
{t("ai.externalMcpRuntimeSummary", {
+28 -5
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { SelectItem } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useApp } from "@/context/AppContext";
import {
MAX_COMMAND_SUGGESTION_MAX_CHARS,
@@ -9,6 +10,7 @@ import {
normalizeCommandSuggestionMaxChars,
normalizeCommandSuggestionMinChars,
normalizeTabMouseAction,
normalizeTerminalRightClickAction,
TAB_MOUSE_ACTION_LABEL_KEYS,
TAB_MOUSE_ACTIONS,
} from "@/lib/interactionSettings";
@@ -73,11 +75,32 @@ export function InteractionTab() {
/>
</SettingRow>
<SettingRow label={t("settings.rightClickPaste")} desc={t("settings.rightClickPasteDesc")}>
<SettingSwitch
checked={interaction.right_click_paste}
onChange={(v) => updateInteraction({ right_click_paste: v })}
/>
<SettingRow
label={t("settings.terminalRightClickAction")}
desc={t("settings.terminalRightClickActionDesc")}
>
<Tabs
value={normalizeTerminalRightClickAction(
interaction.terminal_right_click_action,
)}
onValueChange={(value) =>
updateInteraction({
terminal_right_click_action: normalizeTerminalRightClickAction(value),
})
}
>
<TabsList className="grid w-52 grid-cols-3">
<TabsTrigger value="none">
{t("settings.terminalRightClickNone")}
</TabsTrigger>
<TabsTrigger value="menu">
{t("settings.terminalRightClickMenu")}
</TabsTrigger>
<TabsTrigger value="paste">
{t("settings.terminalRightClickPaste")}
</TabsTrigger>
</TabsList>
</Tabs>
</SettingRow>
</SettingSection>
+1
View File
@@ -1343,6 +1343,7 @@ function TabBar({
return;
}
onTabChange(tab.id);
focusOpenTabTerminal(tab);
}}
onDoubleClick={(event) => {
if (
@@ -0,0 +1,149 @@
import { createEvent, fireEvent, render, waitFor } from "@testing-library/react";
import type { Terminal } from "@xterm/xterm";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TerminalRightClickAction } from "@/lib/interactionSettings";
import TerminalContextMenu from "./TerminalContextMenu";
let rightClickAction: TerminalRightClickAction;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
vi.mock("@/context/AppContext", () => ({
useTerminalAppSettings: () => ({
interaction: { terminal_right_click_action: rightClickAction },
translation: {},
search: { custom_engines: [] },
ai: { enabled: false, terminal_ai_actions: [] },
keybindings: {},
}),
}));
vi.mock("@/hooks/useShortcutMap", () => ({ resolveDisplayKeys: () => "" }));
vi.mock("@/lib/aiEvents", () => ({ openAIAssistant: vi.fn() }));
vi.mock("@/lib/clipboard", () => ({ writeClipboardText: vi.fn() }));
vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() }));
vi.mock("@/lib/terminalControlInput", () => ({ sendTerminalClearInput: vi.fn() }));
vi.mock("@/lib/windowManager", () => ({ openSettings: vi.fn() }));
vi.mock("../dialog/terminal/TranslationDialog", () => ({ default: () => null }));
describe("TerminalContextMenu right-click behavior", () => {
beforeEach(() => {
rightClickAction = "menu";
});
it("leaves the right-click event untouched when the action is off", () => {
rightClickAction = "none";
const onAncestorContextMenu = vi.fn();
const onPasteClipboard = vi.fn();
const { getByTestId } = renderTerminalContextMenu({
onAncestorContextMenu,
onPasteClipboard,
});
const event = createEvent.contextMenu(getByTestId("terminal-child"));
fireEvent(getByTestId("terminal-child"), event);
expect(onAncestorContextMenu).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(false);
expect(onPasteClipboard).not.toHaveBeenCalled();
expect(document.querySelector('[data-slot="context-menu-content"]')).toBeNull();
});
it("pastes directly and consumes the context-menu event in paste mode", async () => {
rightClickAction = "paste";
const onAncestorContextMenu = vi.fn();
const onPasteClipboard = vi.fn().mockResolvedValue(undefined);
const clearSelection = vi.fn();
const focus = vi.fn();
const { getByTestId } = renderTerminalContextMenu({
onAncestorContextMenu,
onPasteClipboard,
clearSelection,
focus,
});
const event = createEvent.contextMenu(getByTestId("terminal-child"));
fireEvent(getByTestId("terminal-child"), event);
expect(event.defaultPrevented).toBe(true);
expect(onAncestorContextMenu).not.toHaveBeenCalled();
await waitFor(() => {
expect(onPasteClipboard).toHaveBeenCalledOnce();
expect(clearSelection).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledOnce();
});
expect(document.querySelector('[data-slot="context-menu-content"]')).toBeNull();
});
it("opens the application context menu without pasting in menu mode", async () => {
const onPasteClipboard = vi.fn();
const { getByTestId } = renderTerminalContextMenu({ onPasteClipboard });
fireEvent.contextMenu(getByTestId("terminal-child"));
await waitFor(() => {
expect(document.querySelector('[data-slot="context-menu-content"]')).not.toBeNull();
});
expect(onPasteClipboard).not.toHaveBeenCalled();
});
it("preserves the terminal DOM node when switching between actions", () => {
const view = renderTerminalContextMenu();
const terminalChild = view.getByTestId("terminal-child");
rightClickAction = "none";
view.rerenderMenu();
expect(view.getByTestId("terminal-child")).toBe(terminalChild);
rightClickAction = "paste";
view.rerenderMenu();
expect(view.getByTestId("terminal-child")).toBe(terminalChild);
rightClickAction = "menu";
view.rerenderMenu();
expect(view.getByTestId("terminal-child")).toBe(terminalChild);
});
});
function renderTerminalContextMenu({
onAncestorContextMenu = vi.fn(),
onPasteClipboard = vi.fn(),
clearSelection = vi.fn(),
focus = vi.fn(),
}: {
onAncestorContextMenu?: () => void;
onPasteClipboard?: () => Promise<void> | void;
clearSelection?: () => void;
focus?: () => void;
} = {}) {
const terminal = {
clearSelection,
focus,
getSelection: () => "",
} as unknown as Terminal;
const terminalRef = { current: terminal } as React.RefObject<Terminal | null>;
const element = () => (
<div onContextMenu={onAncestorContextMenu}>
<TerminalContextMenu
sessionId="session-1"
terminalRef={terminalRef}
onFind={vi.fn()}
onPasteText={vi.fn()}
onPasteClipboard={onPasteClipboard}
onClearAll={vi.fn()}
>
<div data-testid="terminal-child" />
</TerminalContextMenu>
</div>
);
const view = render(element());
return {
...view,
rerenderMenu: () => view.rerender(element()),
};
}
+51 -20
View File
@@ -3,6 +3,7 @@ import type { Terminal } from "@xterm/xterm";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
MdAddCircleOutline,
MdAutoAwesome,
MdClearAll,
MdContentCopy,
@@ -23,9 +24,10 @@ import { useTerminalAppSettings } from "@/context/AppContext";
import { resolveDisplayKeys } from "@/hooks/useShortcutMap";
import { openAIAssistant } from "@/lib/aiEvents";
import { writeClipboardText } from "@/lib/clipboard";
import { normalizeTerminalRightClickAction } from "@/lib/interactionSettings";
import { invoke } from "@/lib/invoke";
import { sendTerminalClearInput } from "@/lib/terminalControlInput";
import { openSettings } from "@/lib/windowManager";
import { openQuickCommand, openSettings } from "@/lib/windowManager";
import type { RecordingMode, RecordingStatus, SearchEngine } from "@/types/global";
import TranslationDialog from "../dialog/terminal/TranslationDialog";
import { type QuickIconDef, SEARCH_ICONS } from "../icons";
@@ -71,6 +73,9 @@ export default function TerminalContextMenu({
const { t } = useTranslation();
const termSettings = useTerminalAppSettings();
const { interaction, translation, search, ai, keybindings } = termSettings;
const rightClickAction = normalizeTerminalRightClickAction(
interaction.terminal_right_click_action,
);
const dk = (id: string) => resolveDisplayKeys(id, keybindings);
const [ctxSelection, setCtxSelection] = useState({
@@ -116,31 +121,34 @@ export default function TerminalContextMenu({
)
: [];
// Right-click context menu: capture selection state
const handleContextMenu = (e: React.MouseEvent) => {
// Right-click context menu: capture selection state.
const handleContextMenu = () => {
const terminal = terminalRef.current;
if (!terminal) return;
if (interaction.right_click_paste) {
e.preventDefault();
e.stopPropagation();
(async () => {
try {
await onPasteClipboard();
} catch {
/* clipboard access denied */
}
terminal.clearSelection();
terminal.focus();
})();
return;
}
const selection = terminal.getSelection();
const hasSelection = selection.length > 0;
setCtxSelection({ text: selection, hasSelection });
};
const handleDirectPasteContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const terminal = terminalRef.current;
if (!terminal) return;
void (async () => {
try {
await onPasteClipboard();
} catch {
/* clipboard access denied */
}
terminal.clearSelection();
terminal.focus();
})();
};
const doPaste = useCallback(async () => {
try {
await onPasteClipboard();
@@ -226,8 +234,17 @@ export default function TerminalContextMenu({
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="h-full w-full" onContextMenu={handleContextMenu}>
<ContextMenuTrigger asChild disabled={rightClickAction !== "menu"}>
<div
className="h-full w-full"
onContextMenu={
rightClickAction === "menu"
? handleContextMenu
: rightClickAction === "paste"
? handleDirectPasteContextMenu
: undefined
}
>
{children}
</div>
</ContextMenuTrigger>
@@ -244,6 +261,20 @@ export default function TerminalContextMenu({
{t("terminalCtx.find")}
<ContextMenuShortcut>{dk("terminal.find")}</ContextMenuShortcut>
</ContextMenuItem>
{ctxSelection.text.trim().length > 0 && (
<ContextMenuItem
onClick={() =>
openQuickCommand(
JSON.stringify({
command: ctxSelection.text,
}),
)
}
>
<MdAddCircleOutline className="text-[0.875rem] text-muted-foreground mr-2" />
{t("terminalCtx.saveAsQuickCommand")}
</ContextMenuItem>
)}
<ContextMenuSub>
<ContextMenuSubTrigger>
<MdTravelExplore className="text-[0.875rem] text-muted-foreground mr-2" />
+121 -61
View File
@@ -111,6 +111,7 @@ import { installXTerminalKeyboardController } from "./xterminalKeyboardControlle
import { createXTerminalOutputController } from "./xterminalOutputController";
import { installXTerminalSelectionController } from "./xterminalSelectionController";
import { createXTerminalSessionEvents } from "./xterminalSessionEvents";
import { createXTerminalSnapshotRestoreController } from "./xterminalSnapshotRestoreController";
import type {
HibernationLogEvent,
HibernationPhase,
@@ -136,6 +137,7 @@ import {
writeTextInFrames,
} from "./xterminalOutputQueue";
import type { PerformanceMode, XTerminalProps } from "./xterminalTypes";
import { shouldSuspendKeywordHighlighter } from "./xterminalKeywordHighlighting";
import {
createZmodemEventHandler,
type ZmodemEventPayload,
@@ -177,6 +179,15 @@ export default function XTerminal({
null,
);
const [terminalReady, setTerminalReady] = useState(false);
const [restoringSnapshot, setRestoringSnapshot] = useState(false);
const restoringSnapshotRef = useRef(false);
const [snapshotRestoreController] = useState(() =>
createXTerminalSnapshotRestoreController({
restoringRef: restoringSnapshotRef,
setRestoring: setRestoringSnapshot,
setTerminalReady,
}),
);
const [performanceMode, setPerformanceMode] =
useState<PerformanceMode>("normal");
const [terminalGeneration, setTerminalGeneration] = useState(0);
@@ -737,6 +748,16 @@ export default function XTerminal({
setPerformanceMode("normal");
let disposed = false;
const preservedReconnectSnapshot =
hibernationSnapshotRef.current ??
preservedReconnectContentRef.current ??
consumePreservedTerminalReconnectContent(sessionId);
const restoringInitialSnapshot = snapshotRestoreController.begin(
preservedReconnectSnapshot,
);
hibernationSnapshotRef.current = null;
preservedReconnectContentRef.current = null;
const terminal = new Terminal({
scrollback: terminalSettings.scrollback_lines,
cursorBlink: appearance.cursor_blink,
@@ -981,12 +1002,6 @@ export default function XTerminal({
}
};
const preservedReconnectSnapshot =
hibernationSnapshotRef.current ??
preservedReconnectContentRef.current ??
consumePreservedTerminalReconnectContent(sessionId);
hibernationSnapshotRef.current = null;
preservedReconnectContentRef.current = null;
const initialReplayPromise = preservedReconnectSnapshot?.content
? writeTextInFrames(terminal, preservedReconnectSnapshot.content).then(
() => {
@@ -1676,6 +1691,7 @@ export default function XTerminal({
if (!isTerminalAlive()) return;
sendBackendResize(terminal.cols, terminal.rows, result.reason);
refreshGutter();
snapshotRestoreController.completeAfterFinalFit();
};
const fitScheduler = createTerminalFitScheduler({
@@ -1839,13 +1855,27 @@ export default function XTerminal({
};
const repaintVisibleTerminal = () => {
if (!visibleRef.current || !isTerminalAlive()) return;
if (
restoringSnapshotRef.current ||
!visibleRef.current ||
!isTerminalAlive()
)
return;
requestAnimationFrame(() => {
if (!visibleRef.current || !isTerminalAlive()) return;
terminal.clearTextureAtlas();
if (
restoringSnapshotRef.current ||
!visibleRef.current ||
!isTerminalAlive()
)
return;
terminal.refresh(0, Math.max(0, terminal.rows - 1));
requestAnimationFrame(() => {
if (!visibleRef.current || !isTerminalAlive()) return;
if (
restoringSnapshotRef.current ||
!visibleRef.current ||
!isTerminalAlive()
)
return;
terminal.refresh(0, Math.max(0, terminal.rows - 1));
});
});
@@ -1913,42 +1943,45 @@ export default function XTerminal({
const { applyVisibilityPolicy, noteOutputActivity } =
createXTerminalHibernationController({
sessionId,
terminal,
outputDrain,
visibleRef,
sessionTypeRef,
aiCapturingRef,
zmodemActiveRef,
syncPeerSessionIdsRef,
outputDrainRef,
disconnectedRef,
reconnectingRef,
hibernateTimerRef,
hibernationEpochRef,
hibernationPendingRef,
hibernationPhaseRef,
detachedHibernateEpochRef,
hibernationSnapshotRef,
hibernationCleanupRef,
hibernatedRef,
lastOutputActivityAtRef,
showSearchBar,
activeMode,
isTerminalAlive,
logHibernation,
clearHibernateTimer,
enterDisconnectedStateIfAttachSessionMissing,
updateOutputDrainMode,
flushFrameGateAndDrain,
captureReconnectSnapshot,
setTerminalReady,
setHibernated,
setTerminalGeneration,
maybeRecoverPerformanceMode,
refreshOutputPressureMode,
repaintVisibleTerminal,
});
sessionId,
terminal,
outputDrain,
visibleRef,
sessionTypeRef,
aiCapturingRef,
zmodemActiveRef,
syncPeerSessionIdsRef,
outputDrainRef,
disconnectedRef,
reconnectingRef,
hibernateTimerRef,
hibernationEpochRef,
hibernationPendingRef,
hibernationPhaseRef,
detachedHibernateEpochRef,
hibernationSnapshotRef,
hibernationCleanupRef,
hibernatedRef,
lastOutputActivityAtRef,
showSearchBar,
activeMode,
isTerminalAlive,
logHibernation,
clearHibernateTimer,
enterDisconnectedStateIfAttachSessionMissing,
updateOutputDrainMode,
flushFrameGateAndDrain,
captureReconnectSnapshot,
beginSnapshotRestore: (snapshot) => {
snapshotRestoreController.begin(snapshot);
},
setTerminalReady,
setHibernated,
setTerminalGeneration,
maybeRecoverPerformanceMode,
refreshOutputPressureMode,
repaintVisibleTerminal,
});
handleVisibilityChangeRef.current = applyVisibilityPolicy;
applyVisibilityPolicy();
@@ -1991,7 +2024,7 @@ export default function XTerminal({
flushFrameGateAndDrain("dynamic_title_attach"),
flushPendingDynamicTitle,
});
void sessionEvents.setup().catch((error) => {
const sessionSetupPromise = sessionEvents.setup().catch((error) => {
resumeDynamicTitlePublication(sessionId);
logger.error({
domain: "session.lifecycle",
@@ -2038,7 +2071,9 @@ export default function XTerminal({
`\r\n\x1b[36m[${tRef.current("terminal.reconnecting")}]\x1b[0m\r\n`,
);
const newSessionId = await createReconnectedSession();
preservedReconnectContentRef.current = captureReconnectSnapshot();
const reconnectSnapshot = captureReconnectSnapshot();
preservedReconnectContentRef.current = reconnectSnapshot;
snapshotRestoreController.begin(reconnectSnapshot);
const oldSessionId = sessionIdRef.current;
disconnectedRef.current = false;
disconnectedNoticeShownRef.current = false;
@@ -2222,6 +2257,7 @@ export default function XTerminal({
});
const observer = new ResizeObserver((entries) => {
if (restoringSnapshotRef.current) return;
const entry = entries[0];
if (!entry) return;
fitScheduler.observeResize(
@@ -2257,16 +2293,24 @@ export default function XTerminal({
pasteClipboard,
});
fitScheduler.schedule({
reason: "initial",
force: true,
refresh: true,
onComplete: () => {
if (restoringInitialSnapshot) {
void sessionSetupPromise.then(() => {
if (!isTerminalAlive()) return;
setTerminalReady(true);
refreshGutter();
},
});
snapshotRestoreController.markReplayAndAttachComplete();
});
} else {
void sessionSetupPromise;
fitScheduler.schedule({
reason: "initial",
force: true,
refresh: true,
onComplete: () => {
if (!isTerminalAlive()) return;
setTerminalReady(true);
refreshGutter();
},
});
}
return () => {
disposed = true;
@@ -2381,7 +2425,9 @@ export default function XTerminal({
latestLifecycleState.terminalTransparencyEnabled !==
terminalTransparencyEnabled
) {
preservedReconnectContentRef.current = captureReconnectSnapshot();
const reconnectSnapshot = captureReconnectSnapshot();
preservedReconnectContentRef.current = reconnectSnapshot;
snapshotRestoreController.begin(reconnectSnapshot);
}
if (!isHibernateRendererCleanup) {
resumeDynamicTitlePublication(sessionId);
@@ -2409,17 +2455,27 @@ export default function XTerminal({
visible && active,
terminalInstance,
sessionId,
restoringSnapshotRef,
);
// isDark is derived from the terminal theme background so built-in rule colors
// switch automatically when the user changes themes.
const isDark = hexLuminance(terminalTheme.colors.terminal.background) < 0.5;
const keywordHighlighterSuspended = shouldSuspendKeywordHighlighter({
visible,
hibernated,
terminalReady,
performanceMode,
});
useKeywordHighlighter(
terminalInstance,
terminalSettings,
sessionId,
isDark,
performanceMode !== "normal" || !visible,
{
suspended: keywordHighlighterSuspended,
releaseCachesAfterDelay: !visible || hibernated,
},
);
const { tooltipState, menuState, closeMenu } = useActionLinks(
@@ -2441,6 +2497,7 @@ export default function XTerminal({
showGutter,
showContentPadding,
workspacePaddingSetting: terminalSettings.show_workspace_padding,
snapshotRestoringRef: restoringSnapshotRef,
});
const searchInputRef = useRef<HTMLInputElement | null>(null);
@@ -2549,7 +2606,7 @@ export default function XTerminal({
backgroundColor: terminalBackground,
}}
>
{showGutter && terminalReady && (
{showGutter && terminalReady && !restoringSnapshot && (
<TerminalGutter
terminalRef={terminalRef}
showLineNumbers={showLineNumbers}
@@ -2563,7 +2620,10 @@ export default function XTerminal({
)}
<div
className="nyaterm-wallpaper-transparent-surface nyaterm-terminal-surface flex-1 min-w-0 h-full relative"
style={{ backgroundColor: terminalBackground }}
style={{
backgroundColor: terminalBackground,
visibility: restoringSnapshot ? "hidden" : "visible",
}}
>
<TerminalContextMenu
sessionId={sessionId}
@@ -0,0 +1,120 @@
import { renderHook, waitFor } from "@testing-library/react";
import type { Terminal } from "@xterm/xterm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TerminalFitScheduler } from "./terminalFitScheduler";
import { useTerminalRefreshEffects } from "./useTerminalRefreshEffects";
const windowMocks = vi.hoisted(() => ({
scaleChanged: undefined as
| ((event: { payload: { scaleFactor: number } }) => void)
| undefined,
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
onResized: async () => vi.fn(),
onMoved: async () => vi.fn(),
onFocusChanged: async () => vi.fn(),
onScaleChanged: async (
callback: (event: { payload: { scaleFactor: number } }) => void,
) => {
windowMocks.scaleChanged = callback;
return vi.fn();
},
}),
}));
describe("useTerminalRefreshEffects", () => {
beforeEach(() => {
windowMocks.scaleChanged = undefined;
});
it("repaints an active visible terminal without texture invalidation", () => {
const schedule = vi.fn();
renderHook(() =>
useTerminalRefreshEffects({
terminalRef: { current: {} as Terminal },
fitSchedulerRef: {
current: { schedule } as unknown as TerminalFitScheduler,
},
active: true,
visible: true,
terminalReady: true,
performanceMode: "normal",
sessionId: "session-1",
showGutter: false,
showContentPadding: false,
}),
);
const activeRefresh = schedule.mock.calls
.map(([request]) => request)
.find((request) => request.reason === "active");
expect(activeRefresh).toEqual(
expect.objectContaining({ force: true, refresh: true, focus: true }),
);
expect(activeRefresh).not.toHaveProperty("clearTextureAtlas");
});
it("still invalidates textures after a DPI scale change", async () => {
const schedule = vi.fn();
renderHook(() =>
useTerminalRefreshEffects({
terminalRef: { current: {} as Terminal },
fitSchedulerRef: {
current: { schedule } as unknown as TerminalFitScheduler,
},
active: true,
visible: true,
terminalReady: true,
performanceMode: "normal",
sessionId: "session-1",
showGutter: false,
showContentPadding: false,
}),
);
await waitFor(() =>
expect(windowMocks.scaleChanged).toBeTypeOf("function"),
);
windowMocks.scaleChanged?.({ payload: { scaleFactor: 2 } });
expect(schedule).toHaveBeenCalledWith(
expect.objectContaining({
reason: "scale-factor",
force: true,
refresh: true,
clearTextureAtlas: true,
}),
);
});
it("suppresses incidental refreshes while a snapshot restore is finalizing", async () => {
const schedule = vi.fn();
const snapshotRestoringRef = { current: true };
renderHook(() =>
useTerminalRefreshEffects({
terminalRef: { current: {} as Terminal },
fitSchedulerRef: {
current: { schedule } as unknown as TerminalFitScheduler,
},
active: true,
visible: true,
terminalReady: true,
performanceMode: "normal",
sessionId: "session-1",
showGutter: false,
showContentPadding: false,
snapshotRestoringRef,
}),
);
await waitFor(() =>
expect(windowMocks.scaleChanged).toBeTypeOf("function"),
);
schedule.mockClear();
window.dispatchEvent(new Event("nyaterm:refresh-terminals"));
windowMocks.scaleChanged?.({ payload: { scaleFactor: 2 } });
expect(schedule).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,7 @@ interface UseTerminalRefreshEffectsParams {
showGutter: boolean;
showContentPadding: boolean;
workspacePaddingSetting?: boolean;
snapshotRestoringRef?: RefObject<boolean>;
}
export function useTerminalRefreshEffects({
@@ -30,6 +31,7 @@ export function useTerminalRefreshEffects({
showGutter,
showContentPadding,
workspacePaddingSetting,
snapshotRestoringRef,
}: UseTerminalRefreshEffectsParams) {
useEffect(() => {
if (terminalReady && fitSchedulerRef.current && terminalRef.current) {
@@ -89,7 +91,6 @@ export function useTerminalRefreshEffects({
reason: "active",
force: true,
refresh: true,
clearTextureAtlas: true,
focus: true,
});
}
@@ -97,7 +98,13 @@ export function useTerminalRefreshEffects({
useEffect(() => {
const handleRefresh = () => {
if (!visible || !fitSchedulerRef.current || !terminalRef.current) return;
if (
snapshotRestoringRef?.current ||
!visible ||
!fitSchedulerRef.current ||
!terminalRef.current
)
return;
fitSchedulerRef.current.schedule({
reason: "global-refresh",
@@ -111,7 +118,7 @@ export function useTerminalRefreshEffects({
return () => {
window.removeEventListener("nyaterm:refresh-terminals", handleRefresh);
};
}, [active, fitSchedulerRef, terminalRef, visible]);
}, [active, fitSchedulerRef, snapshotRestoringRef, terminalRef, visible]);
useEffect(() => {
if (!terminalReady) return;
@@ -129,6 +136,7 @@ export function useTerminalRefreshEffects({
force = false,
scaleFactor?: number,
) => {
if (snapshotRestoringRef?.current) return;
const nextDevicePixelRatio = window.devicePixelRatio || 1;
const dprChanged = Math.abs(nextDevicePixelRatio - lastDevicePixelRatio) > 0.001;
if (dprChanged) {
@@ -217,7 +225,15 @@ export function useTerminalRefreshEffects({
unlistenFocused?.();
unlistenScale?.();
};
}, [active, fitSchedulerRef, sessionId, terminalReady, terminalRef, visible]);
}, [
active,
fitSchedulerRef,
sessionId,
snapshotRestoringRef,
terminalReady,
terminalRef,
visible,
]);
useEffect(() => {
const handleClear = () => {
@@ -36,6 +36,7 @@ function createHarness(
sessionType?: SessionType;
lastOutputActivityAt?: number;
flushFrameGateAndDrain?: (reason: string) => Promise<boolean>;
reconnectSnapshot?: TerminalReconnectSnapshot | null;
} = {},
) {
let now = 0;
@@ -65,6 +66,7 @@ function createHarness(
const setTerminalGeneration = vi.fn();
const updateOutputDrainMode = vi.fn();
const repaintVisibleTerminal = vi.fn();
const beginSnapshotRestore = vi.fn();
const flushFrameGateAndDrain =
options.flushFrameGateAndDrain ?? vi.fn(async () => true);
@@ -112,7 +114,8 @@ function createHarness(
enterDisconnectedStateIfAttachSessionMissing: () => false,
updateOutputDrainMode,
flushFrameGateAndDrain,
captureReconnectSnapshot: () => null,
captureReconnectSnapshot: () => options.reconnectSnapshot ?? null,
beginSnapshotRestore,
setTerminalReady,
setHibernated,
setTerminalGeneration,
@@ -148,6 +151,7 @@ function createHarness(
return {
advance,
beginSnapshotRestore,
controller,
flushFrameGateAndDrain,
hibernatedRef,
@@ -184,6 +188,26 @@ describe("createXTerminalHibernationController", () => {
expect(setHibernated).toHaveBeenCalledWith(true);
});
it("starts snapshot restore before disposing a hibernated renderer", async () => {
const snapshot: TerminalReconnectSnapshot = {
content: "preserved terminal history",
lineTimestamps: [],
captureStartLine: 0,
captureEndLine: 0,
};
const { advance, beginSnapshotRestore, setHibernated } = createHarness({
reconnectSnapshot: snapshot,
});
advance(XTERM_PERFORMANCE_CONFIG.lifecycle.deepHibernateDelayMs);
await settle();
expect(beginSnapshotRestore).toHaveBeenCalledWith(snapshot);
expect(beginSnapshotRestore.mock.invocationCallOrder[0]).toBeLessThan(
setHibernated.mock.invocationCallOrder[0],
);
});
it("does not hibernate while hidden output activity continues", async () => {
const { advance, controller } = createHarness();
@@ -243,7 +267,9 @@ describe("createXTerminalHibernationController", () => {
it("never hibernates a visible terminal from output idleness", async () => {
const { advance, controller, repaintVisibleTerminal, timers } =
createHarness({ visible: true });
createHarness({
visible: true,
});
expect(timers.size).toBe(0);
expect(repaintVisibleTerminal).toHaveBeenCalled();
@@ -286,7 +312,9 @@ describe("createXTerminalHibernationController", () => {
.mockResolvedValueOnce(true)
.mockReturnValueOnce(afterDetachDrain.promise);
const { advance, controller, hibernatedRef, hibernationPhaseRef } =
createHarness({ flushFrameGateAndDrain });
createHarness({
flushFrameGateAndDrain,
});
advance(XTERM_PERFORMANCE_CONFIG.lifecycle.deepHibernateDelayMs);
await settle();
@@ -61,6 +61,7 @@ interface CreateXTerminalHibernationControllerParams {
updateOutputDrainMode: () => void;
flushFrameGateAndDrain: (reason: string) => Promise<boolean>;
captureReconnectSnapshot: () => TerminalReconnectSnapshot | null;
beginSnapshotRestore: (snapshot: TerminalReconnectSnapshot | null) => void;
setTerminalReady: (ready: boolean) => void;
setHibernated: (hibernated: boolean) => void;
setTerminalGeneration: (updater: (generation: number) => number) => void;
@@ -108,6 +109,7 @@ export function createXTerminalHibernationController({
updateOutputDrainMode,
flushFrameGateAndDrain,
captureReconnectSnapshot,
beginSnapshotRestore,
setTerminalReady,
setHibernated,
setTerminalGeneration,
@@ -350,7 +352,9 @@ export function createXTerminalHibernationController({
return;
}
hibernationSnapshotRef.current = captureReconnectSnapshot();
const hibernationSnapshot = captureReconnectSnapshot();
hibernationSnapshotRef.current = hibernationSnapshot;
beginSnapshotRestore(hibernationSnapshot);
hibernationCleanupRef.current = true;
hibernationPhaseRef.current = "hibernated";
outputDrain.setMode("hibernated");
@@ -270,9 +270,21 @@ export function installXTerminalKeyboardController({
}
if (terminal.hasSelection() && !getSmartCursorSelectedInputRange()) {
// The selection is preserved by design while typing (it is only cleared
// on mouse click), so input is sent with wasUserInput=false to skip
// xterm's selection clearing. That also skips xterm's scrollOnUserInput,
// so scroll back to the cursor explicitly to keep the prompt visible.
const inputPreservingSelection = (data: string) => {
markTerminalUserInput(terminal);
terminal.input(data, false);
const buffer = terminal.buffer.active;
if (buffer.baseY !== buffer.viewportY) {
terminal.scrollToBottom();
}
};
if (directInputData) {
e.preventDefault();
inputFromKeyboardController(directInputData);
inputPreservingSelection(directInputData);
return false;
}
if (
@@ -282,12 +294,12 @@ export function installXTerminalKeyboardController({
!e.altKey
) {
e.preventDefault();
inputFromKeyboardController("\x7f");
inputPreservingSelection("\x7f");
return false;
}
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
inputFromKeyboardController("\r");
inputPreservingSelection("\r");
return false;
}
if (
@@ -298,7 +310,7 @@ export function installXTerminalKeyboardController({
!e.shiftKey
) {
e.preventDefault();
inputFromKeyboardController("\x1b[D");
inputPreservingSelection("\x1b[D");
return false;
}
if (
@@ -309,7 +321,7 @@ export function installXTerminalKeyboardController({
!e.shiftKey
) {
e.preventDefault();
inputFromKeyboardController("\x1b[C");
inputPreservingSelection("\x1b[C");
return false;
}
if (
@@ -320,7 +332,7 @@ export function installXTerminalKeyboardController({
!e.shiftKey
) {
e.preventDefault();
inputFromKeyboardController("\x1b[A");
inputPreservingSelection("\x1b[A");
return false;
}
if (
@@ -331,7 +343,7 @@ export function installXTerminalKeyboardController({
!e.shiftKey
) {
e.preventDefault();
inputFromKeyboardController("\x1b[B");
inputPreservingSelection("\x1b[B");
return false;
}
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
@@ -366,65 +378,65 @@ export function installXTerminalKeyboardController({
const keyLower = e.key.toLowerCase();
if (ctrlCharMap[keyLower]) {
e.preventDefault();
inputFromKeyboardController(ctrlCharMap[keyLower]);
inputPreservingSelection(ctrlCharMap[keyLower]);
return false;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;5D");
inputPreservingSelection("\x1b[1;5D");
return false;
}
if (e.key === "ArrowRight") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;5C");
inputPreservingSelection("\x1b[1;5C");
return false;
}
if (e.key === "ArrowUp") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;5A");
inputPreservingSelection("\x1b[1;5A");
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;5B");
inputPreservingSelection("\x1b[1;5B");
return false;
}
}
if ((e.altKey || e.metaKey) && !e.ctrlKey && !e.shiftKey) {
if (e.key === "ArrowLeft") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;3D");
inputPreservingSelection("\x1b[1;3D");
return false;
}
if (e.key === "ArrowRight") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;3C");
inputPreservingSelection("\x1b[1;3C");
return false;
}
if (e.key === "ArrowUp") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;3A");
inputPreservingSelection("\x1b[1;3A");
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
inputFromKeyboardController("\x1b[1;3B");
inputPreservingSelection("\x1b[1;3B");
return false;
}
const keyLower = e.key.toLowerCase();
if (keyLower === "b") {
e.preventDefault();
inputFromKeyboardController("\x1bb");
inputPreservingSelection("\x1bb");
return false;
}
if (keyLower === "f") {
e.preventDefault();
inputFromKeyboardController("\x1bf");
inputPreservingSelection("\x1bf");
return false;
}
if (keyLower === "d") {
e.preventDefault();
inputFromKeyboardController("\x1bd");
inputPreservingSelection("\x1bd");
return false;
}
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { shouldSuspendKeywordHighlighter } from "./xterminalKeywordHighlighting";
describe("shouldSuspendKeywordHighlighter", () => {
const ready = {
visible: true,
hibernated: false,
terminalReady: true,
performanceMode: "normal" as const,
};
it("only resumes for a visible, ready terminal under normal pressure", () => {
expect(shouldSuspendKeywordHighlighter(ready)).toBe(false);
expect(shouldSuspendKeywordHighlighter({ ...ready, visible: false })).toBe(true);
expect(shouldSuspendKeywordHighlighter({ ...ready, hibernated: true })).toBe(true);
expect(shouldSuspendKeywordHighlighter({ ...ready, terminalReady: false })).toBe(true);
expect(
shouldSuspendKeywordHighlighter({ ...ready, performanceMode: "strained" }),
).toBe(true);
expect(
shouldSuspendKeywordHighlighter({ ...ready, performanceMode: "overloaded" }),
).toBe(true);
});
it("does not accept focus or active state as an input", () => {
expect(Object.keys(ready)).not.toContain("active");
expect(shouldSuspendKeywordHighlighter(ready)).toBe(false);
});
});
@@ -0,0 +1,21 @@
import type { PerformanceMode } from "./xterminalTypes";
interface KeywordHighlightSuspensionState {
visible: boolean;
hibernated: boolean;
terminalReady: boolean;
performanceMode: PerformanceMode;
}
/**
* Highlighting follows presentation readiness and output pressure. Focus/active
* state is deliberately absent so every visible split pane can stay highlighted.
*/
export function shouldSuspendKeywordHighlighter({
visible,
hibernated,
terminalReady,
performanceMode,
}: KeywordHighlightSuspensionState): boolean {
return !visible || hibernated || !terminalReady || performanceMode !== "normal";
}
@@ -17,7 +17,10 @@ import {
resetDynamicTitlesForTests,
resumeDynamicTitlePublication,
} from "@/lib/dynamicTabTitles";
import { createXTerminalSessionEvents } from "./xterminalSessionEvents";
import {
createXTerminalSessionEvents,
replaySnapshotBeforeAttach,
} from "./xterminalSessionEvents";
function params(overrides: Record<string, unknown> = {}) {
return {
@@ -222,3 +225,38 @@ describe("xterminalSessionEvents setup lifecycle", () => {
wakeEvents.dispose();
});
});
function createDeferred() {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
describe("replaySnapshotBeforeAttach", () => {
it("replays the snapshot before pending wake events and backend attach", async () => {
const replay = createDeferred();
const order: string[] = [];
const attachSession = vi.fn(async () => {
order.push("attach");
});
const restore = replaySnapshotBeforeAttach({
initialReplayPromise: replay.promise.then(() => {
order.push("replay");
}),
replayPendingWakeEvents: () => order.push("pending-wake"),
attachSession,
});
await Promise.resolve();
expect(attachSession).not.toHaveBeenCalled();
expect(order).toEqual([]);
replay.resolve();
await restore;
expect(order).toEqual(["replay", "pending-wake", "attach"]);
expect(attachSession).toHaveBeenCalledTimes(1);
});
});
@@ -27,6 +27,16 @@ interface ZmodemHandler {
handle: (payload: ZmodemEventPayload) => void;
}
export async function replaySnapshotBeforeAttach(options: {
initialReplayPromise: Promise<void>;
replayPendingWakeEvents: () => void;
attachSession: () => Promise<void>;
}) {
await options.initialReplayPromise.catch(() => {});
options.replayPendingWakeEvents();
await options.attachSession();
}
interface CreateXTerminalSessionEventsParams {
sessionId: string;
terminal: Terminal;
@@ -290,13 +300,16 @@ export function createXTerminalSessionEvents({
);
if (!addUnlistener(nextZmodemUnlisten)) return;
replayPendingWakeEvents();
let backendAttached = false;
try {
await initialReplayPromise.catch(() => {});
await invoke("attach_session", { sessionId });
backendAttached = true;
await replaySnapshotBeforeAttach({
initialReplayPromise,
replayPendingWakeEvents,
attachSession: async () => {
await invoke("attach_session", { sessionId });
backendAttached = true;
},
});
// Attachment has completed even if the renderer drain below times out.
// Clear the detached epoch now so cleanup cannot issue a duplicate attach.
detachedHibernateEpochRef.current = null;
@@ -0,0 +1,106 @@
import type { Terminal } from "@xterm/xterm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TerminalReconnectSnapshot } from "@/lib/terminalReconnectHistory";
import { writeTextInFrames } from "./xterminalOutputQueue";
import { createXTerminalSnapshotRestoreController } from "./xterminalSnapshotRestoreController";
const snapshot = (content: string): TerminalReconnectSnapshot => ({
content,
lineTimestamps: [],
captureStartLine: 0,
captureEndLine: 0,
});
describe("createXTerminalSnapshotRestoreController", () => {
let animationFrames: FrameRequestCallback[];
beforeEach(() => {
animationFrames = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const createHarness = () => {
let rendererVisible = true;
const readyStates: boolean[] = [];
const restoringRef = { current: false };
const controller = createXTerminalSnapshotRestoreController({
restoringRef,
setRestoring: (restoring) => {
rendererVisible = !restoring;
},
setTerminalReady: (ready) => readyStates.push(ready),
});
return {
controller,
readyStates,
rendererVisible: () => rendererVisible,
};
};
const runNextFrame = () => {
const callback = animationFrames.shift();
expect(callback).toBeTypeOf("function");
callback?.(performance.now());
};
it("keeps the renderer hidden through every replay frame until final fit", async () => {
const harness = createHarness();
const terminal = {
write: vi.fn((_data: string, callback: () => void) => callback()),
} as unknown as Terminal;
const replay = writeTextInFrames(terminal, "x".repeat(96 * 1024));
expect(harness.controller.begin(snapshot("large snapshot"))).toBe(true);
expect(harness.rendererVisible()).toBe(false);
runNextFrame();
expect(terminal.write).toHaveBeenCalledTimes(1);
expect(harness.rendererVisible()).toBe(false);
runNextFrame();
expect(terminal.write).toHaveBeenCalledTimes(2);
expect(harness.rendererVisible()).toBe(false);
while (animationFrames.length > 0) runNextFrame();
await replay;
expect(harness.controller.getPhase()).toBe("replaying");
expect(harness.rendererVisible()).toBe(false);
expect(harness.controller.markReplayAndAttachComplete()).toBe(true);
expect(harness.controller.getPhase()).toBe("awaiting-final-fit");
expect(harness.rendererVisible()).toBe(false);
expect(harness.readyStates[harness.readyStates.length - 1]).toBe(true);
expect(harness.controller.completeAfterFinalFit()).toBe(true);
expect(harness.rendererVisible()).toBe(true);
expect(harness.controller.completeAfterFinalFit()).toBe(false);
});
it("does not hide a terminal when there is no snapshot content", () => {
const harness = createHarness();
expect(harness.controller.begin(null)).toBe(false);
expect(harness.controller.begin(snapshot(""))).toBe(false);
expect(harness.rendererVisible()).toBe(true);
expect(harness.controller.getPhase()).toBe("idle");
expect(harness.readyStates).toEqual([]);
});
it("clears a stale restore barrier when the replacement has no snapshot", () => {
const harness = createHarness();
harness.controller.begin(snapshot("old session"));
expect(harness.rendererVisible()).toBe(false);
expect(harness.controller.begin(null)).toBe(false);
expect(harness.rendererVisible()).toBe(true);
expect(harness.controller.getPhase()).toBe("idle");
});
});
@@ -0,0 +1,71 @@
import type { TerminalReconnectSnapshot } from "@/lib/terminalReconnectHistory";
interface MutableRef<T> {
current: T;
}
export type SnapshotRestorePhase =
| "idle"
| "replaying"
| "awaiting-final-fit"
| "revealed";
interface CreateXTerminalSnapshotRestoreControllerParams {
restoringRef: MutableRef<boolean>;
setRestoring: (restoring: boolean) => void;
setTerminalReady: (ready: boolean) => void;
}
export function createXTerminalSnapshotRestoreController({
restoringRef,
setRestoring,
setTerminalReady,
}: CreateXTerminalSnapshotRestoreControllerParams) {
let phase: SnapshotRestorePhase = "idle";
const begin = (snapshot: TerminalReconnectSnapshot | null | undefined) => {
if (!snapshot?.content) {
if (restoringRef.current) {
phase = "idle";
restoringRef.current = false;
setRestoring(false);
}
return false;
}
phase = "replaying";
restoringRef.current = true;
setRestoring(true);
setTerminalReady(false);
return true;
};
const markReplayAndAttachComplete = () => {
if (phase !== "replaying") return false;
phase = "awaiting-final-fit";
setTerminalReady(true);
return true;
};
const completeAfterFinalFit = () => {
if (phase !== "awaiting-final-fit") return false;
phase = "revealed";
restoringRef.current = false;
setRestoring(false);
return true;
};
return {
begin,
markReplayAndAttachComplete,
completeAfterFinalFit,
isRestoring: () => restoringRef.current,
getPhase: () => phase,
};
}
export type XTerminalSnapshotRestoreController = ReturnType<
typeof createXTerminalSnapshotRestoreController
>;
+2 -1
View File
@@ -155,7 +155,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
interaction: {
copy_on_select: false,
allow_osc52_clipboard_write: false,
right_click_paste: false,
terminal_right_click_action: "menu",
terminal_zoom_enabled: true,
command_suggestions_enabled: true,
command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS,
@@ -185,6 +185,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
transfer: {
editor_type: "external",
internal_editor_display: "workspace",
internal_editor_font_size: 13,
download_threads: 3,
upload_threads: 3,
duplicate_strategy: "ask",
+2 -1
View File
@@ -115,7 +115,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
interaction: {
copy_on_select: false,
allow_osc52_clipboard_write: false,
right_click_paste: false,
terminal_right_click_action: "menu",
terminal_zoom_enabled: true,
command_suggestions_enabled: true,
command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS,
@@ -146,6 +146,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
transfer: {
editor_type: "external",
internal_editor_display: "workspace",
internal_editor_font_size: 13,
download_threads: 3,
upload_threads: 3,
duplicate_strategy: "ask",
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import type { ThemeColors } from "@/lib/themes";
import { applyTerminalThemeToDOM } from "./ThemeContext";
describe("applyTerminalThemeToDOM", () => {
it("publishes the terminal selection color for shared editor surfaces", () => {
const selectionBackground = "#264f78";
applyTerminalThemeToDOM({
selectionBackground,
} as ThemeColors["terminal"]);
expect(document.documentElement.style.getPropertyValue("--df-terminal-selection")).toBe(
selectionBackground,
);
});
});
+1
View File
@@ -68,6 +68,7 @@ export function applyTerminalThemeToDOM(colors: ThemeColors["terminal"]) {
const root = document.documentElement.style;
root.setProperty("--df-terminal-bg", colors.background);
root.setProperty("--df-terminal-fg", colors.foreground);
root.setProperty("--df-terminal-selection", colors.selectionBackground);
}
/** Provides theme, themeName, setTheme. Syncs with appSettings.appearance.theme from backend. */
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef } from "react";
import {
decreaseFileEditorFontSize,
increaseFileEditorFontSize,
} from "@/lib/fileEditorFontSize";
import type { AppSettings } from "@/types/global";
type UpdateAppSettings = (
updates: Partial<AppSettings> | ((prev: AppSettings) => Partial<AppSettings>),
) => void;
const CTRL_WHEEL_ZOOM_THROTTLE_MS = 50;
const FILE_EDITOR_ROOT_SELECTOR = '[data-file-editor-root="true"]';
function isElement(value: EventTarget | null): value is Element {
return value instanceof Element;
}
function eventTargetIsInsideFileEditorRoot(event: WheelEvent) {
const pathContainsFileEditorRoot = event.composedPath().some((target) => {
if (!isElement(target)) return false;
return target.matches(FILE_EDITOR_ROOT_SELECTOR);
});
if (pathContainsFileEditorRoot) return true;
const target = event.target;
return isElement(target) && target.closest(FILE_EDITOR_ROOT_SELECTOR) !== null;
}
export function useFileEditorZoom(updateAppSettings: UpdateAppSettings) {
const lastCtrlWheelZoomAtRef = useRef(0);
const handleZoomIn = useCallback(() => {
updateAppSettings((prev) => ({
transfer: {
...prev.transfer,
internal_editor_font_size: increaseFileEditorFontSize(
prev.transfer.internal_editor_font_size,
),
},
}));
}, [updateAppSettings]);
const handleZoomOut = useCallback(() => {
updateAppSettings((prev) => ({
transfer: {
...prev.transfer,
internal_editor_font_size: decreaseFileEditorFontSize(
prev.transfer.internal_editor_font_size,
),
},
}));
}, [updateAppSettings]);
useEffect(() => {
const handleCtrlWheelZoom = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
if (event.deltaY === 0) return;
if (!eventTargetIsInsideFileEditorRoot(event)) return;
event.preventDefault();
const now = Date.now();
if (now - lastCtrlWheelZoomAtRef.current < CTRL_WHEEL_ZOOM_THROTTLE_MS) return;
lastCtrlWheelZoomAtRef.current = now;
if (event.deltaY < 0) {
handleZoomIn();
} else {
handleZoomOut();
}
};
window.addEventListener("wheel", handleCtrlWheelZoom, { passive: false, capture: true });
return () => {
window.removeEventListener("wheel", handleCtrlWheelZoom, true);
};
}, [handleZoomIn, handleZoomOut]);
return {
handleZoomIn,
handleZoomOut,
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { Terminal } from "@xterm/xterm";
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance";
import type { AppSettings } from "@/types/global";
import { useKeywordHighlighter } from "./useKeywordHighlighter";
const highlighterMocks = vi.hoisted(() => ({
instances: [] as Array<{
dispose: ReturnType<typeof vi.fn>;
releaseCaches: ReturnType<typeof vi.fn>;
setRules: ReturnType<typeof vi.fn>;
setSuspended: ReturnType<typeof vi.fn>;
}>,
}));
vi.mock("../lib/keywordHighlighter", () => ({
KeywordHighlighter: class {
dispose = vi.fn();
releaseCaches = vi.fn();
setRules = vi.fn();
setSuspended = vi.fn();
constructor() {
highlighterMocks.instances.push(this);
}
},
}));
vi.mock("../lib/keywordHighlightPresets", () => ({ getBuiltinRules: () => [] }));
const settings = {
keyword_highlights_enabled: true,
keyword_highlights: [],
keyword_highlight_builtin_rules: {},
keyword_highlights_across_wrapped_lines: false,
} as unknown as AppSettings["terminal"];
describe("useKeywordHighlighter cache release policy", () => {
beforeEach(() => {
vi.useFakeTimers();
highlighterMocks.instances.length = 0;
});
afterEach(() => {
vi.useRealTimers();
});
it("suspends under pressure without scheduling a cache release", () => {
const terminal = {} as Terminal;
renderHook(() =>
useKeywordHighlighter(terminal, settings, "session-1", true, {
suspended: true,
releaseCachesAfterDelay: false,
}),
);
const highlighter = highlighterMocks.instances[0];
vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.lifecycle.hiddenCacheReleaseDelayMs);
expect(highlighter.setSuspended).toHaveBeenCalledWith(true);
expect(highlighter.releaseCaches).not.toHaveBeenCalled();
});
it("releases caches after a terminal stays hidden", () => {
const terminal = {} as Terminal;
renderHook(() =>
useKeywordHighlighter(terminal, settings, "session-1", true, {
suspended: true,
releaseCachesAfterDelay: true,
}),
);
const highlighter = highlighterMocks.instances[0];
vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.lifecycle.hiddenCacheReleaseDelayMs);
expect(highlighter.releaseCaches).toHaveBeenCalledTimes(1);
});
});
+11 -6
View File
@@ -18,14 +18,19 @@ import type { AppSettings, KeywordHighlightRule } from "../types/global";
export function useKeywordHighlighter(
terminal: Terminal | null,
terminalSettings: AppSettings["terminal"],
_sessionId: string,
sessionId: string,
isDark: boolean,
suspended = false,
options: {
suspended?: boolean;
releaseCachesAfterDelay?: boolean;
} = {},
): void {
const highlighterRef = useRef<KeywordHighlighter | null>(null);
const cacheReleaseTimerRef = useRef<number | null>(null);
const [highlighterInstance, setHighlighterInstance] = useState<KeywordHighlighter | null>(null);
const enabled = terminalSettings.keyword_highlights_enabled ?? false;
const suspended = options.suspended ?? false;
const releaseCachesAfterDelay = options.releaseCachesAfterDelay ?? false;
// Merge user rules (higher priority) + built-in rules (lower priority).
// User rules carry two color fields; pick the right one for the current theme
@@ -62,7 +67,7 @@ export function useKeywordHighlighter(
if (!terminal) return;
const highlighter = new KeywordHighlighter(terminal);
const highlighter = new KeywordHighlighter(terminal, sessionId);
highlighterRef.current = highlighter;
setHighlighterInstance(highlighter);
@@ -71,7 +76,7 @@ export function useKeywordHighlighter(
highlighterRef.current = null;
setHighlighterInstance((current) => (current === highlighter ? null : current));
};
}, [terminal, enabled]);
}, [terminal, enabled, sessionId]);
// Re-push rules whenever settings change or theme family switches.
useEffect(() => {
@@ -97,7 +102,7 @@ export function useKeywordHighlighter(
cacheReleaseTimerRef.current = null;
}
if (suspended) {
if (suspended && releaseCachesAfterDelay) {
cacheReleaseTimerRef.current = window.setTimeout(() => {
cacheReleaseTimerRef.current = null;
highlighterInstance.releaseCaches();
@@ -110,5 +115,5 @@ export function useKeywordHighlighter(
cacheReleaseTimerRef.current = null;
}
};
}, [highlighterInstance, suspended]);
}, [highlighterInstance, releaseCachesAfterDelay, suspended]);
}
+38
View File
@@ -0,0 +1,38 @@
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { useMcpActiveSession } from "./useMcpActiveSession";
const mocks = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke }));
beforeEach(() => {
vi.clearAllMocks();
mocks.invoke.mockResolvedValue(undefined);
});
it("reports active session changes and clearing to the MCP host", async () => {
const { rerender } = renderHook(({ sessionId }) => useMcpActiveSession(sessionId), {
initialProps: { sessionId: "session-a" as string | null },
});
await waitFor(() =>
expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", {
sessionId: "session-a",
}),
);
rerender({ sessionId: "session-b" });
await waitFor(() =>
expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", {
sessionId: "session-b",
}),
);
rerender({ sessionId: null });
await waitFor(() =>
expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", {
sessionId: null,
}),
);
});
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from "react";
import { invoke } from "@/lib/invoke";
export function useMcpActiveSession(activeSessionId: string | null) {
useEffect(() => {
void invoke("report_mcp_active_session", {
sessionId: activeSessionId,
}).catch(() => {});
}, [activeSessionId]);
}
+223
View File
@@ -0,0 +1,223 @@
import { renderHook } from "@testing-library/react";
import type { Terminal } from "@xterm/xterm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler";
import type { TerminalColors } from "@/lib/themes";
import type { AppSettings } from "@/types/global";
import { useTerminalSettings } from "./useTerminalSettings";
const webglMocks = vi.hoisted(() => ({
instances: [] as Array<{
dispose: ReturnType<typeof vi.fn>;
contextLoss?: () => void;
}>,
}));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class {
dispose = vi.fn();
constructor() {
webglMocks.instances.push(this);
}
onContextLoss(callback: () => void) {
webglMocks.instances[webglMocks.instances.length - 1].contextLoss =
callback;
return { dispose: vi.fn() };
}
},
}));
vi.mock("@/lib/xtermImeCompatibility", () => ({
installImeCompatibilityPatch: () => ({ dispose: vi.fn() }),
}));
const theme = (background: string): TerminalColors =>
({ background, foreground: "#ffffff" }) as TerminalColors;
const appearance = (fontSize: number): AppSettings["appearance"] =>
({
font_family: "JetBrains Mono",
font_size: fontSize,
font_weight: "normal",
font_weight_bold: "bold",
cursor_blink: true,
cursor_style: "block",
minimum_contrast_ratio: 1,
}) as unknown as AppSettings["appearance"];
const terminalSettings = {
hardware_acceleration: true,
font_size_delta: 0,
scrollback_lines: 5_000,
} as AppSettings["terminal"];
const interaction = {
word_separators: " ()[]{}'\"",
alt_as_meta: false,
ime_compatibility: false,
} as AppSettings["interaction"];
describe("useTerminalSettings renderer refresh", () => {
let rafCallbacks: Map<number, FrameRequestCallback>;
let rafRequests: ReturnType<typeof vi.fn>;
let nextRafId: number;
beforeEach(() => {
vi.useFakeTimers();
webglMocks.instances.length = 0;
rafCallbacks = new Map();
nextRafId = 1;
rafRequests = vi.fn((callback: FrameRequestCallback) => {
const id = nextRafId++;
rafCallbacks.set(id, callback);
return id;
});
vi.stubGlobal("requestAnimationFrame", rafRequests);
vi.stubGlobal("cancelAnimationFrame", (id: number) =>
rafCallbacks.delete(id),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const flushAnimationFrames = () => {
while (rafCallbacks.size > 0) {
const callbacks = [...rafCallbacks.values()];
rafCallbacks.clear();
for (const callback of callbacks) callback(performance.now());
}
};
function createHookHarness(
rendererVisible = true,
snapshotRestoring = false,
) {
const terminal = {
rows: 24,
options: {},
clearTextureAtlas: vi.fn(),
refresh: vi.fn(),
loadAddon: vi.fn(),
} as unknown as Terminal;
const terminalRef = { current: terminal };
const fitSchedulerRef = {
current: { schedule: vi.fn() } as unknown as TerminalFitScheduler,
};
const snapshotRestoringRef = { current: snapshotRestoring };
const initialProps = {
visible: rendererVisible,
colors: theme("#000000"),
ui: appearance(14),
};
const hook = renderHook(
(props: {
visible: boolean;
colors: TerminalColors;
ui: AppSettings["appearance"];
}) =>
useTerminalSettings(
terminalRef,
fitSchedulerRef,
props.colors,
props.ui,
terminalSettings,
interaction,
props.visible,
terminal,
"session-1",
snapshotRestoringRef,
),
{
initialProps,
},
);
return {
...hook,
terminal,
terminalRef,
fitSchedulerRef,
initialProps,
snapshotRestoringRef,
};
}
it("installs WebGL and schedules only one reveal chain", () => {
const { terminal } = createHookHarness();
flushAnimationFrames();
expect(terminal.loadAddon).toHaveBeenCalledTimes(1);
expect(terminal.refresh).toHaveBeenCalledTimes(3);
expect(terminal.clearTextureAtlas).toHaveBeenCalledTimes(1);
expect(rafRequests).toHaveBeenCalledTimes(3);
});
it("repaints hidden-to-visible WebGL without clearing the texture atlas", () => {
const harness = createHookHarness();
const stableColors = theme("#000000");
const stableAppearance = appearance(14);
flushAnimationFrames();
vi.mocked(harness.terminal.refresh).mockClear();
vi.mocked(harness.terminal.clearTextureAtlas).mockClear();
harness.rerender({
visible: false,
colors: stableColors,
ui: stableAppearance,
});
flushAnimationFrames();
vi.mocked(harness.terminal.refresh).mockClear();
vi.mocked(harness.terminal.clearTextureAtlas).mockClear();
harness.rerender({
visible: true,
colors: stableColors,
ui: stableAppearance,
});
flushAnimationFrames();
expect(harness.terminal.refresh).toHaveBeenCalledTimes(2);
expect(harness.terminal.clearTextureAtlas).not.toHaveBeenCalled();
});
it("does not add WebGL or settings refreshes during snapshot restore", () => {
const harness = createHookHarness(true, true);
flushAnimationFrames();
expect(harness.terminal.loadAddon).toHaveBeenCalledTimes(1);
expect(harness.terminal.refresh).not.toHaveBeenCalled();
expect(harness.fitSchedulerRef.current?.schedule).not.toHaveBeenCalled();
harness.snapshotRestoringRef.current = false;
harness.rerender(harness.initialProps);
flushAnimationFrames();
expect(harness.terminal.refresh).not.toHaveBeenCalled();
});
it("still clears the texture atlas for theme and font changes", () => {
const harness = createHookHarness();
flushAnimationFrames();
vi.mocked(harness.terminal.clearTextureAtlas).mockClear();
harness.rerender({
visible: true,
colors: theme("#101010"),
ui: appearance(14),
});
flushAnimationFrames();
expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1);
vi.mocked(harness.terminal.clearTextureAtlas).mockClear();
harness.rerender({
visible: true,
colors: theme("#101010"),
ui: appearance(16),
});
flushAnimationFrames();
expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1);
});
});
+26 -11
View File
@@ -12,6 +12,10 @@ import type { AppSettings } from "@/types/global";
type TerminalRendererPreference = "dom" | "webgl" | "auto";
type ResolvedTerminalRendererMode = "dom" | "webgl";
function isSnapshotRestoreActive(ref?: RefObject<boolean>) {
return ref?.current === true;
}
function resolveTerminalRendererMode(options: {
preference: TerminalRendererPreference;
transparencyEnabled: boolean;
@@ -34,6 +38,7 @@ export function useTerminalSettings(
rendererVisible = true,
terminalInstance: Terminal | null = null,
sessionId?: string,
snapshotRestoringRef?: RefObject<boolean>,
) {
const webglAddonRef = useRef<WebglAddon | null>(null);
const webglTerminalRef = useRef<Terminal | null>(null);
@@ -82,24 +87,30 @@ export function useTerminalSettings(
}, []);
const scheduleTextureRefresh = useCallback(() => {
if (isSnapshotRestoreActive(snapshotRestoringRef)) return;
if (textureRefreshFrameRef.current !== null) return;
textureRefreshFrameRef.current = requestAnimationFrame(() => {
textureRefreshFrameRef.current = null;
if (isSnapshotRestoreActive(snapshotRestoringRef)) return;
const terminal = terminalRef.current;
if (!terminal) return;
terminal.clearTextureAtlas();
terminal.refresh(0, Math.max(0, terminal.rows - 1));
});
}, [terminalRef]);
}, [snapshotRestoringRef, terminalRef]);
const scheduleRevealRefresh = useCallback(() => {
cancelRevealRefresh();
if (isSnapshotRestoreActive(snapshotRestoringRef)) return;
let remainingFrames = XTERM_PERFORMANCE_CONFIG.webgl.revealRefreshFrames;
const refreshNextFrame = () => {
revealRefreshFrameRef.current = requestAnimationFrame(() => {
if (isSnapshotRestoreActive(snapshotRestoringRef)) {
revealRefreshFrameRef.current = null;
return;
}
const terminal = terminalRef.current;
if (terminal) {
terminal.clearTextureAtlas();
terminal.refresh(0, Math.max(0, terminal.rows - 1));
}
@@ -112,7 +123,7 @@ export function useTerminalSettings(
});
};
refreshNextFrame();
}, [cancelRevealRefresh, terminalRef]);
}, [cancelRevealRefresh, snapshotRestoringRef, terminalRef]);
useEffect(() => {
return () => {
@@ -172,7 +183,6 @@ export function useTerminalSettings(
}
clearHiddenWebglDisposeTimer();
scheduleRevealRefresh();
const installWebgl = (targetTerminal: Terminal) => {
try {
@@ -214,7 +224,9 @@ export function useTerminalSettings(
}
};
if (!webglAddonRef.current) {
if (webglAddonRef.current) {
scheduleRevealRefresh();
} else {
installWebgl(terminal);
}
}, [
@@ -253,12 +265,14 @@ export function useTerminalSettings(
scheduleTextureRefresh();
// Auto-fit on font size change
fitSchedulerRef.current?.schedule({
reason: "appearance",
force: true,
refresh: true,
clearTextureAtlas: true,
});
if (!isSnapshotRestoreActive(snapshotRestoringRef)) {
fitSchedulerRef.current?.schedule({
reason: "appearance",
force: true,
refresh: true,
clearTextureAtlas: true,
});
}
}
}, [
appearance,
@@ -266,6 +280,7 @@ export function useTerminalSettings(
terminalRef,
fitSchedulerRef,
scheduleTextureRefresh,
snapshotRestoringRef,
]);
// React to terminal core settings changes: scrollback
+28 -13
View File
@@ -141,6 +141,7 @@
"disabled": "AI is disabled",
"empty": "Ask AI to explain output or generate commands.",
"enableAutoExecution": "Enable auto",
"enableFullAccess": "Enable full access",
"enableOneModelHint": "Enable at least one model before using the AI panel.",
"enabled": "Enable AI assistant",
"errorDetected": "Terminal error detected",
@@ -160,8 +161,8 @@
"externalMcp": "External MCP",
"externalMcpAllSessions": "All sessions",
"externalMcpAllowOnce": "Allow once",
"externalMcpAllowSession": "Allow for this MCP session",
"externalMcpApprovalDesc": "An MCP client is requesting access to a NyaTerm session.",
"externalMcpAllowSession": "Allow for this connection",
"externalMcpApprovalDesc": "An MCP client is requesting access to a NyaTerm capability.",
"externalMcpApprovalTitle": "External MCP approval",
"externalMcpCapability": "Capability",
"externalMcpClient": "Client",
@@ -169,22 +170,20 @@
"externalMcpCopyConfig": "Copy config",
"externalMcpCurrentWindow": "Current window",
"externalMcpDeny": "Deny",
"externalMcpDesc": "Allow external MCP clients to use a snapshot of the selected NyaTerm sessions.",
"externalMcpDesc": "Persistently allow external MCP clients to discover connections and use scoped NyaTerm sessions.",
"externalMcpDisabled": "Disabled",
"externalMcpEnabled": "Enable External MCP",
"externalMcpError": "Error",
"externalMcpIdleTimeout": "Idle timeout (minutes)",
"externalMcpPersistent": "Persistent",
"externalMcpRisk": "Risk",
"externalMcpRunning": "Running",
"externalMcpRuntimeSummary": "Window: {{window}} · Sessions: {{sessions}} · Connections: {{connections}}",
"externalMcpScope": "Session scope",
"externalMcpServerMode": "Server mode",
"externalMcpSession": "Session",
"externalMcpTemporary": "Temporary",
"externalMcpTarget": "Target",
"fileActions": "File AI actions",
"fileUnsupported": "File not supported for AI",
"formattingResponse": "Formatting",
"fullAccessConfirmDesc": "{{target}} will be able to execute high-risk commands, writes, overwrites, and deletions through NyaTerm without further approval. Session scope, validation, and auditing remain enabled.",
"fullAccessConfirmTitle": "Enable full access?",
"general": "General",
"generate": "Generate",
"generateCommand": "Generate Command",
@@ -240,10 +239,15 @@
"notConfigured": "Not set",
"notInstalled": "Not installed",
"panelMetaMultiTarget": "{{target}} + {{count}} sessions",
"permissionAuto": "Auto",
"permissionConfirm": "Confirm",
"permissionAuto": "Safe auto",
"permissionAutoDesc": "Automatically allows locally recognized safe actions; unknown, high-risk, and destructive actions still require confirmation.",
"permissionConfirm": "Always confirm",
"permissionConfirmDesc": "Sensitive reads and all write operations require confirmation.",
"permissionFullAccess": "Full access",
"permissionFullAccessDesc": "All scoped NyaTerm capabilities run without approval, including high-risk and destructive actions.",
"permissionMode": "Permission mode",
"permissionObserver": "Observer",
"permissionObserver": "Read-only",
"permissionObserverDesc": "Allows basic reads, asks before sensitive reads, and blocks write operations.",
"placeholder": "Ask anything... Type @ for sessions",
"profileName": "Name",
"providerKind": "Provider",
@@ -660,6 +664,9 @@
"sftpFilenameEncoding": "SFTP filename encoding",
"sftpFilenameEncodingDesc": "Use this when remote filenames are not UTF-8.",
"sftpFilenameEncodingFollowTerminal": "Follow terminal encoding",
"sftpPipelineDepth": "Pipeline depth",
"sftpPipelineDepthAuto": "Automatic",
"sftpPipelineDepthDesc": "Controls the number of in-flight SFTP requests for a single file. Higher values may improve throughput on high-latency networks, but use more connection and server resources. Automatic is recommended.",
"sftpShellDetectionTimeout": "Shell detection timeout",
"sftpShellDetectionTimeoutDesc": "How long to wait for shell type detection before skipping directory tracking setup.",
"sftpShellDetectionTimeoutInvalid": "Shell detection timeout must be between {{min}} and {{max}} ms",
@@ -1042,8 +1049,11 @@
"special": "Special",
"sureDelete": "Delete '{{name}}'?",
"sureDeleteMultiple": "Delete {{count}} selected items?",
"symbolicLink": "Symbolic Link",
"symlinkName": "Symlink Name",
"symlinkTarget": "Symlink Target",
"symlinkTargetRequired": "Symlink target cannot be empty.",
"symlinkTargetSaved": "Symlink target saved",
"syncFailed": "Path sync failed",
"syncTerminalPath": "Sync Path",
"targetCwdUnavailable": "Target session current directory is unavailable",
@@ -1697,6 +1707,7 @@
"sortDefault": "Custom Order",
"sortNameAsc": "Name A → Z",
"sortNameDesc": "Name Z → A",
"sshConfigSource": "SSH Config",
"stopBits": "Stop bits",
"terminalPath": "Terminal path",
"ungroupedConnections": "Ungrouped",
@@ -2252,8 +2263,6 @@
"resumeBrokenTransfer": "Resume Transfers",
"resumeBrokenTransferDesc": "Resume incomplete transfers instead of restarting.",
"revisionLabel": "Revision",
"rightClickPaste": "Right-click Paste",
"rightClickPasteDesc": "Paste clipboard text on terminal right-click.",
"s3AccessKeyId": "Access Key ID",
"s3Bucket": "Bucket",
"s3BucketRequired": "S3 bucket is required.",
@@ -2397,6 +2406,11 @@
"terminalFontWeightBold": "Bold Font Weight",
"terminalFontWeightBoldDesc": "Weight used when terminal output requests bold text.",
"terminalFontWeightDesc": "Weight used for normal terminal text.",
"terminalRightClickAction": "Right-click Behavior",
"terminalRightClickActionDesc": "Choose what happens when you right-click in the terminal.",
"terminalRightClickMenu": "Menu",
"terminalRightClickNone": "Off",
"terminalRightClickPaste": "Paste",
"terminalShortcuts": "Terminal Hotkeys",
"terminalShortcutsDesc": "Shortcuts inside the terminal.",
"terminalTheme": "Terminal Theme",
@@ -2791,6 +2805,7 @@
"pasteSelectedText": "Paste Selected Text",
"recordingLogs": "Recording Logs",
"recordingSettings": "Settings...",
"saveAsQuickCommand": "Set as Quick Command",
"searchCaseSensitive": "Case sensitive",
"searchCurrentBuffer": "Current Buffer",
"searchDeepHistory": "Deep History",
+28 -13
View File
@@ -141,6 +141,7 @@
"disabled": "AI가 비활성화되어 있습니다",
"empty": "AI에게 출력 설명이나 명령 생성을 요청하세요.",
"enableAutoExecution": "자동 실행 켜기",
"enableFullAccess": "전체 권한 사용",
"enableOneModelHint": "AI 패널을 사용하려면 모델을 하나 이상 활성화하세요.",
"enabled": "AI 어시스턴트 사용",
"errorDetected": "터미널 오류가 감지되었습니다",
@@ -160,8 +161,8 @@
"externalMcp": "외부 MCP",
"externalMcpAllSessions": "모든 세션",
"externalMcpAllowOnce": "한 번 허용",
"externalMcpAllowSession": "이 MCP 세션에서 허용",
"externalMcpApprovalDesc": "MCP 클라이언트가 NyaTerm 세션 접근을 요청합니다.",
"externalMcpAllowSession": "이 연결에서 허용",
"externalMcpApprovalDesc": "MCP 클라이언트가 NyaTerm 기능 사용을 요청합니다.",
"externalMcpApprovalTitle": "외부 MCP 승인",
"externalMcpCapability": "기능",
"externalMcpClient": "클라이언트",
@@ -169,22 +170,20 @@
"externalMcpCopyConfig": "설정 복사",
"externalMcpCurrentWindow": "현재 창",
"externalMcpDeny": "거부",
"externalMcpDesc": "외부 MCP 클라이언트가 선택된 NyaTerm 세션의 활성화 시점 스냅샷을 사용하도록 허용합니다.",
"externalMcpDesc": "외부 MCP 클라이언트가 연결을 검색하고 범위 내 NyaTerm 세션을 사용하도록 영구적으로 허용합니다.",
"externalMcpDisabled": "비활성화됨",
"externalMcpEnabled": "외부 MCP 활성화",
"externalMcpError": "오류",
"externalMcpIdleTimeout": "유휴 시간 제한(분)",
"externalMcpPersistent": "영구",
"externalMcpRisk": "위험",
"externalMcpRunning": "실행 중",
"externalMcpRuntimeSummary": "창: {{window}} · 세션: {{sessions}} · 연결: {{connections}}",
"externalMcpScope": "세션 범위",
"externalMcpServerMode": "서버 모드",
"externalMcpSession": "세션",
"externalMcpTemporary": "임시",
"externalMcpTarget": "대상",
"fileActions": "파일 AI 작업",
"fileUnsupported": "AI가 지원하지 않는 파일입니다",
"formattingResponse": "서식 지정 중",
"fullAccessConfirmDesc": "{{target}}에서 NyaTerm을 통해 고위험 명령, 쓰기, 덮어쓰기 및 삭제 작업을 추가 승인 없이 실행할 수 있습니다. 세션 범위, 매개변수 검증 및 감사는 계속 적용됩니다.",
"fullAccessConfirmTitle": "전체 권한을 사용할까요?",
"general": "일반",
"generate": "생성",
"generateCommand": "명령 생성",
@@ -240,10 +239,15 @@
"notConfigured": "설정되지 않음",
"notInstalled": "설치되지 않음",
"panelMetaMultiTarget": "{{target}} + 세션 {{count}}개",
"permissionAuto": "자동",
"permissionConfirm": "확인",
"permissionAuto": "안전 자동",
"permissionAutoDesc": "로컬에서 안전하다고 확인된 작업은 자동 허용하며, 알 수 없거나 고위험 또는 파괴적인 작업은 계속 확인합니다.",
"permissionConfirm": "매번 확인",
"permissionConfirmDesc": "민감한 읽기와 모든 쓰기 작업에 확인이 필요합니다.",
"permissionFullAccess": "전체 권한",
"permissionFullAccessDesc": "범위 내 모든 NyaTerm 기능을 승인 없이 실행하며, 고위험 및 파괴적인 작업도 포함합니다.",
"permissionMode": "권한 모드",
"permissionObserver": "관찰",
"permissionObserver": "읽기 전용",
"permissionObserverDesc": "일반 읽기는 허용하고 민감한 읽기는 확인하며 쓰기 작업은 차단합니다.",
"placeholder": "무엇이든 물어보세요... @를 입력하면 세션을 선택할 수 있습니다",
"profileName": "이름",
"providerKind": "공급자",
@@ -659,6 +663,9 @@
"sftpFilenameEncoding": "SFTP 파일 이름 인코딩",
"sftpFilenameEncodingDesc": "원격 파일 이름이 UTF-8이 아닐 때 사용합니다.",
"sftpFilenameEncodingFollowTerminal": "터미널 인코딩 따르기",
"sftpPipelineDepth": "파이프라인 깊이",
"sftpPipelineDepthAuto": "자동",
"sftpPipelineDepthDesc": "단일 파일에서 동시에 처리되는 SFTP 요청 수를 제어합니다. 값이 클수록 지연 시간이 긴 네트워크에서 처리량이 향상될 수 있지만 연결 및 서버 리소스를 더 많이 사용합니다. 자동 설정을 권장합니다.",
"sftpShellDetectionTimeout": "셸 감지 시간 제한",
"sftpShellDetectionTimeoutDesc": "디렉터리 추적 설정을 건너뛰기 전에 셸 유형 감지를 기다릴 최대 시간입니다.",
"sftpShellDetectionTimeoutInvalid": "셸 감지 시간 제한은 {{min}}~{{max}}ms 사이여야 합니다",
@@ -1041,8 +1048,11 @@
"special": "특수",
"sureDelete": "'{{name}}'을(를) 삭제할까요?",
"sureDeleteMultiple": "선택한 {{count}}개 항목을 삭제할까요?",
"symbolicLink": "심볼릭 링크",
"symlinkName": "심볼릭 링크 이름",
"symlinkTarget": "심볼릭 링크 대상",
"symlinkTargetRequired": "심볼릭 링크 대상을 비워 둘 수 없습니다.",
"symlinkTargetSaved": "심볼릭 링크 대상이 저장되었습니다",
"syncFailed": "경로 동기화 실패",
"syncTerminalPath": "경로 동기화",
"targetCwdUnavailable": "대상 세션의 현재 디렉터리를 사용할 수 없습니다",
@@ -1696,6 +1706,7 @@
"sortDefault": "사용자 지정 순서",
"sortNameAsc": "이름 오름차순(A → Z)",
"sortNameDesc": "이름 내림차순(Z → A)",
"sshConfigSource": "SSH 설정",
"stopBits": "정지 비트",
"terminalPath": "터미널 경로",
"ungroupedConnections": "그룹 없음",
@@ -2251,8 +2262,6 @@
"resumeBrokenTransfer": "전송 재개",
"resumeBrokenTransferDesc": "완료되지 않은 전송을 처음부터 다시 시작하는 대신 재개합니다.",
"revisionLabel": "리비전",
"rightClickPaste": "우클릭 붙여넣기",
"rightClickPasteDesc": "터미널을 우클릭하면 클립보드 텍스트를 붙여넣습니다.",
"s3AccessKeyId": "액세스 키 ID",
"s3Bucket": "버킷",
"s3BucketRequired": "S3 버킷이 필요합니다.",
@@ -2396,6 +2405,11 @@
"terminalFontWeightBold": "굵은 글꼴 두께",
"terminalFontWeightBoldDesc": "터미널 출력이 굵은 텍스트를 요청할 때 사용할 두께입니다.",
"terminalFontWeightDesc": "일반 터미널 텍스트에 사용할 두께입니다.",
"terminalRightClickAction": "우클릭 동작",
"terminalRightClickActionDesc": "터미널을 우클릭할 때 실행할 동작을 선택합니다.",
"terminalRightClickMenu": "메뉴",
"terminalRightClickNone": "끄기",
"terminalRightClickPaste": "붙여넣기",
"terminalShortcuts": "터미널 단축키",
"terminalShortcutsDesc": "터미널 내부의 단축키입니다.",
"terminalTheme": "터미널 테마",
@@ -2782,6 +2796,7 @@
"pasteSelectedText": "선택한 텍스트 붙여넣기",
"recordingLogs": "녹화 로그",
"recordingSettings": "설정...",
"saveAsQuickCommand": "빠른 명령으로 저장",
"searchCaseSensitive": "대소문자 구분",
"searchCurrentBuffer": "현재 버퍼",
"searchDeepHistory": "전체 기록",
+28 -13
View File
@@ -141,6 +141,7 @@
"disabled": "AI 助手未启用",
"empty": "让 AI 帮你解释终端输出或生成命令。",
"enableAutoExecution": "开启全自动",
"enableFullAccess": "启用完全权限",
"enableOneModelHint": "至少启用一个模型才能在 AI 面板使用。",
"enabled": "启用 AI 助手",
"errorDetected": "检测到终端错误输出",
@@ -160,8 +161,8 @@
"externalMcp": "外部 MCP",
"externalMcpAllSessions": "所有会话",
"externalMcpAllowOnce": "允许一次",
"externalMcpAllowSession": "本次 MCP 会话内允许",
"externalMcpApprovalDesc": "一个 MCP 客户端正在请求访问 NyaTerm 会话。",
"externalMcpAllowSession": "本次连接中允许",
"externalMcpApprovalDesc": "一个 MCP 客户端正在请求使用 NyaTerm 能力。",
"externalMcpApprovalTitle": "外部 MCP 审批",
"externalMcpCapability": "能力",
"externalMcpClient": "客户端",
@@ -169,22 +170,20 @@
"externalMcpCopyConfig": "复制配置",
"externalMcpCurrentWindow": "当前窗口",
"externalMcpDeny": "拒绝",
"externalMcpDesc": "允许外部 MCP 客户端使用所选 NyaTerm 会话的启用时快照。",
"externalMcpDesc": "持久允许外部 MCP 客户端发现连接并使用作用域内的 NyaTerm 会话。",
"externalMcpDisabled": "已禁用",
"externalMcpEnabled": "启用外部 MCP",
"externalMcpError": "错误",
"externalMcpIdleTimeout": "空闲超时(分钟)",
"externalMcpPersistent": "持久",
"externalMcpRisk": "风险",
"externalMcpRunning": "运行中",
"externalMcpRuntimeSummary": "窗口:{{window}} · 会话:{{sessions}} · 连接:{{connections}}",
"externalMcpScope": "会话范围",
"externalMcpServerMode": "服务模式",
"externalMcpSession": "会话",
"externalMcpTemporary": "临时",
"externalMcpTarget": "目标",
"fileActions": "文件右键 AI 功能",
"fileUnsupported": "该文件暂不支持 AI 分析",
"formattingResponse": "整理中",
"fullAccessConfirmDesc": "{{target}} 将可以通过 NyaTerm 直接执行高风险命令、写入、覆盖和删除操作,不再请求确认。会话范围、参数校验和审计仍然生效。",
"fullAccessConfirmTitle": "启用完全权限?",
"general": "常规",
"generate": "生成",
"generateCommand": "AI 生成命令",
@@ -240,10 +239,15 @@
"notConfigured": "未配置",
"notInstalled": "未安装",
"panelMetaMultiTarget": "{{target}} + {{count}} 个会话",
"permissionAuto": "自动",
"permissionConfirm": "确认",
"permissionAuto": "安全自动",
"permissionAutoDesc": "自动放行本地识别为安全的操作;未知、高风险和破坏性操作仍需确认",
"permissionConfirm": "每次确认",
"permissionConfirmDesc": "敏感读取和所有写入操作都需要确认。",
"permissionFullAccess": "完全权限",
"permissionFullAccessDesc": "作用域内的所有 NyaTerm 能力均不再审批,包括高风险和破坏性操作。",
"permissionMode": "权限模式",
"permissionObserver": "观察",
"permissionObserver": "只读",
"permissionObserverDesc": "允许普通读取;敏感读取需要确认;禁止写入操作。",
"placeholder": "描述你的需求… 输入 @ 选择目标会话",
"profileName": "名称",
"providerKind": "供应商",
@@ -659,6 +663,9 @@
"sftpFilenameEncoding": "SFTP 文件名编码",
"sftpFilenameEncodingDesc": "远程文件名不是 UTF-8 时使用此设置。",
"sftpFilenameEncodingFollowTerminal": "跟随终端编码",
"sftpPipelineDepth": "Pipeline 深度",
"sftpPipelineDepthAuto": "自动",
"sftpPipelineDepthDesc": "控制单个文件同时进行的 SFTP 请求数量。较大的值可能提升高延迟网络下的传输速度,但会占用更多连接和服务器资源。建议保持自动。",
"sftpShellDetectionTimeout": "Shell 探测超时",
"sftpShellDetectionTimeoutDesc": "等待 Shell 类型探测的最长时间,超时后会跳过目录跟随初始化。",
"sftpShellDetectionTimeoutInvalid": "Shell 探测超时必须在 {{min}} 到 {{max}} ms 之间",
@@ -1041,8 +1048,11 @@
"special": "特殊",
"sureDelete": "确定要删除“{{name}}”吗?",
"sureDeleteMultiple": "确定要删除这 {{count}} 个已选项目吗?",
"symbolicLink": "符号链接",
"symlinkName": "符号链接名称",
"symlinkTarget": "符号链接目标",
"symlinkTargetRequired": "符号链接目标不能为空。",
"symlinkTargetSaved": "符号链接目标已保存",
"syncFailed": "同步终端路径失败",
"syncTerminalPath": "同步终端路径",
"targetCwdUnavailable": "目标会话当前目录不可用",
@@ -1696,6 +1706,7 @@
"sortDefault": "自定义顺序",
"sortNameAsc": "名称 A → Z",
"sortNameDesc": "名称 Z → A",
"sshConfigSource": "SSH 配置",
"stopBits": "停止位",
"terminalPath": "终端路径",
"ungroupedConnections": "未分组",
@@ -2251,8 +2262,6 @@
"resumeBrokenTransfer": "断点续传",
"resumeBrokenTransferDesc": "尝试恢复未完成的文件传输而非重新开始。",
"revisionLabel": "版本号",
"rightClickPaste": "右键粘贴",
"rightClickPasteDesc": "在终端中右键点击时从剪贴板粘贴文本。",
"s3AccessKeyId": "Access Key ID",
"s3Bucket": "Bucket",
"s3BucketRequired": "必须填写 S3 Bucket。",
@@ -2396,6 +2405,11 @@
"terminalFontWeightBold": "粗体字重",
"terminalFontWeightBoldDesc": "终端输出要求粗体文本时使用的字重。",
"terminalFontWeightDesc": "普通终端文本使用的字重。",
"terminalRightClickAction": "右键行为",
"terminalRightClickActionDesc": "选择在终端中点击鼠标右键时执行的操作。",
"terminalRightClickMenu": "菜单",
"terminalRightClickNone": "关闭",
"terminalRightClickPaste": "粘贴",
"terminalShortcuts": "终端快捷键",
"terminalShortcutsDesc": "在终端内使用的快捷操作。",
"terminalTheme": "终端主题",
@@ -2790,6 +2804,7 @@
"pasteSelectedText": "粘贴选定的文本",
"recordingLogs": "录制日志",
"recordingSettings": "设置...",
"saveAsQuickCommand": "设为快捷命令",
"searchCaseSensitive": "区分大小写",
"searchCurrentBuffer": "当前缓冲区",
"searchDeepHistory": "深度历史",
+28 -13
View File
@@ -141,6 +141,7 @@
"disabled": "AI 助手未啟用",
"empty": "讓 AI 幫你解釋終端輸出或產生命令。",
"enableAutoExecution": "開啟全自動",
"enableFullAccess": "啟用完全權限",
"enableOneModelHint": "至少啟用一個模型才能在 AI 面板使用。",
"enabled": "啟用 AI 助手",
"errorDetected": "偵測到終端錯誤輸出",
@@ -160,8 +161,8 @@
"externalMcp": "外部 MCP",
"externalMcpAllSessions": "所有工作階段",
"externalMcpAllowOnce": "允許一次",
"externalMcpAllowSession": "此 MCP 工作階段內允許",
"externalMcpApprovalDesc": "MCP 用戶端正在要求存取 NyaTerm 工作階段。",
"externalMcpAllowSession": "此連線期間允許",
"externalMcpApprovalDesc": "MCP 用戶端正在要求使用 NyaTerm 功能。",
"externalMcpApprovalTitle": "外部 MCP 核准",
"externalMcpCapability": "功能",
"externalMcpClient": "用戶端",
@@ -169,22 +170,20 @@
"externalMcpCopyConfig": "複製設定",
"externalMcpCurrentWindow": "目前視窗",
"externalMcpDeny": "拒絕",
"externalMcpDesc": "允許外部 MCP 用戶端使用所選 NyaTerm 工作階段的啟用時快照。",
"externalMcpDesc": "持久允許外部 MCP 用戶端探索連線並使用範圍內的 NyaTerm 工作階段。",
"externalMcpDisabled": "已停用",
"externalMcpEnabled": "啟用外部 MCP",
"externalMcpError": "錯誤",
"externalMcpIdleTimeout": "閒置逾時(分鐘)",
"externalMcpPersistent": "持久",
"externalMcpRisk": "風險",
"externalMcpRunning": "執行中",
"externalMcpRuntimeSummary": "視窗:{{window}} · 工作階段:{{sessions}} · 連線:{{connections}}",
"externalMcpScope": "工作階段範圍",
"externalMcpServerMode": "伺服器模式",
"externalMcpSession": "工作階段",
"externalMcpTemporary": "暫時",
"externalMcpTarget": "目標",
"fileActions": "檔案右鍵 AI 功能",
"fileUnsupported": "該檔案暫不支援 AI 分析",
"formattingResponse": "整理中",
"fullAccessConfirmDesc": "{{target}} 將可以透過 NyaTerm 直接執行高風險命令、寫入、覆寫和刪除操作,不再要求確認。工作階段範圍、參數驗證和稽核仍然有效。",
"fullAccessConfirmTitle": "啟用完全權限?",
"general": "一般",
"generate": "產生",
"generateCommand": "AI 產生命令",
@@ -240,10 +239,15 @@
"notConfigured": "未設定",
"notInstalled": "未安裝",
"panelMetaMultiTarget": "{{target}} + {{count}} 個工作階段",
"permissionAuto": "自動",
"permissionConfirm": "確認",
"permissionAuto": "安全自動",
"permissionAutoDesc": "自動允許本機識別為安全的操作;未知、高風險和破壞性操作仍需確認",
"permissionConfirm": "每次確認",
"permissionConfirmDesc": "敏感讀取和所有寫入操作都需要確認。",
"permissionFullAccess": "完全權限",
"permissionFullAccessDesc": "範圍內的所有 NyaTerm 功能均不再核准,包括高風險和破壞性操作。",
"permissionMode": "權限模式",
"permissionObserver": "觀察",
"permissionObserver": "唯讀",
"permissionObserverDesc": "允許一般讀取;敏感讀取需要確認;禁止寫入操作。",
"placeholder": "描述你的需求… 輸入 @ 選擇目標工作階段",
"profileName": "名稱",
"providerKind": "供應商",
@@ -659,6 +663,9 @@
"sftpFilenameEncoding": "SFTP 檔名編碼",
"sftpFilenameEncodingDesc": "遠端檔名不是 UTF-8 時使用此設定。",
"sftpFilenameEncodingFollowTerminal": "跟隨終端編碼",
"sftpPipelineDepth": "Pipeline 深度",
"sftpPipelineDepthAuto": "自動",
"sftpPipelineDepthDesc": "控制單一檔案同時進行的 SFTP 請求數量。較大的值可能提升高延遲網路下的傳輸速度,但會占用更多連線和伺服器資源。建議保持自動。",
"sftpShellDetectionTimeout": "Shell 探測逾時",
"sftpShellDetectionTimeoutDesc": "等待 Shell 類型探測的最長時間,逾時後會略過目錄跟隨初始化。",
"sftpShellDetectionTimeoutInvalid": "Shell 探測逾時必須在 {{min}} 到 {{max}} ms 之間",
@@ -1041,8 +1048,11 @@
"special": "特殊",
"sureDelete": "確定要刪除“{{name}}”嗎?",
"sureDeleteMultiple": "確定要刪除這 {{count}} 個已選專案嗎?",
"symbolicLink": "符號連結",
"symlinkName": "符號連結名稱",
"symlinkTarget": "符號連結目標",
"symlinkTargetRequired": "符號連結目標不能為空。",
"symlinkTargetSaved": "符號連結目標已儲存",
"syncFailed": "同步終端路徑失敗",
"syncTerminalPath": "同步終端路徑",
"targetCwdUnavailable": "目標工作階段目前目錄無法使用",
@@ -1694,6 +1704,7 @@
"sortDefault": "自訂順序",
"sortNameAsc": "名稱 A → Z",
"sortNameDesc": "名稱 Z → A",
"sshConfigSource": "SSH 設定",
"stopBits": "停止位",
"terminalPath": "終端路徑",
"user": "使用者",
@@ -2246,8 +2257,6 @@
"resumeBrokenTransfer": "斷點續傳",
"resumeBrokenTransferDesc": "嘗試恢復未完成的檔案傳輸而非重新開始。",
"revisionLabel": "版本號",
"rightClickPaste": "右鍵貼上",
"rightClickPasteDesc": "在終端中右鍵點選時從剪貼簿貼上文字。",
"s3AccessKeyId": "Access Key ID",
"s3Bucket": "Bucket",
"s3BucketRequired": "必須填寫 S3 Bucket。",
@@ -2391,6 +2400,11 @@
"terminalFontWeightBold": "粗體字重",
"terminalFontWeightBoldDesc": "終端輸出要求粗體文字時使用的字重。",
"terminalFontWeightDesc": "一般終端文字使用的字重。",
"terminalRightClickAction": "右鍵行為",
"terminalRightClickActionDesc": "選擇在終端中按一下滑鼠右鍵時執行的操作。",
"terminalRightClickMenu": "選單",
"terminalRightClickNone": "關閉",
"terminalRightClickPaste": "貼上",
"terminalShortcuts": "終端快捷鍵",
"terminalShortcutsDesc": "在終端內使用的快捷操作。",
"terminalTheme": "終端主題",
@@ -2785,6 +2799,7 @@
"pasteSelectedText": "貼上選取的文字",
"recordingLogs": "錄製日誌",
"recordingSettings": "設定...",
"saveAsQuickCommand": "設為快捷命令",
"searchCaseSensitive": "區分大小寫",
"searchCurrentBuffer": "目前緩衝區",
"searchDeepHistory": "深度歷史",

Some files were not shown because too many files have changed in this diff Show More