Compare commits

..

3 Commits

Author SHA1 Message Date
TachibanaLolo
ec04334a21 Merge branch 'binaricat:main' into main
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-01-09 22:03:02 +08:00
TachibanaLolo
57e3641ec5 docs: add Netcatty feature todo list 2026-01-09 22:02:34 +08:00
TachibanaLolo
8258ad6e95 Merge pull request #1 from AkarinServer/feature/linux-build-support
feat: add linux build support (x64/arm64)
2026-01-08 23:22:16 +08:00
475 changed files with 17600 additions and 105854 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)"
]
}
}

View File

@@ -1,108 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine version priority:
// 1. VERSION env variable
// 2. Valid version tag (v1.2.3 format)
// 3. Short commit ID (first 7 chars of GITHUB_SHA)
// 4. package.json version as fallback
function getVersion() {
if (process.env.VERSION) {
return process.env.VERSION;
}
const refName = process.env.GITHUB_REF_NAME;
// Check if refName is a valid version tag (e.g., v1.2.3)
if (refName && /^v\d+\.\d+\.\d+/.test(refName)) {
return refName.replace(/^v/, '');
}
// Use short commit ID
const sha = process.env.GITHUB_SHA;
if (sha) {
return sha.substring(0, 7);
}
// Fall back to package.json version
try {
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return pkg.version;
} catch {
return '0.0.0';
}
}
const version = getVersion();
const repo = process.env.GITHUB_REPOSITORY || 'binaricat/netcatty';
// For tag releases, use the tag; for workflow_dispatch, create a tag from version
const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.GITHUB_REF_NAME))
? process.env.GITHUB_REF_NAME
: `v${version}`;
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
// Note: electron-builder uses different arch names for Linux packages:
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
},
linux: {
appimage: {
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
arm64: `Netcatty-${version}-linux-arm64.AppImage`
},
deb: {
x64: `Netcatty-${version}-linux-amd64.deb`,
arm64: `Netcatty-${version}-linux-arm64.deb`
},
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
}
}
};
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
intel: `[![DMG Intel X64](https://img.shields.io/badge/DMG-Intel_X64-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.x64})`
},
linux: {
appimage_x64: `[![AppImage x64](https://img.shields.io/badge/AppImage-x64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.x64})`,
appimage_arm64: `[![AppImage arm64](https://img.shields.io/badge/AppImage-arm64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.arm64})`,
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
}
};
const content = `
## Download based on your OS:
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;
fs.writeFileSync('release_notes.md', content);
console.log('Generated release_notes.md');

View File

@@ -13,18 +13,12 @@ on:
jobs:
build:
name: build-${{ matrix.name }}
name: build-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: macos
os: macos-latest
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
os: [macos-latest, windows-latest, ubuntu-latest]
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -37,55 +31,43 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: npm
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
fi
VERSION="${GITHUB_REF_NAME#v}"
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
- name: Build package (macOS)
if: matrix.os == 'macos-latest'
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:mac
- name: Build package (Windows)
if: matrix.os == 'windows-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
# macOS code signing & notarization (only for macOS builds)
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-${{ matrix.name }}
name: netcatty-${{ matrix.os }}
path: |
release/*.dmg
release/*.zip
@@ -95,157 +77,17 @@ jobs:
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-22.04
env:
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
env:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Build package
env:
npm_config_arch: x64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-x64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
env:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
release/latest*.yml
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
needs: build
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -259,74 +101,20 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Verify update metadata files
run: |
missing=0
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "Re-downloading individual artifacts to recover missing files..."
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
tmpdir="/tmp/artifact-${name}"
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
if [ -d "${tmpdir}" ]; then
for yml in "${tmpdir}"/latest*.yml; do
[ -f "$yml" ] && cp -v "$yml" artifacts/
done
fi
done
echo "After recovery:"
ls -la artifacts/*.yml
fi
# Final check — fail if any update yml is still missing
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::error::$f is still missing after recovery attempt"
exit 1
fi
done
echo "All update metadata files present."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify downloaded Linux amd64 deb artifact
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
- name: Verify downloaded Linux arm64 deb artifact metadata
env:
VERIFY_LOAD: "0"
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.msi
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.yml
artifacts/*.tar.gz
artifacts/*.blockmap
artifacts/latest*.yml
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

29
.gitignore vendored
View File

@@ -17,8 +17,7 @@ dist-ssr
*.tsbuildinfo
coverage
/.vite
/build/*
!/build/icons
/build
/electron/native/**/build
/release
/out
@@ -34,29 +33,3 @@ coverage
*.njsproj
*.sln
*.sw?
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
# Test suite (local only, not committed)
/tests/
/vitest.config.ts
# Serena MCP project config (local only)
/.serena/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat

1409
App.tsx

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
# Changelog
## [Unreleased] - 2026-03-11
### 功能
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
- 发现新版本后自动开始下载(`autoDownload=true`
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
- 下载失败时弹出错误 toast提供"打开 Releases"降级入口
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
### 设计原理
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
- `manualCheckStatus` 字段追踪手动检查 UI 状态idle/checking/available/up-to-date/error`autoDownloadStatus` 在 UI 层按优先级渲染
- `SettingsSystemTab` 不再持有本地 update state单向接收 `useUpdateCheck` 统一数据
- 将原有两套独立系统GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
### 接口变更SettingsSystemTabProps
- 移除:`autoDownloadStatus``downloadPercent`
- 新增:`updateState`(完整 UpdateState`checkNow``installUpdate``openReleasePage`
### 注意事项
- `checkNow` 语义:使用 GitHub API`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
- 此功能仅对打包后的应用Windows NSIS、macOS dmg/zip、Linux AppImage生效dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复

View File

@@ -5,19 +5,18 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -47,20 +46,20 @@
# 目次 <!-- omit in toc -->
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
- [分割ターミナル](#分割ターミナル)
- [ホスト管理](#ホスト管理)
- [ターミナル](#ターミナル)
- [SFTP](#sftp)
- [キーチェーン](#キーチェーン)
- [ポートフォワーディング](#ポートフォワーディング)
- [クラウド同期](#クラウド同期)
- [テーマとカスタマイズ](#テーマとカスタマイズ)
- [対応ディストリビューション](#対応ディストリビューション)
- [はじめに](#はじめに)
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -71,119 +70,174 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
---
<a name="なぜ-netcatty"></a>
# なぜ Netcatty
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
---
<a name="機能"></a>
# 機能
### 🗂 Vault
- **複数ビュー** — グリッド / リスト / ツリー
- **高速検索** — ホストやグループを素早く見つける
### 🖥️ ターミナルワークスペース
### 🖥 ターミナルとセッション
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **セッション管理** — 複数の接続を並行して扱う
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
- **セッション永続化** — 再起動後もセッションを復元
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
### 🔐 SSH クライアント
- **SSH2 プロトコル**、完全な認証サポート
- **パスワード&キー認証**
- **SSH 証明書**サポート
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
- **エージェント転送** — OpenSSH Agent および Pageant 対応
- **環境変数** — ホストごとにカスタム環境変数を設定
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
### 📁 SFTP
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
- **ドラッグ&ドロップ**ファイル転送
- **キュー管理**でバッチ転送
- **進捗追跡**、転送速度表示
---
### 🔑 キーチェーン
- **SSH キー生成** — RSA、ECDSA、ED25519
- **既存キーのインポート** — PEM、OpenSSH 形式
- **SSH 証明書**サポート
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
- **公開鍵をエクスポート**してリモートホストへ
<a name="デモ"></a>
# デモ
### 🔌 ポートフォワーディング
- **ローカルフォワーディング** — リモートサービスをローカルに公開
- **リモートフォワーディング** — ローカルサービスをリモートに公開
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
- **ビジュアルトンネル管理**
動画で機能をさっと確認できます(素材は `screenshots/gifs/`
### ☁️ クラウド同期
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
- **ホスト、キー、スニペット、設定を同期**
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
### 🎨 テーマとカスタマイズ
- **ライト&ダークモード**
- **カスタムアクセントカラー**
- **50以上のターミナル配色**
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
- **多言語対応** — English、简体中文 など
---
<a name="スクリーンショット"></a>
# スクリーンショット
<a name="メインウィンドウ"></a>
## メインウィンドウ
<a name="ホスト管理"></a>
## ホスト管理
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
**ダークモード**
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
![ダークモード](screenshots/main-window-dark.png)
<a name="vault-ビュー"></a>
## Vault ビュー
**ライトモード**
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
![ライトモード](screenshots/main-window-light.png)
![Vault グリッドビュー](screenshots/vault_grid_view.png)
**リストビュー**
![Vault リストビュー](screenshots/vault_list_view.png)
![リストビュー](screenshots/main-window-dark-list.png)
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
<a name="ターミナル"></a>
## ターミナル
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
**分割ウィンドウ**
![分割ウィンドウ](screenshots/split-window.png)
**テーマカスタマイズ**
![テーマカスタマイズ](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP ビュー](screenshots/sftp.png)
<a name="キーチェーン"></a>
## キーチェーン
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
| キータイプ | アルゴリズム | 推奨用途 |
|----------|------------|---------|
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
**機能:**
- 🔑 カスタマイズ可能なビット長でキーを生成
- 📥 PEM/OpenSSH 形式のキーをインポート
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
![キーマネージャー](screenshots/key-manager.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
| タイプ | 方向 | ユースケース | 例 |
|-------|-----|------------|---|
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306``localhost:3306` に転送 |
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
![ポートフォワーディング](screenshots/port-forwadring.png)
<a name="クラウド同期"></a>
## クラウド同期
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|------------|----------|-----------------|
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
**同期対象:**
- ✅ ホストと接続設定
- ✅ SSH キーと証明書
- ✅ アイデンティティと認証情報
- ✅ スニペットとスクリプト
- ✅ カスタムグループとタグ
- ✅ ポートフォワーディングルール
- ✅ アプリケーション設定
![クラウド同期](screenshots/cloud-sync.png)
<a name="テーマとカスタマイズ"></a>
## テーマとカスタマイズ
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
![テーマと国際化](screenshots/app-themes-i18n.png)
---
<a name="対応ディストリビューション"></a>
# 対応ディストリビューション
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -198,7 +252,6 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
@@ -210,15 +263,19 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| OS | サポート状況 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
### 前提条件
- Node.js 18+ と npm
@@ -272,7 +329,7 @@ npm run pack
# 特定のプラットフォーム用にパッケージ
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS インストーラー)
npm run pack:linux # Linux (AppImage + DEB + RPM)
npm run pack:linux # Linux (AppImage, deb, rpm)
```
---
@@ -282,7 +339,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
| カテゴリ | テクノロジー |
|--------|------------|
| フレームワーク | Electron 40 |
| フレームワーク | Electron 39 |
| フロントエンド | React 19, TypeScript |
| ビルドツール | Vite 7 |
| ターミナル | xterm.js 5 |
@@ -308,17 +365,6 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくださったすべての方に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="ライセンス"></a>
# ライセンス
@@ -326,19 +372,6 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="star-履歴"></a>
# Star 履歴
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

324
README.md
View File

@@ -5,13 +5,12 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>🔥 AI-Powered SSH Client, SFTP Browser & Terminal Manager 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
</p>
<p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
</p>
<p align="center">
@@ -42,67 +41,25 @@
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
<p align="center">
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
</p>
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
- 🚀 **Multi-host orchestration** — coordinate tasks across multiple servers simultaneously
- 🔥 **Intelligent context awareness** — understands your server environment and provides tailored responses
- 🚀 **One-click complex operations** — set up clusters, deploy services, and more with simple instructions
### 🎬 AI in Action
#### 🔥 Single Host — Intelligent Server Diagnostics
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
---
# Contents <!-- omit in toc -->
- [🔥 Catty Agent — AI Partner](#catty-agent)
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
- [Split Terminals](#split-terminals)
- [Host Management](#host-management)
- [Terminal](#terminal)
- [SFTP](#sftp)
- [Keychain](#keychain)
- [Port Forwarding](#port-forwarding)
- [Cloud Sync](#cloud-sync)
- [Themes & Customization](#themes--customization)
- [Supported Distros](#supported-distros)
- [Getting Started](#getting-started)
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Star History](#star-history)
- [License](#license)
---
@@ -115,128 +72,166 @@ https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
- **Netcatty is** a powerful SFTP client with dual-pane file browser
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
---
<a name="why-netcatty"></a>
# Why Netcatty
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
---
<a name="features"></a>
# Features
### 🗂 Vault
- **Multiple views** — grid / list / tree
- **Fast search** — locate hosts and groups quickly
### 🖥️ Terminal Workspaces
### 🖥 Terminal & Sessions
- **xterm.js-based terminal** with GPU-accelerated rendering
- **Split panes** — horizontal and vertical splits for multi-tasking
- **Session management** — run multiple connections side-by-side
- **Tab management** — multiple sessions with drag-to-reorder
- **Session persistence** — restore sessions on restart
- **Broadcast mode** — type once, send to multiple terminals
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
- **Edit in place** — built-in editor for quick changes
### 🔐 SSH Client
- **SSH2 protocol** with full authentication support
- **Password & key-based authentication**
- **SSH certificates** support
- **Jump hosts / Bastion** — chain through multiple hosts
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
- **Agent forwarding** — including OpenSSH Agent and Pageant
- **Environment variables** — set custom env vars per host
### 🎨 Personalization
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
### 📁 SFTP
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
- **Drag & drop** file transfers
- **Queue management** for batch transfers
- **Progress tracking** with transfer speed
---
<a name="demos"></a>
# Demos
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
### 🔑 Keychain
- **Generate SSH keys** — RSA, ECDSA, ED25519
- **Import existing keys** — PEM, OpenSSH formats
- **SSH certificates** support
- **Identity management** — reusable username + auth combinations
- **Export public keys** to remote hosts
### 🔌 Port Forwarding
- **Local forwarding** — expose remote services locally
- **Remote forwarding** — expose local services remotely
- **Dynamic forwarding** — SOCKS5 proxy
- **Visual tunnel management**
### ☁️ Cloud Sync
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
- **Sync hosts, keys, snippets, and settings**
### 🎨 Themes & Customization
- **Light & Dark mode**
- **Custom accent colors**
- **50+ terminal color schemes**
- **Font customization** — JetBrains Mono, Fira Code, and more
- **i18n support** — English, 简体中文, and more
---
<a name="screenshots"></a>
# Screenshots
<a name="main-window"></a>
## Main Window
<a name="host-management"></a>
## Host Management
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
![Main Window (Dark)](screenshots/main-window-dark.png)
**Dark Mode**
![Main Window (Light)](screenshots/main-window-light.png)
![Dark Mode](screenshots/main-window-dark.png)
<a name="vault-views"></a>
## Vault Views
**Light Mode**
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
![Light Mode](screenshots/main-window-light.png)
![Vault Grid View](screenshots/vault_grid_view.png)
**List View**
![Vault List View](screenshots/vault_list_view.png)
![List View](screenshots/main-window-dark-list.png)
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
<a name="terminal"></a>
## Terminal
![Vault Tree View (Light)](screenshots/treeview-light.png)
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
<a name="split-terminals"></a>
## Split Terminals
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
**Split Windows**
![Split Windows](screenshots/split-window.png)
**Theme Customization**
![Theme Customization](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
![SFTP View](screenshots/sftp.png)
<a name="keychain"></a>
## Keychain
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
| Key Type | Algorithm | Recommended Use |
|----------|-----------|----------------|
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
**Features:**
- 🔑 Generate keys with customizable bit lengths
- 📥 Import PEM/OpenSSH format keys
- 👤 Create reusable identities (username + auth method)
- 📤 One-click export public keys to remote hosts
![Key Manager](screenshots/key-manager.png)
<a name="port-forwarding"></a>
## Port Forwarding
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
| Type | Direction | Use Case | Example |
|------|-----------|----------|--------|
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
![Port Forwarding](screenshots/port-forwadring.png)
<a name="cloud-sync"></a>
## Cloud Sync
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
| Provider | Best For | Setup Complexity |
|----------|----------|------------------|
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
| **Google Drive** | Personal use, large storage | ⭐ Easy |
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
**What syncs:**
- ✅ Hosts & connection settings
- ✅ SSH keys & certificates
- ✅ Identities & credentials
- ✅ Snippets & scripts
- ✅ Custom groups & tags
- ✅ Port forwarding rules
- ✅ Application preferences
![Cloud Sync](screenshots/cloud-sync.png)
<a name="themes--customization"></a>
## Themes & Customization
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
![Themes & i18n](screenshots/app-themes-i18n.png)
---
<a name="supported-distros"></a>
@@ -257,9 +252,10 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
<a name="getting-started"></a>
# Getting Started
@@ -267,15 +263,19 @@ Netcatty automatically detects and displays OS icons for connected hosts:
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
| OS | Support |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
| Platform | Architecture | Status |
|----------|--------------|--------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
| **macOS** | Intel | ✅ Supported |
| **Windows** | x64 | ✅ Supported |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> Or right-click the app → Open → Click "Open" in the dialog.
### Prerequisites
- Node.js 18+ and npm
@@ -339,7 +339,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
| Category | Technology |
|----------|------------|
| Framework | Electron 40 |
| Framework | Electron 39 |
| Frontend | React 19, TypeScript |
| Build Tool | Vite 7 |
| Terminal | xterm.js 5 |
@@ -365,17 +365,6 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
---
<a name="contributors"></a>
# Contributors
Thanks to all the people who contribute!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="license"></a>
# License
@@ -383,19 +372,6 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
---
<a name="star-history"></a>
# Star History
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -5,19 +5,18 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -47,20 +46,20 @@
# 目录 <!-- omit in toc -->
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [界面](#主界面)
- [Vault 视图](#vault-视图)
- [分屏终端](#分屏终端)
- [机管理](#主机管理)
- [终端](#终端)
- [SFTP](#sftp)
- [密钥管理](#密钥管理)
- [端口转发](#端口转发)
- [云同步](#云同步)
- [主题与定制](#主题与定制)
- [支持的发行版](#支持的发行版)
- [快速开始](#快速开始)
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -73,118 +72,172 @@
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口Serial等连接方式视环境而定
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
---
<a name="为什么是-netcatty"></a>
# 为什么是 Netcatty
如果你需要同时维护多台服务器Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
---
<a name="功能特性"></a>
# 功能特性
### 🗂 Vault
- **多种视图** —— 网格 / 列表 / 树形
- **快速搜索** —— 迅速定位主机与分组
### 🖥 终端与会话
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
- **分屏功能** —— 水平和垂直分割,多任务并行
- **标签页管理** —— 多会话支持,拖拽排序
- **会话持久化** —— 重启后恢复会话
- **广播模式** —— 一次输入,发送到多个终端
### 🖥️ 终端工作区
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
### 🔐 SSH 客户端
- **SSH2 协议**,完整的认证支持
- **密码和密钥认证**
- **SSH 证书**支持
- **跳板机 / 堡垒机** —— 多主机链式连接
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
- **环境变量** —— 为每个主机设置自定义环境变量
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
### 📁 SFTP
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
- **拖放传输** 文件
- **队列管理** 批量传输
- **进度跟踪** 显示传输速度
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
- **关键词高亮** —— 自定义终端输出高亮规则
### 🔑 密钥管理
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
- **导入已有密钥** —— PEM、OpenSSH 格式
- **SSH 证书**支持
- **身份管理** —— 可复用的用户名 + 认证方式组合
- **导出公钥**到远程主机
---
### 🔌 端口转发
- **本地转发** —— 将远程服务暴露到本地
- **远程转发** —— 将本地服务暴露到远程
- **动态转发** —— SOCKS5 代理
- **可视化隧道管理**
<a name="演示"></a>
# 演示
### ☁️ 云同步
- **端到端加密同步** —— 数据在离开设备前加密
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
- **同步主机、密钥、代码片段和设置**
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
### 🎨 主题与定制
- **浅色 & 深色模式**
- **自定义强调色**
- **50+ 终端配色方案**
- **字体自定义** —— JetBrains Mono、Fira Code 等
- **多语言支持** —— English、简体中文 等
---
<a name="界面截图"></a>
# 界面截图
<a name="主界面"></a>
## 主界面
<a name="主机管理"></a>
## 主机管理
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机
![主界面(深色)](screenshots/main-window-dark.png)
**深色模式**
![主界面(浅色)](screenshots/main-window-light.png)
![深色模式](screenshots/main-window-dark.png)
<a name="vault-视图"></a>
## Vault 视图
**浅色模式**
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
![浅色模式](screenshots/main-window-light.png)
![Vault 网格视图](screenshots/vault_grid_view.png)
**列表视图**
![Vault 列表视图](screenshots/vault_list_view.png)
![列表视图](screenshots/main-window-dark-list.png)
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
<a name="终端"></a>
## 终端
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
**分屏窗口**
![分屏窗口](screenshots/split-window.png)
**主题定制**
![主题定制](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 视图](screenshots/sftp.png)
<a name="密钥管理"></a>
## 密钥管理
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
| 密钥类型 | 算法 | 推荐用途 |
|---------|------|---------|
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
| **证书** | CA 签名 | 企业环境、短期认证 |
**功能:**
- 🔑 生成可自定义位长的密钥
- 📥 导入 PEM/OpenSSH 格式密钥
- 👤 创建可复用身份(用户名 + 认证方式)
- 📤 一键导出公钥到远程主机
![密钥管理器](screenshots/key-manager.png)
<a name="端口转发"></a>
## 端口转发
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
| 类型 | 方向 | 使用场景 | 示例 |
|-----|-----|---------|-----|
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
![端口转发](screenshots/port-forwadring.png)
<a name="云同步"></a>
## 云同步
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
| 服务商 | 最适合 | 配置复杂度 |
|-------|-------|----------|
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
**同步内容:**
- ✅ 主机与连接设置
- ✅ SSH 密钥与证书
- ✅ 身份与凭证
- ✅ 代码片段与脚本
- ✅ 自定义分组与标签
- ✅ 端口转发规则
- ✅ 应用程序偏好设置
![云同步](screenshots/cloud-sync.png)
<a name="主题与定制"></a>
## 主题与定制
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
![主题与国际化](screenshots/app-themes-i18n.png)
---
<a name="支持的发行版"></a>
# 支持的发行版
Netcatty 自动识别并在主机列表中展示对应的系统图标:
Netcatty 自动检测并显示已连接主机的操作系统图标:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -199,9 +252,10 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
<a name="快速开始"></a>
# 快速开始
@@ -209,15 +263,19 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 操作系统 | 支持情况 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
### 前置条件
- Node.js 18+ 和 npm
@@ -281,7 +339,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
| 分类 | 技术 |
|-----|-----|
| 框架 | Electron 40 |
| 框架 | Electron 39 |
| 前端 | React 19, TypeScript |
| 构建工具 | Vite 7 |
| 终端 | xterm.js 5 |
@@ -307,17 +365,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="贡献者"></a>
# 贡献者
感谢所有参与贡献的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="开源协议"></a>
# 开源协议
@@ -325,19 +372,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="star-历史"></a>
# Star 历史
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
## How Things Talk
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
- UI calls application hooks hooks call domain helpers persistence/config via infrastructure adapters.
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
@@ -44,12 +44,6 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.
## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
---
## Aside Panel Design System
@@ -60,20 +54,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
Import from `./ui/aside-panel`:
```tsx
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
AsidePanelFooter,
AsideActionMenu,
AsideActionMenuItem
AsideActionMenuItem
} from "./ui/aside-panel";
```
### Basic Usage
```tsx
<AsidePanel
open={isOpen}
<AsidePanel
open={isOpen}
onClose={handleClose}
title="Panel Title"
subtitle="Optional subtitle"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,495 +0,0 @@
import type { SyncPayload } from '../domain/sync';
import {
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
*/
function captureCurrentSyncDataVersion(): number | undefined {
try {
const state = getCloudSyncManager().getState();
const v = state.localVersion;
return typeof v === 'number' && v > 0 ? v : undefined;
} catch {
return undefined;
}
}
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
/** Sync-data version at the time the snapshot was taken (the integer
* that the CloudSyncManager increments on each successful cloud sync).
* Undefined when the user had never synced yet, or for legacy backups
* persisted before this field was added. */
syncDataVersion?: number;
/** App version transition fields, only for `app_version_change` records.
* Kept for backward compatibility with already-persisted backups. */
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
}
export interface LocalVaultBackupDetails {
backup: LocalVaultBackupPreview;
payload: SyncPayload;
}
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
return Math.max(
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
);
};
export const getLocalVaultBackupMaxCount = (): number => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
return sanitizeLocalVaultBackupMaxCount(
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
);
};
export const setLocalVaultBackupMaxCount = (value: number): number => {
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
return sanitized;
};
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
const bridge = netcattyBridge.get();
const caps = await bridge?.getVaultBackupCapabilities?.();
// Conservatively treat a missing bridge (non-Electron environments, early
// boot) as unavailable so callers fall back to the locked-down UI path
// instead of assuming capabilities they can't verify.
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
}
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
const bridge = netcattyBridge.get();
const entries = await bridge?.listVaultBackups?.();
return Array.isArray(entries) ? entries : [];
}
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
const bridge = netcattyBridge.get();
if (!bridge?.readVaultBackup) return null;
return bridge.readVaultBackup({ id });
}
export async function openLocalVaultBackupDir(): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.openVaultBackupDir?.();
}
export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
syncDataVersion?: number;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
},
): Promise<LocalVaultBackupPreview | null> {
// Intentional: an empty-vault backup has nothing to restore from, so we
// early-return instead of writing a zero-entry record. Callers that rely
// on a backup (protective-before-restore, version-change on first run)
// must treat `null` as "no safety net this time" and continue — blocking
// the user's flow on a missing backup would be worse than allowing the
// apply to proceed without one.
if (!hasMeaningfulSyncData(payload)) {
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
return null;
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
// Default to the live cloud-sync version so every new backup carries
// it even when the caller didn't pass one explicitly. Bridge sanitizer
// drops invalid values (non-positive / non-finite), so this is safe.
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
// The main-process bridge refuses to write backups when safeStorage is
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
// carries plaintext credentials that must never touch disk unencrypted.
// Callers (startup version-change, protective-before-restore) intentionally
// continue without a backup rather than blocking the user's flow, so we
// log and return null here.
const message = error instanceof Error ? error.message : String(error);
console.warn('[localVaultBackups] Backup skipped:', message);
return null;
}
}
/**
* Thrown when a caller requires a protective backup and the backup
* couldn't be written — safeStorage unavailable, bridge missing,
* main-process rejection, disk error.
*
* Callers should surface this as a user-visible abort rather than
* proceeding with the destructive apply. Separate from "nothing to
* back up" (empty vault) which is returned as `null`.
*/
export class ProtectiveBackupUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProtectiveBackupUnavailableError';
}
}
/**
* Create a protective local backup before a destructive apply (restore
* from backup list, restore from Gist revision, cloud download applied
* over meaningful local state).
*
* Returns `null` when there is nothing meaningful to back up — in that
* case the caller can safely proceed with the apply, because there is
* no local data to lose.
*
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
* meaningful but the backup attempt failed. Callers MUST abort the
* destructive apply in that case and surface the error to the user,
* otherwise we regress the exact safety contract the backup system
* was added to enforce (the `console.error`-and-proceed pattern that
* previously swallowed safeStorage/keychain failures and continued).
*/
export async function createRequiredProtectiveLocalVaultBackup(
payload: SyncPayload,
): Promise<LocalVaultBackupPreview | null> {
if (!hasMeaningfulSyncData(payload)) {
// Nothing to protect — an empty-vault backup would produce a
// useless record, not a safety net.
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
throw new ProtectiveBackupUnavailableError(
'Vault backup bridge is not available in this environment.',
);
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: 'before_restore',
maxCount: getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ProtectiveBackupUnavailableError(message);
}
}
/**
* How long each heartbeat extends the cross-window restore barrier.
* Short enough that an abandoned lock (crashed window, hung task)
* clears itself quickly without user intervention. The heartbeat
* interval below refreshes the deadline as long as the caller's task
* is still running, so large vaults or slow keychain unlocks cannot
* expose a mid-apply window to concurrent auto-sync even when the
* total apply time exceeds this value.
*/
const RESTORE_BARRIER_HOLD_MS = 60_000;
/**
* How often the heartbeat refreshes the barrier. Picked to ensure at
* least two refreshes land before the current deadline would expire,
* so a single missed tick (event-loop stall, GC pause) cannot drop
* the barrier prematurely.
*/
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
/**
* Run `task` while holding a cross-window "restore in progress" barrier.
*
* The barrier is a localStorage key readable by every window of the same
* origin. useAutoSync reads it on each auto-sync and on each data-change
* debounce tick, refusing to push while the deadline is still in the
* future. We write a time-bounded deadline (rather than a boolean) so a
* crashed window can never leave sync permanently wedged.
*
* While the task runs, a heartbeat timer re-writes the deadline so a
* slow apply (large vault, slow keychain) keeps the barrier held rather
* than exposing a post-deadline window to concurrent auto-sync. The
* heartbeat is cleared and the barrier is released in a finally block
* so success, throw, and unexpected early-return all converge on the
* same cleanup.
*/
export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
Date.now() + holdMs,
);
} catch (error) {
// If we can't write the barrier we still proceed — the UI-side
// `isSyncBusy` guard and same-window debounce cancellation are a
// secondary defense. Better to complete the restore than refuse on
// a broken localStorage.
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
}
};
writeDeadline();
const heartbeat = setInterval(
writeDeadline,
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
);
try {
return await task();
} finally {
clearInterval(heartbeat);
try {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
} catch {
/* ignore — the deadline will expire naturally */
}
}
}
/**
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
* distinguish "the last apply completed cleanly" from "the last apply
* crashed mid-way and the local vault is a partial mix of states."
*/
export interface VaultApplyInProgressRecord {
startedAt: number;
protectiveBackupId: string | null;
}
/**
* Returns the persisted apply-in-progress record if a previous apply
* was interrupted before clearing it. Callers (notably auto-sync) use
* this to refuse to push a partial-apply local state over an intact
* cloud copy. See `applyProtectedSyncPayload` for the write side.
*
* `null` here means "no interrupted apply detected" — either nothing
* was ever applied, or the last apply finished cleanly.
*/
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
try {
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
const protectiveBackupId =
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
if (!startedAt) return null;
return { startedAt, protectiveBackupId };
} catch {
return null;
}
}
/**
* Clears the apply-in-progress sentinel. The normal completion path
* inside `applyProtectedSyncPayload` clears it automatically; this
* export exists so the user's explicit recovery action ("I've restored
* from a backup, resume sync") can acknowledge the interrupted state
* from the UI without re-running an apply.
*/
export function clearInterruptedVaultApply(): void {
try {
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
} catch {
/* ignore — next clean apply will overwrite */
}
}
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
try {
localStorageAdapter.writeString(
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
JSON.stringify(record),
);
} catch (error) {
// Sentinel write is best-effort: a failure here means a later crash
// won't be detected, but does NOT compromise the apply itself.
// Log so a systematic storage outage is diagnosable.
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
}
}
/**
* Shared "apply a remote-sourced payload safely" helper.
*
* Holds the cross-window restore barrier, snapshots the pre-apply vault
* into a protective backup, persists an apply-in-progress sentinel, and
* only then runs the supplied `applyPayload` callback. Every destructive
* apply path (startup merge, conflict resolution, empty-vault restore,
* manual Gist-revision restore) must go through this so the protections
* can't drift out of sync between the main window and the settings
* window.
*
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
* writes to several localStorage keys non-atomically (hosts, keys, port-
* forwarding rules, settings). A crash mid-sequence leaves the vault in
* a state that is neither pre-apply nor post-apply, and the next
* auto-sync would otherwise push that partial state over an intact cloud
* copy. The sentinel flags "local may be inconsistent" for the next
* session; `readInterruptedVaultApply` exposes that to callers that
* enforce "don't auto-push a half-applied vault."
*
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
* current vault. Callers pass their own React-closure builder (hosts,
* keys, port-forwarding rules) because the caller owns that state.
*
* `translateProtectiveBackupFailure` converts the
* `ProtectiveBackupUnavailableError` into a user-visible message in the
* caller's locale. It runs only on the thrown-and-caught path.
*/
export function applyProtectedSyncPayload(options: {
buildPreApplyPayload: () => SyncPayload;
applyPayload: () => void | Promise<void>;
translateProtectiveBackupFailure: (message: string) => string;
}): Promise<void> {
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
return withRestoreBarrier(async () => {
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
protectiveBackupId = backup?.id ?? null;
} catch (error) {
// Destructive apply without a working safety net is exactly the
// overwrite-without-recovery regression this module was added to
// prevent. Surface the failure to the caller; every call site
// currently aborts the apply and shows a user-visible error.
if (error instanceof ProtectiveBackupUnavailableError) {
throw new Error(translateProtectiveBackupFailure(error.message));
}
throw error;
}
// Mark the apply as in-progress. If the renderer crashes between
// the first localStorage write inside `applyPayload` and the
// successful completion below, the next session will observe this
// sentinel and refuse to auto-sync the partial state.
writeApplyInProgressSentinel({
startedAt: Date.now(),
protectiveBackupId,
});
// Only clear the sentinel on successful completion. A throw from
// `applyPayload` deliberately leaves the sentinel set: the partial
// write is still on disk, and the next session must observe the
// flag so auto-sync refuses to push the half-applied state.
await applyPayload();
clearInterruptedVaultApply();
});
}
export async function ensureVersionChangeBackup(
payload: SyncPayload,
currentAppVersion: string | null | undefined,
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
const normalizedVersion = currentAppVersion?.trim() || '';
if (!normalizedVersion) {
return { created: false, backup: null };
}
const previousVersion =
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
if (!previousVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
return { created: false, backup: null };
}
if (previousVersion === normalizedVersion) {
return { created: false, backup: null };
}
let backup: LocalVaultBackupPreview | null = null;
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
if (payloadIsMeaningful) {
backup = await createLocalVaultBackup(payload, {
reason: 'app_version_change',
sourceAppVersion: previousVersion,
targetAppVersion: normalizedVersion,
});
}
// Only advance the stored version stamp when we actually wrote a
// backup. Two failure modes we must NOT collapse into "advance":
//
// 1. Meaningful payload + backup failed (transient keychain lock,
// disk error) — leaving the stamp unchanged means the next
// launch retries, instead of turning a transient error into a
// permanent "the version-change backup never happened" hole.
//
// 2. Non-meaningful payload at the moment we checked — on startup
// the async vault rehydrate may not have finished yet, so
// `hasMeaningfulSyncData` can return false transiently even
// though the user has real data. Advancing in that window would
// burn the one-shot upgrade opportunity; holding keeps the
// retry available on the next launch when rehydrate has
// completed (or when the user genuinely starts from empty and
// the next migration-boundary arrives).
//
// Trade-off: a user who truly starts empty and never adds data will
// hit this branch on every launch until they do. That's cheap (a
// single meaningful-data check) and strictly safer than silently
// skipping the first real upgrade backup.
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
if (shouldAdvanceVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
}
return {
created: Boolean(backup),
backup,
};
}

View File

@@ -1,38 +0,0 @@
/**
* Application-layer notification port.
*
* UI layers (e.g. toast) register their implementation via `setNotify`.
* Application code calls `notify.*` without importing any UI module.
*/
export interface NotifyOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
interface Notify {
success: NotifyFn;
error: NotifyFn;
warning: NotifyFn;
info: NotifyFn;
}
const noop: NotifyFn = () => {};
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
/** Called once by the UI layer to wire up the real implementation. */
export function setNotify(impl: Notify): void {
_impl = impl;
}
export const notify: Notify = {
success: (...args) => _impl.success(...args),
error: (...args) => _impl.error(...args),
warning: (...args) => _impl.warning(...args),
info: (...args) => _impl.info(...args),
};

View File

@@ -6,7 +6,6 @@ type Listener = () => void;
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
getActiveTabId = () => this.activeTabId;
@@ -14,10 +13,7 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
});
}

View File

@@ -1,349 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
selectDraftForAgentSwitch,
setDraftView,
setSessionView,
updateDraftForScope,
} from "./aiDraftState.ts";
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
assert.equal(draft.agentId, "agent-alpha");
assert.equal(draft.text, "");
assert.deepEqual(draft.attachments, []);
assert.deepEqual(draft.selectedUserSkillSlugs, []);
assert.equal(typeof draft.updatedAt, "number");
});
test("resolvePanelView defaults to draft when no explicit view exists", () => {
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
});
test("setDraftView records draft mode", () => {
assert.deepEqual(setDraftView({}, "terminal:123"), {
"terminal:123": { mode: "draft" },
});
});
test("activateDraftView clears the terminal scope's active session owner", () => {
const activeSessionIdMap = {
"terminal:123": "session-123",
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "session", sessionId: "session-123" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.deepEqual(next.activeSessionIdMap, {
"workspace:abc": "session-workspace",
});
assert.deepEqual(next.panelViewByScope, {
"terminal:123": { mode: "draft" },
"workspace:abc": panelViewByScope["workspace:abc"],
});
});
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
const activeSessionIdMap = {
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "draft" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("setSessionView records target session id", () => {
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
"workspace:abc": { mode: "session", sessionId: "session-123" },
});
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-123" },
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
assert.deepEqual(next.draftsByScope, {
"workspace:2": draftsByScope["workspace:2"],
});
assert.deepEqual(next.panelViewByScope, {
"workspace:2": panelViewByScope["workspace:2"],
});
});
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = updateDraftForScope(
draftsByScope,
"terminal:1",
"agent-alpha",
(draft) => ({
...draft,
text: "hello world",
}),
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "hello world");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-alpha",
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-beta",
);
assert.equal(next, draftsByScope);
});
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "keep me only if I was already drafting",
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "keep me only if I was already drafting");
assert.deepEqual(next.attachments, currentDraft.attachments);
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
const currentDraft = createEmptyDraft("agent-alpha");
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "");
assert.deepEqual(next.attachments, []);
assert.deepEqual(next.selectedUserSkillSlugs, []);
});
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "unfinished prompt",
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "unfinished prompt");
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("draft mutation version increments on every mutation for the same scope", () => {
const scopeKey = "terminal:1";
const initialVersion = getDraftMutationVersionState({}, scopeKey);
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
assert.equal(initialVersion, 0);
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
});
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
const scopeKey = "terminal:1";
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
assert.equal(initialGeneration, 0);
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-1" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
const activeSessionIdMap = {
"terminal:closed": "session-closed",
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
const activeSessionIdMap = {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});

View File

@@ -1,282 +0,0 @@
import type {
AIDraft,
AIPanelView,
} from '../../infrastructure/ai/types';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
type DraftMutationVersionByScope = Record<string, number>;
type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
agentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
};
}
export function getDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): number {
return versionsByScope[scopeKey] ?? 0;
}
export function bumpDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): DraftMutationVersionByScope {
return {
...versionsByScope,
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
};
}
export function getDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): number {
return generationsByScope[scopeKey] ?? 0;
}
export function bumpDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): DraftUploadGenerationByScope {
return {
...generationsByScope,
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): AIPanelView {
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
}
export function setDraftView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): PanelViewByScope {
const currentPanelView = panelViewByScope[scopeKey];
if (currentPanelView?.mode === 'draft') {
return panelViewByScope;
}
return {
...panelViewByScope,
[scopeKey]: DEFAULT_PANEL_VIEW,
};
}
export function activateDraftView(
activeSessionIdMap: ActiveSessionIdMap,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
activeSessionIdMap: ActiveSessionIdMap;
panelViewByScope: PanelViewByScope;
} {
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
if (!hasActiveSession) {
return {
activeSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
const nextActiveSessionIdMap = { ...activeSessionIdMap };
delete nextActiveSessionIdMap[scopeKey];
return {
activeSessionIdMap: nextActiveSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
export function setSessionView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
sessionId: string,
): PanelViewByScope {
return {
...panelViewByScope,
[scopeKey]: { mode: 'session', sessionId },
};
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
return {
...draftsByScope,
[scopeKey]: nextDraft,
};
}
export function ensureDraftForScopeState(
draftsByScope: DraftsByScope,
scopeKey: string,
agentId: string,
): DraftsByScope {
if (draftsByScope[scopeKey]) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: createEmptyDraft(agentId),
};
}
export function selectDraftForAgentSwitch(
currentDraft: AIDraft | null | undefined,
agentId: string,
startFresh: boolean,
): AIDraft {
const hasPendingDraftContent = Boolean(
currentDraft
&& (
currentDraft.text.length > 0
|| currentDraft.attachments.length > 0
|| currentDraft.selectedUserSkillSlugs.length > 0
),
);
if (startFresh && !hasPendingDraftContent) {
return createEmptyDraft(agentId);
}
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
return {
...baseDraft,
agentId,
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
if (!hasDraft && !hasPanelView) {
return {
draftsByScope,
panelViewByScope,
};
}
return {
draftsByScope: hasDraft
? (() => {
const nextDrafts = { ...draftsByScope };
delete nextDrafts[scopeKey];
return nextDrafts;
})()
: draftsByScope,
panelViewByScope: hasPanelView
? (() => {
const nextPanelViews = { ...panelViewByScope };
delete nextPanelViews[scopeKey];
return nextPanelViews;
})()
: panelViewByScope,
};
}
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
if (!scopeKey.startsWith('terminal:')) return false;
const targetId = scopeKey.slice('terminal:'.length);
if (!targetId) return false;
return !activeTerminalTargetIds.has(targetId);
}
export function pruneTerminalScopeState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneTerminalTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextTerminalScopeState = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
activeTerminalTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextTerminalScopeState.draftsByScope,
panelViewByScope: nextTerminalScopeState.panelViewByScope,
};
}

View File

@@ -1,160 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
id,
title: id,
agentId: "catty",
scope,
messages: [],
externalSessionId,
createdAt: 1,
updatedAt: 1,
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}, "ext-1"),
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-2"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-3"),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-restorable",
"terminal-local",
"workspace-closed",
]);
assert.deepEqual(next.sessions, [
sessions[0],
sessions[3],
]);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses ACP continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",
hostIds: ["host-1"],
}, "ext-resumed");
const trulyOrphaned = createSession("terminal-stale", {
type: "terminal",
targetId: "terminal-closed-C",
hostIds: ["host-2"],
}, "ext-stale");
const sessions = [resumedElsewhere, trulyOrphaned];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["terminal-open-B"]),
new Set(["terminal-restorable"]),
);
// Only the one not being displayed anywhere should show up as orphaned.
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
// The resumed session must retain its externalSessionId.
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
});

View File

@@ -1,145 +0,0 @@
import type {
AIDraft,
AIPanelView,
AISession,
} from "../../infrastructure/ai/types";
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
activeTargetIds: Set<string>,
): boolean {
const separatorIndex = scopeKey.indexOf(":");
if (separatorIndex === -1) return false;
const scopeType = scopeKey.slice(0, separatorIndex);
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return false;
return !activeTargetIds.has(targetId);
}
export function pruneInactiveScopedState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
function isRestorableTerminalSession(session: AISession): boolean {
return session.scope.type === "terminal"
&& !!session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
/**
* Session ids currently displayed by any live scope. A session whose
* `scope.targetId` is inactive but whose id is still in use somewhere
* (e.g. resumed from history into a different terminal) must not be
* treated as orphaned — deleting it outright would break the chat the
* user is actively continuing.
*/
activeSessionIds: Set<string> = new Set(),
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.filter((session) => !activeSessionIds.has(session.id))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
return {
sessions,
orphanedSessionIds,
};
}
const orphanedSessionIdSet = new Set(orphanedSessionIds);
let sessionsChanged = false;
const nextSessions = sessions.flatMap((session) => {
if (!orphanedSessionIdSet.has(session.id)) {
return [session];
}
if (!isRestorableTerminalSession(session)) {
sessionsChanged = true;
return [];
}
return [session];
});
return {
sessions: sessionsChanged ? nextSessions : sessions,
orphanedSessionIds,
};
}

View File

@@ -1,186 +0,0 @@
import { useSyncExternalStore, useCallback } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
// Access the Electron bridge for cross-window IPC
type NetcattyBridge = {
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
};
const getBridge = (): NetcattyBridge | undefined =>
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
/**
* Custom Theme Store - manages user-created terminal themes
* Uses useSyncExternalStore pattern (same as fontStore)
* Persists to localStorage + cross-window IPC sync
*/
type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
constructor() {
this.loadFromStorage();
this.setupCrossWindowSync();
}
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
}
} catch {
// ignore corrupt data
}
this.notify();
};
private saveToStorage = () => {
try {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
} catch {
// storage full or unavailable
}
};
private notify = () => {
this.cachedAllThemes = null; // invalidate cache on any mutation
this.listeners.forEach(listener => listener());
};
/** Broadcast change to other Electron windows via IPC */
private broadcastChange = () => {
try {
getBridge()?.notifySettingsChanged?.({
key: STORAGE_KEY_CUSTOM_THEMES,
value: this.themes,
});
} catch {
// not in Electron or bridge unavailable
}
};
/** Listen for changes from other windows and reload */
private setupCrossWindowSync = () => {
try {
getBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
}
});
} catch {
// not in Electron or bridge unavailable
}
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
// ---- Getters (stable references for useSyncExternalStore) ----
getCustomThemes = (): TerminalTheme[] => this.themes;
/** Returns all themes: built-in + custom (cached for snapshot stability) */
getAllThemes = (): TerminalTheme[] => {
if (!this.cachedAllThemes) {
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
}
return this.cachedAllThemes;
};
/** Find a theme by ID across both built-in and custom */
getThemeById = (id: string): TerminalTheme | undefined => {
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
};
// ---- Mutations ----
addTheme = (theme: TerminalTheme) => {
this.themes = [...this.themes, { ...theme, isCustom: true }];
this.saveToStorage();
this.notify();
this.broadcastChange();
};
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
this.themes = this.themes.map(t =>
t.id === id ? { ...t, ...updates, isCustom: true } : t
);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
deleteTheme = (id: string) => {
this.themes = this.themes.filter(t => t.id !== id);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getCustomThemes
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
customThemeStore.addTheme(theme);
}, []);
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
customThemeStore.updateTheme(id, updates);
}, []);
const deleteTheme = useCallback((id: string) => {
customThemeStore.deleteTheme(id);
}, []);
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};

View File

@@ -68,14 +68,8 @@ class FontStore {
// Add default fonts first
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
// Build a set of built-in font family names for dedup (case-insensitive)
const builtinFamilyNames = new Set(
TERMINAL_FONTS.map(f => f.name.toLowerCase())
);
// Add local fonts, skipping those already covered by built-in fonts
// Add local fonts with a distinct ID namespace to avoid collisions
localFonts.forEach(font => {
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
fontMap.set(localId, { ...font, id: localId });
});

View File

@@ -1,110 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("vault/sftp tab → noop", () => {
const r = resolveCloseIntent({
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,43 +0,0 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}
if (!workspace) {
// e.g. 'vault', 'sftp', or any non-closable pinned tab
return { kind: 'noop' };
}
const focusedSessionId = workspace.focusedSessionId;
if (focusedSessionId && focusIsInsideTerminal) {
return { kind: 'closeTerminal', sessionId: focusedSessionId };
}
return { kind: 'closeWorkspace', workspaceId: workspace.id };
}

View File

@@ -1,46 +0,0 @@
import { TerminalSession } from '../../types';
type SessionActivityMap = Record<string, boolean>;
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
return new Set(sessions.map((session) => session.id));
};
export const shouldMarkSessionActivity = (
activeTabId: string | null,
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
): boolean => {
return activeTabId !== session.id && activeTabId !== session.workspaceId;
};
export const getSessionActivityIdsToClear = (
activeTabId: string | null,
sessions: TerminalSession[],
): string[] => {
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
return [];
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) {
return [activeSession.id];
}
return sessions
.filter((session) => session.workspaceId === activeTabId)
.map((session) => session.id);
};
export const buildWorkspaceActivityMap = (
sessions: TerminalSession[],
sessionActivityMap: SessionActivityMap,
): Map<string, boolean> => {
const workspaceActivityMap = new Map<string, boolean>();
for (const session of sessions) {
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
workspaceActivityMap.set(session.workspaceId, true);
}
return workspaceActivityMap;
};

View File

@@ -1,78 +0,0 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class SessionActivityStore {
private snapshot: Record<string, boolean> = {};
private listeners = new Set<Listener>();
getSnapshot = () => this.snapshot;
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private emit() {
this.listeners.forEach((listener) => listener());
}
setTabActive = (tabId: string, hasActivity: boolean) => {
const alreadyActive = !!this.snapshot[tabId];
if (alreadyActive === hasActivity) return;
if (hasActivity) {
this.snapshot = { ...this.snapshot, [tabId]: true };
} else {
const { [tabId]: _removed, ...rest } = this.snapshot;
this.snapshot = rest;
}
this.emit();
};
clearTab = (tabId: string) => {
this.setTabActive(tabId, false);
};
clearTabs = (tabIds: Iterable<string>) => {
let changed = false;
const next = { ...this.snapshot };
for (const tabId of tabIds) {
if (!next[tabId]) continue;
delete next[tabId];
changed = true;
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
prune = (validTabIds: Set<string>) => {
let changed = false;
const next: Record<string, boolean> = {};
for (const tabId of Object.keys(this.snapshot)) {
if (validTabIds.has(tabId)) {
next[tabId] = true;
} else {
changed = true;
}
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
}
export const sessionActivityStore = new SessionActivityStore();
export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -1,19 +0,0 @@
export const isSessionError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("session lost") ||
msg.includes("channel not ready") ||
msg.includes("readdir is not a function") ||
msg.includes("channel closed") ||
msg.includes("connection closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("not connected") ||
msg.includes("client disconnected") ||
msg.includes("timed out")
);
};

View File

@@ -1,454 +0,0 @@
import { SftpFileEntry } from "../../../domain/models";
import { formatDate } from "./utils";
// Mock local file data for development (when backend is not available)
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
const mockData: Record<string, SftpFileEntry[]> = {
// Unix-style paths
"/": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Applications",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "System",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Shared",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
// Windows-style paths (normalized to forward slashes for matching)
"C:": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Program Files",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Windows",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Public",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Default",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
],
"C:/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
"C:/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"C:/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
};
return mockData[normPath] || [];
}

View File

@@ -1,52 +0,0 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
updatedAt: number;
}
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
/**
* Build a cache key that includes connection details so that the same host ID
* with different session-time overrides (port, protocol) uses separate entries.
*/
export const buildCacheKey = (
hostId: string,
hostname?: string,
port?: number,
protocol?: string,
sftpSudo?: boolean,
username?: string,
): string => {
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
};
export const getSharedRemoteHostCache = (
cacheKey: string,
): SharedRemoteHostCacheEntry | null => {
const entry = sharedRemoteHostCache.get(cacheKey);
if (!entry) return null;
if (Date.now() - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
sharedRemoteHostCache.delete(cacheKey);
return null;
}
return entry;
};
export const setSharedRemoteHostCache = (
cacheKey: string,
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
): void => {
sharedRemoteHostCache.set(cacheKey, {
...entry,
updatedAt: Date.now(),
});
};

View File

@@ -1,67 +0,0 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
connection: SftpConnection | null;
files: SftpFileEntry[];
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
transferMutationToken: number;
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
activeTabId: string | null;
}
// Constants for empty placeholder pane IDs
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (
id?: string,
showHiddenFiles = false,
): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
transferMutationToken: 0,
});
// File watch event types
export interface FileWatchSyncedEvent {
watchId: string;
localPath: string;
remotePath: string;
bytesWritten: number;
}
export interface FileWatchErrorEvent {
watchId: string;
localPath: string;
remotePath: string;
error: string;
}
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
}

View File

@@ -1,580 +0,0 @@
import React, { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
rightTabs: { tabs: SftpPane[] };
leftPane: SftpPane;
rightPane: SftpPane;
setLeftTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
setRightTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (prev: SftpPane) => SftpPane) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
}
export const useSftpConnections = ({
hosts,
keys,
identities,
leftTabsRef,
rightTabsRef,
leftTabs,
rightTabs: _rightTabs,
leftPane,
rightPane,
setLeftTabs,
setRightTabs,
getActivePane,
updateTab,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId || options?.forceNewTab) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
} else {
activeTabId = sideTabs.activeTabId;
}
if (!activeTabId) return;
const isReconnectAttempt = reconnectingRef.current[side];
// Notify caller of the tab ID synchronously, before any async work.
// This allows callers to map metadata (e.g. connection keys) to the tab
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = `${side}-${Date.now()}`;
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
connectionCacheKeyMapRef.current.set(
connectionId,
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
);
}
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
// This ensures proper auto-detection works and respects host-level encoding settings
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
// When forceNewTab is set, we're preserving the old tab for instant switching —
// don't close its SFTP session or clear its cache.
if (!options?.forceNewTab) {
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
// Delete the mapping BEFORE the async closeSftp call to prevent
// concurrent code from using a stale sftpId that the backend may
// have already removed during the await.
sftpSessionsRef.current.delete(currentPane.connection.id);
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
}
}
}
if (host === "local") {
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) {
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
homeDir,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
loading: true,
reconnecting: false,
error: null,
connectionLogs: [],
filenameEncoding, // Reset encoding for new connection
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
files,
timestamp: Date.now(),
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: cachedStartPath,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
// Always show loading while connecting — even with cached files.
// The cached file list is shown as a preview, but the pane stays
// non-interactive until the SFTP session is actually established.
loading: true,
reconnecting: prev.reconnecting,
error: null,
connectionLogs: [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
if (sid !== sftpSessionId) return;
let logLine: string;
switch (status) {
case 'connecting':
logLine = `Connecting to ${label}...`;
break;
case 'authenticating':
logLine = `${label} - Key exchange complete`;
break;
case 'auth-attempt':
if (detail?.endsWith('rejected')) {
logLine = `${label} - ✗ ${detail}`;
} else if (detail === 'all methods exhausted') {
logLine = `${label} - ✗ All authentication methods exhausted`;
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
logLine = `${label} - ${detail}`;
} else {
logLine = `${label} - Trying ${detail}...`;
}
break;
case 'connected':
logLine = `${label} - Connected`;
break;
case 'error':
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
break;
default:
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
});
}
try {
const credentials = getHostCredentials(host);
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("authentication") ||
msg.includes("auth") ||
msg.includes("password") ||
msg.includes("permission denied")
);
};
const hasKey = !!credentials.privateKey;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
}
}
}
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
if (sharedHostCache && provisionalCacheKey) {
dirCacheRef.current.set(provisionalCacheKey, {
files: sharedHostCache.files,
timestamp: Date.now(),
});
}
let files: SftpFileEntry[] = [];
try {
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
} catch {
// Cached path may be stale (deleted, permissions changed).
// Remove the provisional cache entry so phantom files don't resurface.
if (provisionalCacheKey) {
dirCacheRef.current.delete(provisionalCacheKey);
}
// Fall back to homeDir, then "/", chaining attempts.
let fallbackSucceeded = false;
if (sharedHostCache && startPath !== homeDir) {
try {
startPath = homeDir;
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// homeDir also failed, try root
}
}
if (!fallbackSucceeded && startPath !== "/") {
try {
startPath = "/";
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// root also failed
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
setSharedRemoteHostCache(hostCacheKey, {
path: startPath,
homeDir,
files,
filenameEncoding,
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir,
}
: null,
files,
loading: false,
reconnecting: false,
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
} finally {
unsubSftpProgress?.();
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
getHostCredentials,
getActivePane,
updateTab,
clearCacheForConnection,
createEmptyPane,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
],
);
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (
autoConnectLocalOnMount &&
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
const timer = window.setTimeout(() => {
initialConnectDoneRef.current = true;
connect("left", "local");
}, 0);
return () => window.clearTimeout(timer);
}
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const reconnectTimers: number[] = [];
const scheduleReconnect = (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (!lastHost || !reconnectingRef.current[side]) return;
const timer = window.setTimeout(() => {
if (!reconnectingRef.current[side]) return;
void connect(side, lastHost);
}, 1000);
reconnectTimers.push(timer);
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
scheduleReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
scheduleReconnect("right");
}
return () => {
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
};
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
const disconnect = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
if (!pane || !activeTabId) return;
navSeqRef.current[side] += 1;
if (pane.connection) {
clearCacheForConnection(pane.connection.id);
}
reconnectingRef.current[side] = false;
lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],
);
return {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -1,64 +0,0 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize, formatDate } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
return getMockLocalFiles(path);
}
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
};
});
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
if (!rawFiles) return [];
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[],
);
return {
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -1,703 +0,0 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
// Re-export UploadResult for external usage
export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const {
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload,
} = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const readBinaryFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
return await bridge.readLocalFile(filePath);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) {
throw new Error("Binary file reading not supported");
}
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const writeTextFile = useCallback(
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return;
}
throw new Error("Local file writing not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
let localTempPath: string;
let wasCancelled = false;
let externalTransferId: string | undefined;
const isLocalTempDownloadCancelled = () =>
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
const cleanupTempDownload = async (filePath: string) => {
if (!bridge.deleteTempFile) return;
try {
await bridge.deleteTempFile(filePath);
} catch (err) {
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
}
};
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
addExternalUpload({
id: externalTransferId,
fileName,
sourcePath: remotePath,
targetPath: "(temp)",
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
retryable: false,
});
try {
const result = await bridge.downloadSftpToTempWithProgress(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
externalTransferId,
(transferred, total, speed) => {
updateExternalUpload(externalTransferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
undefined,
(error) => {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
},
() => {
updateExternalUpload(externalTransferId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
},
);
wasCancelled = result.cancelled;
localTempPath = result.localPath;
} catch (err) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
throw err;
}
if (wasCancelled) {
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
updateExternalUpload(externalTransferId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
} else {
localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
}
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
}
throw err;
}
let watchId: string | undefined;
if (options?.enableWatch && bridge.startFileWatch) {
try {
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
watchId = result.watchId;
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
if (addExternalUpload) {
const scanningTask: TransferTask = {
id: taskId,
fileName: "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
};
addExternalUpload(scanningTask);
}
},
onScanningEnd: (taskId: string) => {
if (dismissExternalUpload) {
dismissExternalUpload(taskId);
}
},
onTaskCreated: (task: UploadTaskInfo) => {
if (addExternalUpload) {
const transferTask: TransferTask = {
id: task.id,
fileName: task.displayName,
sourcePath: "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
};
addExternalUpload(transferTask);
}
},
onTaskProgress: (taskId: string, progress) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
transferredBytes: progress.transferred,
speed: progress.speed,
});
}
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
});
}
},
onTaskFailed: (taskId: string, error: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
}
},
onTaskCancelled: (taskId: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
}
},
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
// NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError)
writeSftpBinaryWithProgress: bridge?.writeSftpBinaryWithProgress
? async (sftpId, path, data, taskId, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.writeSftpBinaryWithProgress) return undefined;
// Pass undefined for encoding to use session default, and forward callbacks
return b.writeSftpBinaryWithProgress(
sftpId,
path,
data,
taskId,
undefined, // encoding - use session default
onProgress,
onComplete,
onError
);
}
: undefined,
cancelSftpUpload: bridge?.cancelSftpUpload,
// Stream transfer for large files (avoids loading into memory)
startStreamTransfer: bridge?.startStreamTransfer
? async (options, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.startStreamTransfer) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
try {
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
return result;
} catch (error) {
return {
transferId: options.transferId,
error: error instanceof Error ? error.message : String(error),
};
}
}
: undefined,
cancelTransfer: bridge?.cancelTransfer,
};
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
// Invalidate cache for the upload target so returning to that path
// triggers a fresh listing.
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
useCompressedUpload,
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string },
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
// Capture the pane ID now so we can refresh the correct tab after
// upload, even if focus switches during the transfer.
const uploadPaneId = pane.id;
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const directUploadBridge: UploadBridge = {
...createUploadBridge,
};
try {
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: directUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller,
);
// Refresh the specific tab that initiated the upload (not whichever
// tab is active now — focus may have switched during the transfer).
// Also invalidate the upload target's cache entry so returning to
// that path triggers a fresh listing.
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
}
}, []);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return null;
}
return await bridge.selectApplication();
},
[],
);
return {
readTextFile,
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
};
};

View File

@@ -1,27 +0,0 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
});
return () => {
try {
unsubscribeSynced?.();
unsubscribeError?.();
} catch {
// ignore cleanup errors
}
};
}, [options]);
};

View File

@@ -1,102 +0,0 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
}
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
[hosts, identities, keys],
);

View File

@@ -1,964 +0,0 @@
import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
dirCacheTtlMs: number;
}
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
clearSelection: (side: "left" | "right") => void;
selectAll: (side: "left" | "right") => void;
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
export const useSftpPaneActions = ({
hosts,
getActivePane,
updateTab,
updateActiveTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
}, []);
const isSamePath = useCallback((a: string, b: string): boolean => {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}, [normalizePathForCompare]);
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizePathForCompare(candidate);
const normalizedParent = normalizePathForCompare(parent);
if (normalizedCandidate === normalizedParent) return false;
if (/^[a-z]:\\$/.test(normalizedParent)) {
return normalizedCandidate.startsWith(normalizedParent);
}
if (normalizedParent === "/") {
return normalizedCandidate.startsWith("/");
}
const separator = normalizedParent.includes("\\") ? "\\" : "/";
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
}, [normalizePathForCompare]);
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
// Prefer the per-connection cache key — it's set at connect time and
// correctly identifies the endpoint even when multiple tabs share the
// same hostId with different session-time overrides.
if (connectionId) {
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
if (perConnKey) return perConnKey;
}
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
const connHost = lastConnectedHostRef.current[side];
if (connHost && connHost !== "local" && connHost.id === hostId) {
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
}
// Fall back to vault host
const host = hostsRef.current.find(h => h.id === hostId);
if (host) {
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
}
return hostId;
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
// Track the last confirmed (successfully loaded) state per tab, so that
// restore-on-error/supersede always reverts to a known-good state rather
// than an intermediate optimistic state from another in-flight navigation.
// Includes connectionId so stale entries from a previous host are ignored.
const lastConfirmedRef = useRef(
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
);
const navigateTo = useCallback(
async (
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
// This allows refreshing a background tab (e.g. after a transfer completes
// while focus has switched to another host).
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (!pane?.connection || !targetTabId) {
return;
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
if (
cached &&
Date.now() - cached.timestamp < dirCacheTtlMs &&
cached.files
) {
tabNavSeqRef.current.set(targetTabId, requestId);
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files: cached.files,
loading: false,
error: null,
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
// shared cache is a best-effort optimization and hostId uniquely
// identifies the connection in the common case. Session-time
// overrides create separate connections with distinct cache keys
// at the connect() layer.
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files: cached.files,
filenameEncoding: pane.filenameEncoding,
});
}
return;
}
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(targetTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: EMPTY_SET,
loading: true,
error: null,
}));
try {
let files: SftpFileEntry[];
if (pane.connection.isLocal) {
files = await listLocalFiles(path);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
clearCacheForConnection(pane.connection.id);
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, new Error("SFTP session lost"));
}
return;
}
try {
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, err as Error);
}
return;
}
throw err as Error;
}
}
if (navSeqRef.current[side] !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
// This tab also has a newer navigation — drop completely.
return;
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files,
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files,
loading: false,
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files,
filenameEncoding: pane.filenameEncoding,
});
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return;
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
}
},
[
getActivePane,
getActivePaneCacheKey,
updateTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
makeCacheKey,
dirCacheTtlMs,
listLocalFiles,
listRemoteFiles,
sftpSessionsRef,
clearCacheForConnection,
handleSessionError,
isSessionError,
],
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (pane?.connection) {
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
if (!hasRemoteSession) {
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.reconnecting.title",
}));
} else if (!lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
error: "sftp.error.connectionLostManual",
}));
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
// when they switch back to that tab.
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.reconnecting.title",
}));
} else if (!lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
error: "sftp.error.connectionLostManual",
}));
}
}
},
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
);
const navigateUp = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
await navigateTo(side, parentPath);
}
},
[getActivePane, navigateTo],
);
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
const pane = getActivePane(side);
if (!pane?.connection) {
return;
}
if (entry.name === "..") {
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
await navigateTo(side, parentPath);
}
return;
}
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
await navigateTo(side, newPath);
}
},
[getActivePane, navigateTo],
);
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
newSelection.add(name);
}
}
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
}, [updateActiveTab]);
const selectAll = useCallback(
(side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane) return;
updateActiveTab(side, (prev) => ({
...prev,
selectedFiles: new Set(
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
),
}));
},
[getActivePane, updateActiveTab],
);
const setFilter = useCallback((side: "left" | "right", filter: string) => {
updateActiveTab(side, (prev) => ({ ...prev, filter }));
}, [updateActiveTab]);
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
const term = pane.filter.trim().toLowerCase();
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, []);
const createDirectoryAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createDirectoryAtPath(side, pane.connection.currentPath, name);
},
[createDirectoryAtPath, getActivePane],
);
const createFileAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeLocalFile(fullPath, emptyBuffer);
} else {
throw new Error("Local file writing not supported");
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const bridge = netcattyBridge.get();
if (bridge?.writeSftpBinary) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
} else if (bridge?.writeSftp) {
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
} else {
throw new Error("No write method available");
}
}
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createFileAtPath(side, pane.connection.currentPath, name);
},
[createFileAtPath, getActivePane],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) {
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const deleteFilesAtPath = useCallback(
async (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
if (!pane?.connection) {
throw new Error("Source pane is no longer available");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Netcatty bridge not available");
}
try {
for (const name of fileNames) {
const fullPath = joinPath(path, name);
if (pane.connection.isLocal) {
if (!bridge.deleteLocalFile) {
throw new Error("Local delete unavailable");
}
await bridge.deleteLocalFile(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
handleSessionError(side, error);
throw error;
}
if (!bridge.deleteSftp) {
throw new Error("SFTP delete unavailable");
}
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
await refresh(side);
} else {
updateTab(side, pane.id, (prev) => {
if (!prev.connection || prev.connection.id !== connectionId) return prev;
if (prev.connection.currentPath !== path) return prev;
const removeSet = new Set(fileNames);
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
const nextSelection = new Set(prev.selectedFiles);
for (const name of fileNames) {
nextSelection.delete(name);
}
return {
...prev,
files: filteredFiles,
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
throw err;
}
throw err;
}
},
[
clearCacheForConnection,
handleSessionError,
isSessionError,
leftTabsRef,
refresh,
rightTabsRef,
sftpSessionsRef,
updateTab,
],
);
const renameFile = useCallback(
async (side: "left" | "right", oldName: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
// Rename using a full source path (for tree view where entryPath is already absolute).
// newName is still a basename; the new path is built as joinPath(parent, newName).
const renameFileAtPath = useCallback(
async (side: "left" | "right", oldPath: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const parentPath = getParentPath(oldPath);
const newPath = joinPath(parentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
}
if (pane.connection.currentPath === parentPath) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const moveEntriesToPath = useCallback(
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
const pane = getActivePane(side);
if (!pane?.connection || sourcePaths.length === 0) return;
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
const filteredSources = uniqueSources
.sort((a, b) => a.length - b.length)
.filter((path, index, arr) =>
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
);
const movableSources = filteredSources.filter((sourcePath) => {
if (isSamePath(sourcePath, targetPath)) return false;
if (isDescendantPath(targetPath, sourcePath)) return false;
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
return !isSamePath(destinationPath, sourcePath);
});
if (movableSources.length === 0) return;
const sourceParentNames = new Map<string, string[]>();
for (const sourcePath of movableSources) {
const parentPath = getParentPath(sourcePath);
const names = sourceParentNames.get(parentPath) ?? [];
names.push(getFileName(sourcePath));
sourceParentNames.set(parentPath, names);
}
try {
if (pane.connection.isLocal) {
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
if (!renameLocalFile) {
throw new Error("Local rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameLocalFile(sourcePath, destinationPath);
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const renameSftp = netcattyBridge.get()?.renameSftp;
if (!renameSftp) {
throw new Error("SFTP rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
const currentPath = pane.connection.currentPath;
const sourceParents = Array.from(sourceParentNames.keys());
const currentPathAffected =
sourceParents.some((path) => isSamePath(path, currentPath)) ||
isSamePath(targetPath, currentPath);
if (currentPathAffected) {
await refresh(side);
} else {
updateActiveTab(side, (prev) => {
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
return prev;
}
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
return prev;
}
const removeSet = new Set(namesInCurrentPath);
const nextSelection = new Set(prev.selectedFiles);
for (const name of removeSet) {
nextSelection.delete(name);
}
return {
...prev,
files: prev.files.filter((file) => !removeSet.has(file.name)),
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
filePath: string,
mode: string,
) => {
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) {
logger.warn("Cannot change permissions on local files");
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
try {
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
logger.error("Failed to change permissions:", err);
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
return {
navigateTo,
refresh,
navigateUp,
openEntry,
toggleSelection,
rangeSelect,
clearSelection,
selectAll,
setFilter,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
};
};

View File

@@ -1,19 +0,0 @@
import { useEffect } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
useEffect(() => {
const sessionsRef = sftpSessionsRef.current;
return () => {
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
};
}, [sftpSessionsRef]);
};

View File

@@ -1,78 +0,0 @@
import { useCallback } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
interface UseSftpSessionErrorsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
updateActiveTab: (
side: "left" | "right",
updater: (prev: SftpPane) => SftpPane,
) => void;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
clearCacheForConnection: (connectionId: string) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
}
export const useSftpSessionErrors = ({
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
}: UseSftpSessionErrorsParams) =>
useCallback(
(side: "left" | "right", _error: Error) => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!pane || !sideTabs.activeTabId) return;
if (pane.connection) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
}
navSeqRef.current[side] += 1;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.error.connectionLostReconnecting",
}));
} else {
updateActiveTab(side, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "sftp.error.sessionLost",
selectedFiles: new Set(),
filter: "",
}));
}
},
[
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
],
);

View File

@@ -1,299 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
import { logger } from "../../../lib/logger";
interface SftpTabsState {
leftTabs: SftpSideTabs;
rightTabs: SftpSideTabs;
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
rightTabsRef: React.MutableRefObject<SftpSideTabs>;
setLeftTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
setRightTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
leftPane: SftpPane;
rightPane: SftpPane;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
selectTab: (side: "left" | "right", tabId: string) => void;
reorderTabs: (
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
moveTabToOtherSide: (fromSide: "left" | "right", tabId: string) => void;
getTabsInfo: (side: "left" | "right") => Array<{
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
}>;
getActiveTabId: (side: "left" | "right") => string | null;
}
const EMPTY_SELECTION = new Set<string>();
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
defaultShowHiddenFiles?: boolean;
} = {}): SftpTabsState => {
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const [rightTabs, setRightTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const leftTabsRef = useRef(leftTabs);
const rightTabsRef = useRef(rightTabs);
const defaultShowHiddenFilesRef = useRef(defaultShowHiddenFiles);
leftTabsRef.current = leftTabs;
rightTabsRef.current = rightTabs;
defaultShowHiddenFilesRef.current = defaultShowHiddenFiles;
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return null;
return sideTabs.tabs.find((t) => t.id === sideTabs.activeTabId) || null;
}, []);
const leftPane = useMemo(() => {
const pane = leftTabs.activeTabId
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [leftTabs]);
const rightPane = useMemo(() => {
const pane = rightTabs.activeTabId
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [rightTabs]);
const updateTab = useCallback(
(side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
tabs: prev.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
}));
},
[],
);
const updateActiveTab = useCallback(
(side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return;
updateTab(side, sideTabs.activeTabId, updater);
},
[updateTab],
);
const clearSelectionsExcept = useCallback(
(target: { side: "left" | "right"; tabId: string } | null) => {
const clearSideSelections = (
prev: SftpSideTabs,
side: "left" | "right",
): SftpSideTabs => {
let changed = false;
const tabs = prev.tabs.map((tab) => {
const shouldKeepSelection =
target?.side === side && target.tabId === tab.id;
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
return tab;
}
changed = true;
return { ...tab, selectedFiles: EMPTY_SELECTION };
});
return changed ? { ...prev, tabs } : prev;
};
setLeftTabs((prev) => clearSideSelections(prev, "left"));
setRightTabs((prev) => clearSideSelections(prev, "right"));
},
[],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
if (prev.showHiddenFiles === showHiddenFiles) {
return prev;
}
return {
...prev,
showHiddenFiles,
};
});
},
[updateTab],
);
const addTab = useCallback(
(side: "left" | "right") => {
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
return newPane.id;
},
[],
);
const closeTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) return prev;
let newActiveTabId: string | null = null;
if (prev.tabs.length > 1) {
if (prev.activeTabId === tabId) {
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
newActiveTabId = prev.tabs[nextIndex]?.id || null;
} else {
newActiveTabId = prev.activeTabId;
}
}
return {
tabs: prev.tabs.filter((t) => t.id !== tabId),
activeTabId: newActiveTabId,
};
});
},
[],
);
const selectTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
activeTabId: tabId,
}));
},
[],
);
const reorderTabs = useCallback(
(
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabs = [...prev.tabs];
const draggedIndex = tabs.findIndex((t) => t.id === draggedId);
const targetIndex = tabs.findIndex((t) => t.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return prev;
const [draggedTab] = tabs.splice(draggedIndex, 1);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
const adjustedIndex = draggedIndex < targetIndex ? insertIndex - 1 : insertIndex;
tabs.splice(adjustedIndex, 0, draggedTab);
return { ...prev, tabs };
});
},
[],
);
const moveTabToOtherSide = useCallback(
(fromSide: "left" | "right", tabId: string) => {
const sourceTabs = fromSide === "left" ? leftTabsRef.current : rightTabsRef.current;
const setSourceTabs = fromSide === "left" ? setLeftTabs : setRightTabs;
const setTargetTabs = fromSide === "left" ? setRightTabs : setLeftTabs;
const tabToMove = sourceTabs.tabs.find((t) => t.id === tabId);
if (!tabToMove) return;
logger.info("[SFTP] Moving tab to other side", {
fromSide,
toSide: fromSide === "left" ? "right" : "left",
tabId,
hostLabel: tabToMove.connection?.hostLabel,
});
setSourceTabs((prev) => {
const newTabs = prev.tabs.filter((t) => t.id !== tabId);
let newActiveTabId: string | null = null;
if (newTabs.length > 0) {
if (prev.activeTabId === tabId) {
newActiveTabId = newTabs[0].id;
} else {
newActiveTabId = prev.activeTabId;
}
}
return { tabs: newTabs, activeTabId: newActiveTabId };
});
setTargetTabs((prev) => ({
tabs: [...prev.tabs, tabToMove],
activeTabId: tabToMove.id,
}));
},
[],
);
const DEFAULT_TAB_LABEL = "New Tab";
const getTabsInfo = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || DEFAULT_TAB_LABEL,
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
}));
},
[],
);
const getActiveTabId = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.activeTabId;
},
[],
);
return {
leftTabs,
rightTabs,
leftTabsRef,
rightTabsRef,
setLeftTabs,
setRightTabs,
leftPane,
rightPane,
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
selectTab,
reorderTabs,
moveTabToOtherSide,
getTabsInfo,
getActiveTabId,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
import { SftpFileEntry } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
};
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
if (isNaN(date.getTime())) return "--";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
export const getFileExtension = (name: string): string => {
if (name === "..") return "folder";
const ext = name.split(".").pop()?.toLowerCase();
return ext || "file";
};
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
};
// Check if path is Windows-style
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const isWindowsRoot = (path: string): boolean => {
if (!isWindowsPath(path)) return false;
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
};
export const joinPath = (base: string, name: string): string => {
if (isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base.replace(/\/+$/, "")}/${name}`;
};
export const getParentPath = (path: string): string => {
if (isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
return result;
}
if (path === "/") {
return "/";
}
const parts = path.split("/").filter(Boolean);
parts.pop();
const result = parts.length ? `/${parts.join("/")}` : "/";
return result;
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";
};

View File

@@ -1,207 +0,0 @@
import { useSyncExternalStore } from 'react';
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
/**
* UI Font Store - singleton pattern using useSyncExternalStore
* Fetches system fonts and combines with bundled fonts
*/
type Listener = () => void;
interface UIFontStoreState {
availableFonts: UIFont[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
/**
* Type definition for Local Font Access API
*/
interface LocalFontData {
family: string;
}
class UIFontStore {
private state: UIFontStoreState = {
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
private notify = () => {
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<UIFontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
initialize = async (): Promise<void> => {
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
const localFonts = await this.getLocalFonts();
// Use a Map to deduplicate by normalized font name
const fontMap = new Map<string, UIFont>();
// Add bundled fonts first (they have priority)
UI_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace
localFonts.forEach(font => {
const localId = `local-${font.id}`;
// Skip if a bundled font with similar name exists
if (!fontMap.has(font.id)) {
fontMap.set(localId, { ...font, id: localId });
}
});
this.setState({
availableFonts: Array.from(fontMap.values()),
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local UI fonts, using defaults:', error);
this.setState({
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
private async getLocalFonts(): Promise<UIFont[]> {
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Deduplicate by family name
const uniqueFamilies = new Set<string>();
const dedupedFonts = fonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
return true;
});
// Map to UIFont structure
return dedupedFonts.map(f => ({
id: f.family.toLowerCase().replace(/\s+/g, '-'),
name: f.family,
family: withUiCjkFallback(`"${f.family}", system-ui`),
}));
} catch (error) {
console.warn('Failed to query local fonts:', error);
return [];
}
}
getFontById = (fontId: string): UIFont => {
const fonts = this.state.availableFonts;
const found = fonts.find(f => f.id === fontId);
if (found) return found;
// For local fonts that haven't been loaded yet, construct a fallback
// This handles the case when main window receives a local font ID before fonts are loaded
if (fontId.startsWith('local-')) {
const fontName = fontId
.replace(/^local-/, '')
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
id: fontId,
name: fontName,
family: withUiCjkFallback(`"${fontName}", system-ui`),
};
}
return fonts[0] || UI_FONTS[0];
};
}
// Singleton instance
export const uiFontStore = new UIFontStore();
/**
* Get available UI fonts - triggers initialization on first use
*/
export const useAvailableUIFonts = (): UIFont[] => {
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
uiFontStore.initialize();
}
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getAvailableFonts
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
export const useUIFontsLoaded = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoaded
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
export const initializeUIFonts = (): void => {
uiFontStore.initialize();
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
) {
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discover = useCallback(async () => {
const bridge = getBridge();
if (!bridge) return;
setIsDiscovering(true);
try {
const agents = await bridge.aiDiscoverAgents();
setDiscoveredAgents(agents);
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
}
}, []);
// Discover on mount
useEffect(() => {
discover();
}, [discover]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
let changed = false;
const next = prev.map((ea) => {
// Only update agents that were auto-discovered (id starts with "discovered_")
if (!ea.id.startsWith('discovered_')) return ea;
const match = discoveredAgents.find(
(da) => ea.command === da.path || ea.command === da.command,
);
if (!match) return ea;
// Check if args or ACP config differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
}
return ea;
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(
(da) => !externalAgents.some(
(ea) => ea.command === da.command || ea.command === da.path,
),
);
// Build ExternalAgentConfig from a discovered agent
const enableAgent = useCallback(
(agent: DiscoveredAgent): ExternalAgentConfig => {
return {
id: `discovered_${agent.command}`,
name: agent.name,
command: agent.path || agent.command,
args: agent.args,
icon: agent.icon,
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
};
},
[],
);
return {
discoveredAgents,
unconfiguredAgents,
isDiscovering,
rediscover: discover,
enableAgent,
};
}

View File

@@ -7,23 +7,17 @@ export type ApplicationInfo = {
platform: string;
};
export type SshAgentStatus = {
running: boolean;
startupType: string | null;
error: string | null;
};
export const useApplicationBackend = () => {
const openExternal = useCallback(async (url: string) => {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
// Bridge resolves on success (either via system browser or in-app
// fallback window) and rejects only when both paths fail. Let the
// rejection propagate so callers can present a user-facing message.
await bridge.openExternal(url);
return;
try {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
await bridge.openExternal(url);
return;
}
} catch {
// Ignore and fall back below
}
// Fallback for non-Electron environments (tests, dev server, etc.).
window.open(url, "_blank", "noopener,noreferrer");
}, []);
@@ -33,12 +27,6 @@ export const useApplicationBackend = () => {
return info ?? null;
}, []);
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
const bridge = netcattyBridge.get();
const status = await bridge?.checkSshAgent?.();
return status ?? null;
}, []);
return { openExternal, getApplicationInfo, checkSshAgent };
return { openExternal, getApplicationInfo };
};

View File

@@ -7,24 +7,13 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
import type { SyncPayload } from '../../domain/sync';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
// Data to sync
@@ -33,29 +22,15 @@ interface AutoSyncConfig {
identities?: SyncPayload['identities'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
startupReady?: boolean;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyPayload: (payload: SyncPayload) => void;
}
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
// must not push concurrently.
const isRestoreInProgress = (): boolean => {
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
return typeof raw === 'number' && raw > Date.now();
};
type SyncTrigger = 'auto' | 'manual';
@@ -66,161 +41,55 @@ interface SyncNowOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
* cloud vault before the startup pull has run. See #679. */
const remoteCheckDoneRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
// When checkRemoteVersion detects that the local vault is empty but
// the cloud has data, it pauses and exposes this state so the root
// component can render a confirmation dialog.
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
config.keys,
config.identities,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
portForwardingRules: config.portForwardingRules,
knownHosts: config.knownHosts,
syncedAt: Date.now(),
};
}, [getSyncSnapshot]);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
// Create a hash of current data for comparison (includes settings)
// Create a hash of current data for comparison
const getDataHash = useCallback(() => {
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
portForwardingRules: config.portForwardingRules,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const syncing = state.syncState === 'SYNCING';
if (!hasProvider) {
throw new Error(t('sync.autoSync.noProvider'));
}
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
// Cross-window guard: another window may be in the middle of
// applying a local vault restore. If we push right now we'd upload
// the pre-restore snapshot (the main window's React state hasn't
// observed the localStorage writes yet), clobbering the just-
// restored cloud copy. Skip silently on auto triggers and fail
// loudly on manual ones so the user understands why their click
// did nothing.
//
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
// (the writer) and with the matching early-return in the
// debounced-sync effect below (the other reader, which prevents
// scheduling a push while the barrier is held).
if (isRestoreInProgress()) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
return;
}
throw new Error(t('sync.autoSync.restoreInProgress'));
}
// Refuse to auto-push when a previous apply crashed mid-way and
// left the vault in a partial state. `applyProtectedSyncPayload`
// sets a sentinel before its non-atomic localStorage writes and
// clears it on successful completion; the sentinel's presence
// here means the renderer crashed between a first write and the
// clean-up, so the in-memory payload is a mix of pre-apply and
// post-apply entries. Pushing that would silently overwrite an
// intact cloud copy with corrupted data.
//
// Manual triggers surface a user-visible error that points the
// user at the Restore UI; auto triggers return quietly (the
// next startup toast below flags the state).
const interruptedApply = readInterruptedVaultApply();
if (interruptedApply) {
if (trigger === 'auto') {
console.warn(
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
interruptedApply,
);
return;
}
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
}
// If another window unlocked, reuse the in-memory session password from main process.
if (state.securityState !== 'UNLOCKED') {
const bridge = netcattyBridge.get();
@@ -239,45 +108,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
// Refuse to push an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// Both auto and manual triggers are blocked; the user can still
// use Force Push from the SyncBlocked banner if they genuinely
// want to wipe the cloud.
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
}
for (const result of results.values()) {
if (!result.success) {
if (result.conflictDetected) {
@@ -287,258 +120,55 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
lastSyncedDataRef.current = dataHash;
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
// to a successful startup reconciliation from the auto-sync
// gate's point of view. Opening the gate here is the escape
// hatch when a network outage exhausted the startup retry
// timer: a user-triggered manual sync (or any first successful
// auto sync that somehow ran anyway) resumes auto-sync for the
// rest of the session. Without this, a degraded-startup session
// would require the user to manually sync after every edit.
hasCheckedRemoteRef.current = true;
remoteCheckDoneRef.current = true;
lastSyncedDataRef.current = getDataHash();
} catch (error) {
if (trigger === 'manual') {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
notify.error(
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// One-shot toast per mount when a previous apply was interrupted, so the
// user understands why auto-sync is silently paused and where to go to
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
// apply, so this only fires once per genuine crash and naturally stops
// after the user completes a recovery.
const interruptedApplyNotifiedRef = useRef(false);
useEffect(() => {
if (interruptedApplyNotifiedRef.current) return;
if (!sync.isUnlocked) return;
const interrupted = readInterruptedVaultApply();
if (!interrupted) return;
interruptedApplyNotifiedRef.current = true;
notify.error(
t('sync.autoSync.interruptedApplyMessage'),
t('sync.autoSync.interruptedApplyTitle'),
);
}, [sync.isUnlocked, t]);
// Stabilize the fields `checkRemoteVersion` reads from `config`.
// AutoSyncConfig is a fresh object literal on every App render, so a
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
// every unrelated state change — re-firing the retry effect with
// `attempt=0` and spawning overlapping in-flight inspections. The
// refs below let `checkRemoteVersion` read the latest callback and
// readiness flag without pulling the object identity into deps.
const onApplyPayloadRef = useRef(config.onApplyPayload);
useEffect(() => {
onApplyPayloadRef.current = config.onApplyPayload;
}, [config.onApplyPayload]);
const startupReadyRef = useRef(config.startupReady);
useEffect(() => {
startupReadyRef.current = config.startupReady;
}, [config.startupReady]);
// `buildPayload` closes over live React state so its identity flips
// on every vault edit; route it through a ref so `checkRemoteVersion`
// can read the latest builder without churning its memo identity.
const buildPayloadRef = useRef(buildPayload);
useEffect(() => {
buildPayloadRef.current = buildPayload;
}, [buildPayload]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
// interleaved applies, and both could push post-merge snapshots to
// remote. The cross-window `withRestoreBarrier` protects other
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
}, [sync, buildPayload, getDataHash, t]);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
if (checkRemoteInFlightRef.current) {
return;
}
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
return;
}
// Find connected provider BEFORE acquiring the in-flight lock so the
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
remoteCheckDoneRef.current = true;
return;
}
checkRemoteInFlightRef.current = true;
// Track whether the startup path completed in a state where the anchor/base
// are consistent with the local vault. Only then should we latch
// hasCheckedRemoteRef so that transient failures are retryable.
let startupConsistent = false;
hasCheckedRemoteRef.current = true;
// Find connected provider
const connectedProvider =
state.providers.github.status === 'connected' ? 'github' :
state.providers.google.status === 'connected' ? 'google' :
state.providers.onedrive.status === 'connected' ? 'onedrive' :
state.providers.webdav.status === 'connected' ? 'webdav' :
state.providers.s3.status === 'connected' ? 's3' : null;
if (!connectedProvider) return;
try {
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
const base = await manager.loadSyncBase(connectedProvider);
const inspection = await manager.inspectProviderRemote(connectedProvider);
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
// Remote unchanged (or empty) — no local mutation needed; anchor/base
// are already in sync with remote from a previous run.
startupConsistent = true;
return;
}
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
if (userAction === 'restore') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, otherwise a failure
// between commit and apply would leave the anchor pointing at
// remote while local is still empty — the exact overwrite window
// we're trying to close.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
}
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
// stores remotePayload as the base so the next diff is computed
// against what the cloud actually has, not against the merged
// local-only state.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
// Previously this branch stopped after applying merge locally, so the
// merged-in additions lived only on the device that ran the merge
// until the user's next edit.
//
// We push the merged payload *directly* through the manager rather
// than going through the React-state-driven `syncNow`. syncNow
// rebuilds the payload from hooks state, which may not yet reflect
// the onApplyPayload we awaited above (React commit phase is async
// relative to the awaited promise resolution). Passing mergeResult
// in explicitly removes the race entirely and avoids a setTimeout(0)
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
(r) => r.shrinkBlocked === true,
);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
// is acceptable here — the next user-edit-triggered sync will re-check
// (and the user can also force-push from the Settings banner if they
// navigate there). Reset syncState so we don't leave the manager wedged
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.
skipNextSyncRef.current = true;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
}
console.log('[AutoSync] Checking remote version...');
const remotePayload = await sync.downloadFromProvider(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
console.log('[AutoSync] Remote is newer, applying...');
config.onApplyPayload(remotePayload);
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
if (startupConsistent) {
hasCheckedRemoteRef.current = true;
// Only open the auto-sync gate when the inspect actually
// validated the remote state. Leaving the gate closed on
// inspect failure is intentional: an edit made during a
// degraded startup must not race ahead and push a partially-
// hydrated vault over an intact remote. The retry effect
// below re-fires checkRemoteVersion on the next provider/
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
}
checkRemoteInFlightRef.current = false;
// Don't show error toast for initial check - it's not critical
}
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
// and `config.startupReady` are read through refs above so their
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
}, [t]);
}, [sync, config, t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -546,15 +176,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
// Don't auto-sync until the startup remote check has completed.
// Without this gate, an empty local vault can push to the cloud
// before checkRemoteVersion even runs, overwriting a non-empty
// remote vault — the exact bug described in #679.
if (!remoteCheckDoneRef.current) {
return;
}
// Skip initial render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
@@ -563,42 +185,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -607,6 +198,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
console.log('[AutoSync] Data changed, syncing...');
syncNow();
}, 3000);
@@ -615,113 +207,33 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
// a failure that doesn't coincide with a dep change would wedge the
// auto-sync gate closed until the user restarts or manually triggers
// sync from Settings — the 30s/60s/90s cadence below lets a short
// outage (network blip, provider rate-limit) self-heal.
// Check remote version on startup/unlock
useEffect(() => {
if (
!sync.hasAnyConnectedProvider ||
!sync.isUnlocked ||
hasCheckedRemoteRef.current ||
config.startupReady === false
) {
return;
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
// Delay check to ensure everything is loaded
const timer = setTimeout(() => {
checkRemoteVersion();
}, 1000);
return () => clearTimeout(timer);
}
let cancelled = false;
let attempt = 0;
let timerId: NodeJS.Timeout | null = null;
const tick = () => {
if (cancelled) return;
void (async () => {
await checkRemoteVersion();
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
// misconfiguration that needs user action rather than more
// auto-retries.
//
// When retries exhaust we deliberately leave the auto-sync gate
// CLOSED. Opening it here would allow a partially-lost local
// vault to silently clobber an unchanged remote: anchor still
// matches, `checkProviderConflict` sees no remote change,
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
// local, and the empty-vault prompt never fires.
//
// Escape hatch: a successful manual sync from Settings opens
// the gate via `syncNow`'s success path. That path runs the
// same per-provider inspect we use here, so a successful
// manual sync is equivalent to a successful startup inspect
// from the gate's point of view — the user's explicit click
// authorizes both the push and the subsequent auto-sync
// resumption. Until then, auto-sync stays paused and the
// "sync paused" toast is the user's signal to act.
if (attempt >= 4) return;
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
attempt += 1;
timerId = setTimeout(tick, delayMs);
})();
};
tick();
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
// Reset check flags when provider disconnects
// Reset check flag when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
}
}, [sync.hasAnyConnectedProvider]);
// On unmount, release any pending empty-vault confirmation. Without
// this, an unmount mid-dialog (window close, workspace switch) leaves
// the resolver promise dangling forever and the `checkRemoteVersion`
// finally block never sets remoteCheckDoneRef — in practice React
// tears down the hook first, but leaking the resolve callback and
// referenced remotePayload keeps them pinned by the awaiter until
// the next reload. Resolving with 'keep-empty' is the safe default:
// it mirrors the "don't touch remote" choice and leaves the version
// stamp untouched so the next mount re-prompts.
useEffect(() => {
return () => {
const resolve = emptyVaultResolveRef.current;
if (resolve) {
emptyVaultResolveRef.current = null;
resolve('keep-empty');
}
};
}, []);
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
// Guard: resolve only once (prevents double-click from entering an
// inconsistent state). The ref is nulled immediately so subsequent
// calls are no-ops.
const resolve = emptyVaultResolveRef.current;
if (!resolve) return;
emptyVaultResolveRef.current = null;
resolve(action);
}, []);
return {
syncNow,
buildPayload,
isSyncing: sync.isSyncing,
isConnected: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
emptyVaultConflict,
resolveEmptyVaultConflict,
};
};

View File

@@ -1,14 +0,0 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useClipboardBackend = () => {
const readClipboardText = useCallback(async (): Promise<string> => {
const bridge = netcattyBridge.get();
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
const text = await bridge.readClipboardText();
return typeof text === "string" ? text : "";
}, []);
return { readClipboardText };
};

View File

@@ -6,7 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import {
type CloudProvider,
type SecurityState,
@@ -21,14 +21,12 @@ import {
type S3Config,
formatLastSync,
getSyncDotColor,
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
type SyncEventCallback,
} from '../../infrastructure/services/CloudSyncManager';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
@@ -57,7 +55,7 @@ export interface CloudSyncHook {
// Computed
hasAnyConnectedProvider: boolean;
connectedProviderCount: number;
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
// Master Key Actions
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
@@ -83,30 +81,14 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
// Gist Revision History
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
downloadGistRevision: (sha: string) => Promise<{
payload: SyncPayload;
meta: import('../../domain/sync').SyncFileMeta;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null>;
// Settings
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
@@ -118,12 +100,12 @@ export interface CloudSyncHook {
formatLastSync: (timestamp?: number) => string;
getProviderDotColor: (provider: CloudProvider) => string;
refresh: () => void;
}
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
subscribeToEvents: (callback: SyncEventCallback) => () => void;
// Shrink-block state query (for banner hydration on mount)
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
@@ -144,6 +126,17 @@ const getSnapshot = (): SyncManagerState => {
};
export const useCloudSync = (): CloudSyncHook => {
// Force update mechanism to ensure React re-renders
const [, forceUpdate] = useState(0);
// Subscribe to state changes and force update
useEffect(() => {
const unsubscribe = manager.subscribeToStateChanges(() => {
forceUpdate(n => n + 1);
});
return unsubscribe;
}, []);
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -188,18 +181,17 @@ export const useCloudSync = (): CloudSyncHook => {
const hasAnyConnectedProvider = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).some(
(p) => isProviderReadyForSync(p)
(p) => p.status === 'connected' || p.status === 'syncing'
);
}, [state.providers]);
const connectedProviderCount = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).filter(
(p) => isProviderReadyForSync(p)
(p) => p.status === 'connected' || p.status === 'syncing'
).length;
}, [state.providers]);
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
if (state.syncState === 'BLOCKED') return 'blocked';
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
if (state.syncState === 'CONFLICT') return 'conflict';
if (state.syncState === 'ERROR') return 'error';
if (state.syncState === 'SYNCING') return 'syncing';
@@ -280,7 +272,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -288,44 +280,32 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open system browser
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
}
return data.url;
}, []);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -333,33 +313,22 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open system browser
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
}
return data.url;
}, []);
@@ -375,10 +344,6 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.disconnectProvider(provider);
}, []);
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
manager.resetProviderStatus(provider);
}, []);
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
await manager.connectConfigProvider('webdav', config);
}, []);
@@ -387,19 +352,14 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
manager.setAutoSync(enabled, intervalMinutes);
}, []);
const setDeviceName = useCallback((name: string) => {
manager.setDeviceName(name);
const setDeviceName = useCallback((_name: string) => {
// TODO: Add setDeviceName to CloudSyncManager if needed
}, []);
// ========== Utilities ==========
@@ -431,14 +391,14 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Vault is locked');
}, []);
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
await ensureUnlocked();
return await manager.syncAllProviders(payload, opts);
return await manager.syncAllProviders(payload);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
await ensureUnlocked();
return await manager.syncToProvider(provider, payload, opts);
return await manager.syncToProvider(provider, payload);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -446,16 +406,6 @@ export const useCloudSync = (): CloudSyncHook => {
return await manager.downloadFromProvider(provider);
}, [ensureUnlocked]);
const subscribeToEvents = useCallback(
(callback: SyncEventCallback) => manager.subscribe(callback),
[],
);
const getShrinkBlockedFinding = useCallback(
() => manager.getShrinkBlockedFinding(),
[],
);
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
await ensureUnlocked();
return await manager.resolveConflict(resolution);
@@ -499,19 +449,13 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,
// Sync Actions
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,
downloadFromProvider: downloadFromProviderWithUnlock,
resolveConflict: resolveConflictWithUnlock,
// Gist Revision History (#679)
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
downloadGistRevision: manager.downloadGistRevision.bind(manager),
// Settings
setAutoSync,
@@ -524,12 +468,62 @@ export const useCloudSync = (): CloudSyncHook => {
formatLastSync,
getProviderDotColor,
refresh,
};
};
// Event subscription
subscribeToEvents,
// ============================================================================
// Convenience Hooks
// ============================================================================
// Shrink-block state query
getShrinkBlockedFinding,
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: connection.status === 'connected',
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};

View File

@@ -1,62 +0,0 @@
/**
* File upload conversion helpers for AI draft attachments.
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import type { UploadedFile } from '../../infrastructure/ai/types';
import { getPathForFile } from '../../lib/sftpFileUtils';
export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return [];
const uploads: Array<UploadedFile | null> = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);
return {
id,
filename,
dataUrl: result.dataUrl,
base64Data: result.base64,
mediaType,
filePath,
};
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
return null;
}
}),
);
return uploads.filter((upload): upload is UploadedFile => upload !== null);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
interface HotkeyActions {
export interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;
@@ -77,7 +77,6 @@ export const getTerminalPassthroughActions = (): Set<string> => {
return new Set([
'copy',
'paste',
'pasteSelection',
'selectAll',
'clearBuffer',
'searchTerminal',
@@ -136,6 +135,8 @@ export const useGlobalHotkeys = ({
e.stopPropagation();
const currentActions = actionsRef.current;
const _tabs = orderedTabsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);

View File

@@ -1,214 +0,0 @@
/**
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
*
* Performance strategy:
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
* - Custom/unknown themes are computed lazily and cached
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
*/
import { useEffect, useLayoutEffect, useRef } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// ---------------------------------------------------------------------------
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
// ---------------------------------------------------------------------------
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
// ---------------------------------------------------------------------------
// Build the CSS rule string from a TerminalTheme
// ---------------------------------------------------------------------------
const CSS_VARS = [
'background', 'foreground', 'card', 'card-foreground',
'popover', 'popover-foreground', 'primary', 'primary-foreground',
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
'border', 'input', 'ring',
] as const;
function buildImmersiveCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === 'dark';
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
const values = [
bg, fg, card, fg, // background, foreground, card, card-foreground
card, fg, // popover, popover-foreground
cursor, primaryFg, // primary, primary-foreground
secondary, fg, // secondary, secondary-foreground
muted, mutedFg, // muted, muted-foreground
cursor, primaryFg, // accent, accent-foreground
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
border, border, cursor, // border, input, ring
];
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
return `:root { ${rules}; }`;
}
// ---------------------------------------------------------------------------
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
// ---------------------------------------------------------------------------
const cssCache = new Map<string, string>();
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
function themeFingerprint(t: TerminalTheme): string {
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
}
// Pre-compute built-in themes
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
}
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
function getImmersiveCss(theme: TerminalTheme): string {
const fp = themeFingerprint(theme);
let css = cssCache.get(fp);
if (!css) {
css = buildImmersiveCss(theme);
cssCache.set(fp, css);
}
return css;
}
// ---------------------------------------------------------------------------
// Style tag management
// ---------------------------------------------------------------------------
const STYLE_ID = 'netcatty-immersive-override';
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
const root = document.documentElement;
const targetClass = isDark ? 'dark' : 'light';
if (!root.classList.contains(targetClass)) {
root.classList.remove('light', 'dark');
root.classList.add(targetClass);
}
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = css;
// Sync native Electron window chrome
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
netcattyBridge.get()?.setBackgroundColor?.(bg);
}
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
}) {
const overrideActiveRef = useRef(false);
const appliedFpRef = useRef<string | null>(null);
const restoreRef = useRef(restoreOriginalTheme);
restoreRef.current = restoreOriginalTheme;
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
const overlay = document.createElement('div');
overlay.className = 'immersive-fade-overlay';
overlay.style.backgroundColor = `hsl(${bg})`;
document.body.appendChild(overlay);
removeImmersiveStyle();
restoreOriginalTheme();
requestAnimationFrame(() => {
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {
return () => {
removeImmersiveStyle();
appliedFpRef.current = null;
if (overrideActiveRef.current) {
overrideActiveRef.current = false;
restoreRef.current();
}
};
}, []);
}

View File

@@ -15,8 +15,6 @@ export const useKeychainBackend = () => {
privateKey?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");

View File

@@ -1,95 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
type LocalVaultBackupPreview,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
openLocalVaultBackupDir,
readLocalVaultBackup,
setLocalVaultBackupMaxCount,
trimLocalVaultBackups,
} from '../localVaultBackups';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useLocalVaultBackups() {
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
// `null` while we're still asking the main process. The UI should treat
// `null` as "unknown, don't render restore controls yet" so we never expose
// a destructive action that might later be disabled.
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
const refreshBackups = useCallback(async () => {
setIsLoading(true);
try {
const next = await listLocalVaultBackups();
setBackups(next);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const caps = await getLocalVaultBackupCapabilities();
if (!cancelled) {
setEncryptionAvailable(caps.encryptionAvailable);
}
} catch {
if (!cancelled) {
setEncryptionAvailable(false);
}
}
})();
void refreshBackups();
return () => {
cancelled = true;
};
}, [refreshBackups]);
// Cross-window live refresh: the main process broadcasts when any
// renderer's createBackup or trimBackups actually mutated the on-disk
// set. Without this subscription, a protective backup written by the
// main window wouldn't show up in the Settings window's list until
// the user manually navigated away and back, silently under-reporting
// the most recent recovery points.
useEffect(() => {
const bridge = netcattyBridge.get();
const subscribe = bridge?.onVaultBackupsChanged;
if (typeof subscribe !== 'function') return undefined;
const unsubscribe = subscribe(() => {
void refreshBackups();
});
return () => {
try { unsubscribe?.(); } catch { /* ignore */ }
};
}, [refreshBackups]);
const updateMaxBackups = useCallback(async (value: number) => {
const sanitized = setLocalVaultBackupMaxCount(value);
setMaxBackupsState(sanitized);
await trimLocalVaultBackups(sanitized);
await refreshBackups();
return sanitized;
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
return {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
};
}
export default useLocalVaultBackups;

View File

@@ -1,364 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { Host, ManagedSource } from "../../domain/models";
import {
serializeHostsToSshConfig,
mergeWithExistingSshConfig,
} from "../../domain/sshConfigSerializer";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
export interface UseManagedSourceSyncOptions {
hosts: Host[];
managedSources: ManagedSource[];
onUpdateManagedSources: (sources: ManagedSource[]) => void;
}
export const useManagedSourceSync = ({
hosts,
managedSources,
onUpdateManagedSources,
}: UseManagedSourceSyncOptions) => {
const previousHostsRef = useRef<Host[]>([]);
const syncInProgressRef = useRef(false);
// Keep a ref to the latest managedSources to avoid stale closure issues
const managedSourcesRef = useRef(managedSources);
managedSourcesRef.current = managedSources;
const getManagedHostsForSource = useCallback(
(sourceId: string) => {
return hosts.filter((h) => h.managedSourceId === sourceId);
},
[hosts],
);
const readExistingFileContent = useCallback(
async (filePath: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) {
return null;
}
try {
const buffer = await bridge.readLocalFile(filePath);
const decoder = new TextDecoder();
return decoder.decode(buffer);
} catch {
// File might not exist yet
return null;
}
},
[],
);
const mergeWithExistingContent = useCallback(
(
existingContent: string | null,
managedHosts: Host[],
allHosts: Host[],
): string => {
// Serialize the managed hosts
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
if (!existingContent) {
// No existing file, just wrap the managed content
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
}
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
// No existing managed block - need to remove duplicate Host entries
// Build a set of hostnames/aliases that will be managed
const managedHostnameSet = new Set<string>();
for (const host of managedHosts) {
if (!host.protocol || host.protocol === "ssh") {
// Add both hostname and sanitized label (alias) for matching
managedHostnameSet.add(host.hostname.toLowerCase());
if (host.label) {
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
}
}
}
// Use mergeWithExistingSshConfig to filter out existing Host blocks
// that match our managed hosts, keeping preserved content outside markers
const mergedContent = mergeWithExistingSshConfig(
existingContent,
managedHosts,
managedHostnameSet,
allHosts,
);
return mergedContent;
}
// Replace the existing managed block
const before = existingContent.substring(0, beginIndex);
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
},
[],
);
const writeSshConfigToFile = useCallback(
async (source: ManagedSource, managedHosts: Host[]) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) {
console.warn("[ManagedSourceSync] writeLocalFile not available");
return false;
}
try {
// Read existing file content to preserve non-managed parts
const existingContent = await readExistingFileContent(source.filePath);
// Merge with existing content, preserving non-managed parts and removing duplicates
const finalContent = mergeWithExistingContent(
existingContent,
managedHosts,
hosts,
);
const encoder = new TextEncoder();
const buffer = encoder.encode(finalContent);
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
return true;
} catch (err) {
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
return false;
}
},
[readExistingFileContent, mergeWithExistingContent, hosts],
);
const syncManagedSource = useCallback(
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
const managedHosts = getManagedHostsForSource(source.id);
const success = await writeSshConfigToFile(source, managedHosts);
return { sourceId: source.id, success };
},
[getManagedHostsForSource, writeSshConfigToFile],
);
const unmanageSource = useCallback(
(sourceId: string) => {
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources],
);
// Clear the managed block in the SSH config file and then remove the source
// This should be called before deleting a managed group to avoid stale entries
const clearAndRemoveSource = useCallback(
async (source: ManagedSource) => {
// Write empty hosts list to clear the managed block
const success = await writeSshConfigToFile(source, []);
// Remove the source regardless of write success
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
onUpdateManagedSources(updatedSources);
return success;
},
[onUpdateManagedSources, writeSshConfigToFile],
);
// Clear and remove multiple sources atomically to avoid race conditions
// when multiple sources are removed concurrently
const clearAndRemoveSources = useCallback(
async (sources: ManagedSource[]) => {
if (sources.length === 0) return;
// Clear all files in parallel
await Promise.all(
sources.map(async (source) => {
const success = await writeSshConfigToFile(source, []);
return { sourceId: source.id, success };
})
);
// Remove all sources atomically in a single update
const sourceIdsToRemove = new Set(sources.map(s => s.id));
const updatedSources = managedSourcesRef.current.filter(
(s) => !sourceIdsToRemove.has(s.id)
);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources, writeSshConfigToFile],
);
const pendingSyncRef = useRef(false);
const checkAndSyncRef = useRef<() => void>(() => {});
const checkAndSync = useCallback(() => {
if (managedSources.length === 0) {
// Still update previousHostsRef so we have a baseline when sources are added
previousHostsRef.current = hosts;
return;
}
const prevHosts = previousHostsRef.current;
previousHostsRef.current = hosts;
// On initial sync (prevHosts empty), sync all sources that have managed hosts
const isInitialSync = prevHosts.length === 0;
const changedSourceIds = new Set<string>();
if (isInitialSync) {
// Initial sync: sync all sources that have hosts
for (const source of managedSources) {
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
if (currManaged.length > 0) {
changedSourceIds.add(source.id);
}
}
} else {
// Build maps for all hosts (for jump host lookup)
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
// Index hosts by managedSourceId to avoid O(N*M) lookups
const prevHostsBySource = new Map<string, Host[]>();
for (const h of prevHosts) {
if (h.managedSourceId) {
let list = prevHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
prevHostsBySource.set(h.managedSourceId, list);
}
list.push(h);
}
}
const currHostsBySource = new Map<string, Host[]>();
for (const h of hosts) {
if (h.managedSourceId) {
let list = currHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
currHostsBySource.set(h.managedSourceId, list);
}
list.push(h);
}
}
// Helper to check if a host's SSH-relevant fields changed
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
if (!prevHost || !currHost) return prevHost !== currHost;
return (
prevHost.hostname !== currHost.hostname ||
prevHost.port !== currHost.port ||
prevHost.username !== currHost.username ||
prevHost.label !== currHost.label
);
};
for (const source of managedSources) {
const prevManaged = prevHostsBySource.get(source.id) || [];
const currManaged = currHostsBySource.get(source.id) || [];
if (prevManaged.length !== currManaged.length) {
changedSourceIds.add(source.id);
continue;
}
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
let sourceChanged = false;
for (const curr of currManaged) {
const prev = prevManagedMap.get(curr.id);
if (!prev) {
sourceChanged = true;
break;
}
// Compare hostChain arrays for ProxyJump changes
const prevChain = prev.hostChain?.hostIds || [];
const currChain = curr.hostChain?.hostIds || [];
const chainChanged =
prevChain.length !== currChain.length ||
prevChain.some((id, i) => id !== currChain[i]);
const hasChanged =
prev.hostname !== curr.hostname ||
prev.port !== curr.port ||
prev.username !== curr.username ||
prev.label !== curr.label ||
prev.group !== curr.group ||
prev.protocol !== curr.protocol ||
chainChanged;
if (hasChanged) {
sourceChanged = true;
break;
}
// Check if any referenced jump hosts changed (even if outside this managed source)
for (const jumpHostId of currChain) {
const prevJumpHost = prevHostMap.get(jumpHostId);
const currJumpHost = currHostMap.get(jumpHostId);
if (hostChanged(prevJumpHost, currJumpHost)) {
sourceChanged = true;
break;
}
}
if (sourceChanged) break;
}
if (sourceChanged) {
changedSourceIds.add(source.id);
}
}
}
if (changedSourceIds.size > 0) {
syncInProgressRef.current = true;
Promise.all(
managedSources
.filter((s) => changedSourceIds.has(s.id))
.map(syncManagedSource),
).then((results) => {
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
const successfulSourceIds = new Set(
results.filter(r => r.success).map(r => r.sourceId)
);
if (successfulSourceIds.size > 0) {
const currentSources = managedSourcesRef.current;
const now = Date.now();
const updatedSources = currentSources.map((s) =>
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
);
onUpdateManagedSources(updatedSources);
}
}).finally(() => {
syncInProgressRef.current = false;
// Check if there were changes during sync that need to be processed
// Use ref to get the latest checkAndSync to avoid stale closure
if (pendingSyncRef.current) {
pendingSyncRef.current = false;
checkAndSyncRef.current();
}
});
}
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
// Keep ref updated with the latest checkAndSync
checkAndSyncRef.current = checkAndSync;
useEffect(() => {
if (syncInProgressRef.current) {
// Mark that we need to re-sync after current sync completes
pendingSyncRef.current = true;
return;
}
checkAndSync();
}, [hosts, managedSources, checkAndSync]);
return {
syncManagedSource,
unmanageSource,
clearAndRemoveSource,
clearAndRemoveSources,
getManagedHostsForSource,
};
};

View File

@@ -3,9 +3,8 @@
* This should be used at the App level to ensure auto-start happens
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { useEffect, useRef } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -18,9 +17,7 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
groupConfigs: GroupConfig[];
keys: { id: string; privateKey: string }[];
}
/**
@@ -30,39 +27,10 @@ export interface UsePortForwardingAutoStartOptions {
export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
groupConfigs,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
if (host.identityId) {
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
if (!identity) return false;
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
return false;
}
}
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
return false;
}
const chainIds = host.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isHostAuthReady(chainHost, seen)) return false;
}
return true;
}, []);
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
// Keep refs in sync
useEffect(() => {
@@ -73,20 +41,6 @@ export const usePortForwardingAutoStart = ({
keysRef.current = keys;
}, [keys]);
useEffect(() => {
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
}, []);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -103,45 +57,29 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Rule or host not found" };
}
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
const host = hostsRef.current.find((h) => h.id === rule.hostId);
if (!host) {
return { success: false, error: "Host not found" };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
return () => {
setReconnectCallback(null);
};
}, [resolveEffectiveHost]);
}, []);
// Auto-start rules on app launch
useEffect(() => {
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
const runAutoStart = async () => {
// First sync with backend to get any active tunnels
await syncWithBackend();
// Load rules from storage
const rules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
@@ -157,19 +95,18 @@ export const usePortForwardingAutoStart = ({
});
if (autoStartRules.length === 0) return;
autoStartExecutedRef.current = true;
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
// Start each auto-start rule
for (const rule of autoStartRules) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
const host = hosts.find((h) => h.id === rule.hostId);
if (host) {
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
@@ -196,5 +133,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
}, [hosts, keys]);
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { Host, PortForwardingRule } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -9,25 +9,13 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
initReconnectCancelListener,
reconcileWithBackend,
getActiveRuleIds,
startPortForward,
stopAllPortForwards,
stopAndCleanupRule,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
// Module-level ref-counts: these side effects must run at most once per
// window, not per hook instance (the hook mounts from both App.tsx
// and PortForwardingNew.tsx). Ref-counting ensures the resources
// stay alive as long as ANY instance is mounted.
let reconnectCancelListenerRefs = 0;
let reconnectCancelCleanup: (() => void) | undefined;
let heartbeatRefs = 0;
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
@@ -52,7 +40,6 @@ export interface UsePortForwardingStateResult {
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
deleteRule: (id: string) => void;
duplicateRule: (id: string) => void;
importRules: (rules: PortForwardingRule[]) => void;
setRuleStatus: (
id: string,
@@ -63,9 +50,7 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
keys: { id: string; privateKey: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -78,58 +63,8 @@ export interface UsePortForwardingStateResult {
selectedRule: PortForwardingRule | undefined;
}
// Global Store State
let globalRules: PortForwardingRule[] = [];
let isInitialized = false;
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
// Store Actions
const notifyListeners = () => {
listeners.forEach((listener) => listener(globalRules));
};
const setGlobalRules = (newRules: PortForwardingRule[]) => {
globalRules = newRules;
notifyListeners();
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
return rules.map((rule): PortForwardingRule => {
const connection = getActiveConnection(rule.id);
if (connection) {
return {
...rule,
status: connection.status,
error: connection.error,
};
}
return {
...rule,
status: "inactive" as const,
error: undefined,
};
});
};
// Initialization Logic
const initializeStore = async () => {
if (isInitialized) return;
isInitialized = true;
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
setGlobalRules(normalizeRulesWithConnections(saved));
}
};
export const usePortForwardingState = (): UsePortForwardingStateResult => {
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
const [rules, setRules] = useState<PortForwardingRule[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_PF_VIEW_MODE,
@@ -146,97 +81,37 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
}, []);
// Initialize store on mount (only once globally)
// Load rules from storage on mount and sync with backend
useEffect(() => {
void initializeStore();
}, []);
// Subscribe to global store
useEffect(() => {
// If global state was updated before we subscribed (e.g. init finished), update local state
if (rules !== globalRules) {
setRules(globalRules);
}
const listener = (newRules: PortForwardingRule[]) => {
setRules(newRules);
};
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, [rules]);
// Listen for storage events for cross-window sync (main window <-> tray panel)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
// Only handle changes from our specific key
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
// Parse the new value
if (e.newValue) {
try {
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
if (Array.isArray(newRules)) {
// Update global state without triggering another localStorage write
globalRules = normalizeRulesWithConnections(newRules);
notifyListeners();
const loadAndSync = async () => {
// First, sync with backend to get any active tunnels
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
// Sync status with active connections in the service layer
const _activeRuleIds = getActiveRuleIds();
const withSyncedStatus = saved.map((r) => {
const conn = getActiveConnection(r.id);
if (conn) {
// This rule has an active connection, preserve its status
return { ...r, status: conn.status, error: conn.error };
}
} catch {
// ignore parse errors
}
// No active connection, reset to inactive
return { ...r, status: "inactive" as const, error: undefined };
});
setRules(withSyncedStatus);
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
void loadAndSync();
}, []);
// Listen for cross-window reconnect cancellation events.
// Ref-counted so the listener stays alive as long as ANY hook
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
useEffect(() => {
reconnectCancelListenerRefs++;
let cleanup: (() => void) | undefined;
if (reconnectCancelListenerRefs === 1) {
cleanup = initReconnectCancelListener();
reconnectCancelCleanup = cleanup;
}
return () => {
reconnectCancelListenerRefs--;
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
reconnectCancelCleanup();
reconnectCancelCleanup = undefined;
}
};
}, []);
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
// Ref-counted — same pattern as the reconnect cancel listener.
useEffect(() => {
heartbeatRefs++;
let intervalId: ReturnType<typeof setInterval> | undefined;
if (heartbeatRefs === 1) {
const HEARTBEAT_INTERVAL_MS = 4_000;
const tick = async () => {
const { gone, appeared } = await reconcileWithBackend();
if (gone.length === 0 && appeared.length === 0) return;
// Re-derive statuses from the now-updated activeConnections map
setGlobalRules(normalizeRulesWithConnections(globalRules));
};
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
heartbeatIntervalId = intervalId;
}
return () => {
heartbeatRefs--;
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = undefined;
}
};
// Persist rules to storage whenever they change
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
}, []);
const addRule = useCallback(
@@ -249,40 +124,47 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
createdAt: Date.now(),
status: "inactive",
};
const updated = [...globalRules, newRule];
setGlobalRules(updated);
setRules((prev) => {
const updated = [...prev, newRule];
persistRules(updated);
return updated;
});
setSelectedRuleId(newRule.id);
return newRule;
},
[],
[persistRules],
);
const updateRule = useCallback(
(id: string, updates: Partial<PortForwardingRule>) => {
const updated = globalRules.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
setGlobalRules(updated);
setRules((prev) => {
const updated = prev.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
persistRules(updated);
return updated;
});
},
[],
[persistRules],
);
const deleteRule = useCallback(
(id: string) => {
// Stop any active tunnel before removing the rule
stopAndCleanupRule(id);
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
setRules((prev) => {
const updated = prev.filter((r) => r.id !== id);
persistRules(updated);
return updated;
});
if (selectedRuleId === id) {
setSelectedRuleId(null);
}
},
[selectedRuleId],
[selectedRuleId, persistRules],
);
const duplicateRule = useCallback(
(id: string) => {
const original = globalRules.find((r) => r.id === id);
const original = rules.find((r) => r.id === id);
if (!original) return;
const copy: PortForwardingRule = {
@@ -294,101 +176,47 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error: undefined,
lastUsedAt: undefined,
};
const updated = [...globalRules, copy];
setGlobalRules(updated);
setRules((prev) => {
const updated = [...prev, copy];
persistRules(updated);
return updated;
});
setSelectedRuleId(copy.id);
},
[],
[rules, persistRules],
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
// handles the backend, but we also need per-rule broadcasts so other
// windows cancel their pending reconnect timers.
if (newRules.length === 0) {
// Read from localStorage since globalRules may be empty (uninitialized)
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
const rulesToCancel = globalRules.length > 0
? globalRules
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
for (const rule of rulesToCancel) {
stopAndCleanupRule(rule.id);
}
// Safety net: also stop anything the renderer doesn't know about
void stopAllPortForwards();
}
// Stop tunnels for rules that are being removed or whose connection
// config has changed (same ID but different host/port/type means the
// old tunnel is pointing at stale parameters and must be torn down).
//
// Use globalRules as the diff baseline. In a freshly opened settings
// window, globalRules may still be empty because initializeStore is
// async. Fall back to reading directly from localStorage to avoid
// missing tunnels that need to be stopped.
let diffBaseline = globalRules;
if (diffBaseline.length === 0 && newRules.length > 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
diffBaseline = stored;
}
}
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
for (const existing of diffBaseline) {
const incoming = newRulesById.get(existing.id);
if (!incoming) {
// Rule removed entirely
stopAndCleanupRule(existing.id);
} else if (
existing.type !== incoming.type ||
existing.localPort !== incoming.localPort ||
existing.remoteHost !== incoming.remoteHost ||
existing.remotePort !== incoming.remotePort ||
existing.bindAddress !== incoming.bindAddress ||
existing.hostId !== incoming.hostId
) {
// Connection-relevant config changed — tear down the old tunnel
stopAndCleanupRule(existing.id);
}
}
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);
const setRuleStatus = useCallback(
(id: string, status: PortForwardingRule["status"], error?: string) => {
const updated = globalRules.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
setRules((prev) => {
const updated = prev.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
});
persistRules(updated);
return updated;
});
setGlobalRules(updated);
},
[],
[persistRules],
);
const startTunnel = useCallback(
async (
rule: PortForwardingRule,
host: Host,
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
keys: { id: string; privateKey: string }[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,
) => void,
enableReconnect = false,
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
return startPortForward(rule, host, keys, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
@@ -467,7 +295,6 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
updateRule,
deleteRule,
duplicateRule,
importRules,
setRuleStatus,
startTunnel,

View File

@@ -38,35 +38,22 @@ export const useSessionState = () => {
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
}) => {
const createLocalTerminal = useCallback(() => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
id: sessionId,
hostId: localHostId,
hostLabel: options?.shellName || 'Local Terminal',
hostLabel: 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
@@ -79,46 +66,12 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: config,
charset: options?.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
// Handle serial hosts specially - use createSerialSession for them
if (host.protocol === 'serial') {
// Use stored serialConfig or construct from host data
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const sessionId = crypto.randomUUID();
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: host.id,
@@ -130,59 +83,28 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
@@ -192,7 +114,7 @@ export const useSessionState = () => {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
@@ -200,12 +122,12 @@ export const useSessionState = () => {
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
@@ -227,10 +149,10 @@ export const useSessionState = () => {
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
@@ -247,14 +169,29 @@ export const useSessionState = () => {
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
}, [workspaces, setActiveTabId]);
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
@@ -318,71 +255,6 @@ export const useSessionState = () => {
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
// Create sessions for each host
const newSessions: TerminalSession[] = hosts.map(host => {
// Handle serial hosts specially
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
});
const sessionIds = newSessions.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
@@ -445,17 +317,11 @@ export const useSessionState = () => {
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
const splitSession = useCallback((
sessionId: string,
direction: SplitDirection,
options?: {
localShellType?: TerminalSession['shellType'];
},
direction: SplitDirection
) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
@@ -471,14 +337,8 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
// Add pane to existing workspace
const hint: SplitHint = {
direction,
@@ -507,19 +367,13 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
@@ -600,7 +454,6 @@ export const useSessionState = () => {
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
@@ -619,7 +472,6 @@ export const useSessionState = () => {
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
@@ -664,78 +516,6 @@ export const useSessionState = () => {
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
const newSession: TerminalSession = {
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -761,11 +541,9 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
@@ -779,12 +557,10 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);
@@ -837,7 +613,6 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
updateSplitSizes,
@@ -856,7 +631,5 @@ export const useSessionState = () => {
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { RemoteFile, SftpFilenameEncoding } from "../../types";
import type { RemoteFile } from "../../types";
export const useSftpBackend = () => {
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
@@ -15,34 +15,34 @@ export const useSftpBackend = () => {
return bridge.closeSftp(sftpId);
}, []);
const listSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const listSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
return bridge.listSftp(sftpId, path, encoding);
return bridge.listSftp(sftpId, path);
}, []);
const readSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const readSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
return bridge.readSftp(sftpId, path, encoding);
return bridge.readSftp(sftpId, path);
}, []);
const readSftpBinary = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
return bridge.readSftpBinary(sftpId, path, encoding);
return bridge.readSftpBinary(sftpId, path);
}, []);
const writeSftp = useCallback(async (sftpId: string, path: string, content: string, encoding?: SftpFilenameEncoding) => {
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
return bridge.writeSftp(sftpId, path, content, encoding);
return bridge.writeSftp(sftpId, path, content);
}, []);
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer, encoding?: SftpFilenameEncoding) => {
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
return bridge.writeSftpBinary(sftpId, path, content, encoding);
return bridge.writeSftpBinary(sftpId, path, content);
}, []);
const writeSftpBinaryWithProgress = useCallback(
@@ -51,7 +51,6 @@ export const useSftpBackend = () => {
path: string,
content: ArrayBuffer,
transferId: string,
encoding?: SftpFilenameEncoding,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
@@ -63,7 +62,6 @@ export const useSftpBackend = () => {
path,
content,
transferId,
encoding,
onProgress,
onComplete,
onError,
@@ -72,34 +70,34 @@ export const useSftpBackend = () => {
[],
);
const mkdirSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
return bridge.mkdirSftp(sftpId, path, encoding);
return bridge.mkdirSftp(sftpId, path);
}, []);
const deleteSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
return bridge.deleteSftp(sftpId, path, encoding);
return bridge.deleteSftp(sftpId, path);
}, []);
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding) => {
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
return bridge.renameSftp(sftpId, oldPath, newPath, encoding);
return bridge.renameSftp(sftpId, oldPath, newPath);
}, []);
const statSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const statSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
return bridge.statSftp(sftpId, path, encoding);
return bridge.statSftp(sftpId, path);
}, []);
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding) => {
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
return bridge.chmodSftp(sftpId, path, mode, encoding);
return bridge.chmodSftp(sftpId, path, mode);
}, []);
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
@@ -170,12 +168,6 @@ export const useSftpBackend = () => {
return bridge.cancelTransfer(transferId);
}, []);
const cancelSftpUpload = useCallback(async (transferId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.cancelSftpUpload) return undefined;
return bridge.cancelSftpUpload(transferId);
}, []);
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTransferProgress) return undefined;
@@ -188,27 +180,12 @@ export const useSftpBackend = () => {
return bridge.selectApplication();
}, []);
const showSaveDialog = useCallback(async (
defaultPath: string,
filters?: Array<{ name: string; extensions: string[] }>
) => {
const bridge = netcattyBridge.get();
if (!bridge?.showSaveDialog) return null;
return bridge.showSaveDialog(defaultPath, filters);
}, []);
const selectDirectory = async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
};
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean; encoding?: SftpFilenameEncoding }
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
@@ -216,7 +193,9 @@ export const useSftpBackend = () => {
}
// Download the file to temp
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
if (bridge.registerTempFile) {
@@ -228,18 +207,25 @@ export const useSftpBackend = () => {
}
// Open with the selected application
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
await bridge.openWithApplication(tempPath, appPath);
console.log("[SFTPBackend] Application launched");
// Start file watching if enabled
let watchId: string | undefined;
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
console.warn("[SFTPBackend] Failed to start file watch:", err);
// Don't fail the operation if watching fails
}
} else {
console.log("[SFTPBackend] File watching not enabled or not available");
}
return { localTempPath: tempPath, watchId };
@@ -271,11 +257,9 @@ export const useSftpBackend = () => {
startStreamTransfer,
cancelTransfer,
cancelSftpUpload,
onTransferProgress,
selectApplication,
showSaveDialog,
selectDirectory,
downloadSftpToTempAndOpen,
};
};

View File

@@ -3,10 +3,10 @@
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
@@ -17,16 +17,15 @@ export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// ---------------------------------------------------------------------------
// Per-extension associations store
// ---------------------------------------------------------------------------
// Shared state and subscribers for cross-component synchronization
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Loading from storage:', stored);
if (stored) {
const migrated: FileAssociationsMap = {};
for (const [ext, value] of Object.entries(stored)) {
@@ -36,20 +35,29 @@ function loadFromStorage(): FileAssociationsMap {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
}
// Initialize from storage
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
// Verify it was saved
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Verification read from storage:', verify);
}
function updateAssociations(newAssociations: FileAssociationsMap) {
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
subscribers.forEach(callback => callback());
}
@@ -62,54 +70,15 @@ function getSnapshot() {
return snapshotRef;
}
// ---------------------------------------------------------------------------
// Default opener store (separate from per-extension associations)
// ---------------------------------------------------------------------------
const defaultOpenerSubscribers = new Set<() => void>();
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
};
function subscribeDefaultOpener(callback: () => void) {
defaultOpenerSubscribers.add(callback);
return () => defaultOpenerSubscribers.delete(callback);
}
function getDefaultOpenerSnapshot() {
return defaultOpenerSnapshot;
}
function updateDefaultOpener(entry: FileAssociationEntry | null) {
defaultOpenerSnapshot = { entry };
if (entry) {
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
} else {
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
}
defaultOpenerSubscribers.forEach(callback => callback());
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
updateDefaultOpener(
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
);
}
};
window.addEventListener('storage', handleStorage);
@@ -117,49 +86,23 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get the opener entry for a file based on its extension.
* Falls back to the default opener when no per-extension association exists.
* Get the opener entry for a file based on its extension
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
if (associations[ext]) return associations[ext];
// Fall back to default opener, but skip built-in editor for binary files
const fallback = defaultOpenerState.entry;
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
return null;
}
return fallback;
}, [associations, defaultOpenerState]);
/**
* Get the default (fallback) opener, if set.
*/
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
return defaultOpenerState.entry;
}, [defaultOpenerState]);
/**
* Set the default opener used when no per-extension association exists.
*/
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
updateDefaultOpener({ openerType, systemApp });
}, []);
/**
* Remove the default opener.
*/
const removeDefaultOpener = useCallback(() => {
updateDefaultOpener(null);
}, []);
return associations[ext] || null;
}, [associations]);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
updateAssociations({
...snapshotRef.associations,
[extension.toLowerCase()]: { openerType, systemApp },
@@ -176,14 +119,16 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get all per-extension associations as an array.
* Get all associations as an array
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
extension,
openerType: entry.openerType,
systemApp: entry.systemApp,
}));
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
return result;
}, [associations]);
/**
@@ -196,9 +141,6 @@ export function useSftpFileAssociations() {
return {
associations,
getOpenerForFile,
getDefaultOpener,
setDefaultOpener,
removeDefaultOpener,
setOpenerForExtension,
removeAssociation,
getAllAssociations,

View File

@@ -0,0 +1,184 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a boolean value to localStorage.
* Syncs across components in the same window via a custom event,
* and across windows via the native storage event.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists (defaults to false)
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredBoolean = (
storageKey: string,
fallback: boolean = false,
) => {
const [value, setValue] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(storageKey);
return stored ?? fallback;
});
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
return resolved;
});
}, [storageKey]);
useEffect(() => {
// Sync from other components in the same window
const handleCustom = (e: Event) => {
const { key, value: newValue } = (e as CustomEvent).detail;
if (key === storageKey) setValue(newValue);
};
// Sync from other windows
const handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
const stored = localStorageAdapter.readBoolean(storageKey);
setValue(stored ?? fallback);
}
};
window.addEventListener("stored-boolean-change", handleCustom);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("stored-boolean-change", handleCustom);
window.removeEventListener("storage", handleStorage);
};
}, [storageKey, fallback]);
return [value, setAndPersist] as const;
};

View File

@@ -1,29 +0,0 @@
import { useCallback, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for reading a number from localStorage with lazy persistence.
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
* on every state change — call `persist()` explicitly when ready (e.g. on
* mouseup after a drag). This avoids flooding localStorage during
* high-frequency updates like resize drags.
*/
export const useStoredNumber = (
storageKey: string,
fallback: number,
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
});
const persist = useCallback(
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
[storageKey],
);
return [value, setValue, persist] as const;
};

View File

@@ -1,28 +0,0 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a string value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists
* @param validate - Optional function to validate stored value; returns fallback if invalid
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
validate?: (value: string) => value is T,
) => {
const [value, setValue] = useState<T>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored === null) return fallback;
if (validate) return validate(stored) ? stored : fallback;
return stored as T;
});
useEffect(() => {
localStorageAdapter.writeString(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export type ViewMode = "grid" | "list" | "tree";
export type ViewMode = "grid" | "list";
const isViewMode = (value: string | null): value is ViewMode =>
value === "grid" || value === "list" || value === "tree";
value === "grid" || value === "list";
export const useStoredViewMode = (
storageKey: string,

View File

@@ -0,0 +1,68 @@
import { useCallback, useState } from "react";
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
export type SyncStatus = "idle" | "success" | "error";
export const useSyncState = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const resetSyncStatus = useCallback(() => {
setSyncStatus("idle");
}, []);
const verify = useCallback(async (token: string, gistId?: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
if (gistId) {
await loadFromGist(token, gistId);
}
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
const upload = useCallback(
async (
token: string,
gistId: string | undefined,
data: Parameters<typeof syncToGist>[2],
) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const newGistId = await syncToGist(token, gistId, data);
setSyncStatus("success");
return newGistId;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
},
[],
);
const download = useCallback(async (token: string, gistId: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const data = await loadFromGist(token, gistId);
setSyncStatus("success");
return data;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
};

View File

@@ -78,25 +78,19 @@ export const useTerminalBackend = () => {
bridge?.closeSession?.(sessionId);
}, []);
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
return bridge.setSessionEncoding(sessionId, encoding);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
return bridge.onSessionData(sessionId, cb);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);
@@ -128,28 +122,6 @@ export const useTerminalBackend = () => {
return bridge.getSessionPwd(sessionId);
}, []);
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionRemoteInfo) {
return { success: false, error: 'getSessionRemoteInfo unavailable' };
}
return bridge.getSessionRemoteInfo(sessionId);
}, []);
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionDistroInfo) {
return { success: false, error: 'getSessionDistroInfo unavailable' };
}
return bridge.getSessionDistroInfo(sessionId);
}, []);
const getServerStats = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
@@ -166,13 +138,9 @@ export const useTerminalBackend = () => {
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,

View File

@@ -1,72 +0,0 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTrayPanelBackend = () => {
const hideTrayPanel = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.hideTrayPanel?.();
}, []);
const openMainWindow = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.openMainWindow?.();
}, []);
const quitApp = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.quitApp?.();
}, []);
const jumpToSession = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
}, []);
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
const bridge = netcattyBridge.get();
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
}, []);
const onTrayPanelRefresh = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelRefresh?.(callback);
}, []);
const onTrayPanelMenuData = useCallback(
(
callback: (data: {
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
portForwardRules?: Array<{
id: string;
label: string;
type: "local" | "remote" | "dynamic";
localPort: number;
remoteHost?: string;
remotePort?: number;
status: "inactive" | "connecting" | "active" | "error";
hostId?: string;
}>;
}) => void,
) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelMenuData?.(callback);
},
[],
);
return {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
};
};

View File

@@ -1,47 +0,0 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export const useTreeExpandedState = (storageKey: string) => {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored) {
try {
const paths = JSON.parse(stored) as string[];
return new Set(paths);
} catch {
return new Set();
}
}
return new Set();
});
useEffect(() => {
const pathsArray = Array.from(expandedPaths);
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
}, [storageKey, expandedPaths]);
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const expandAll = (allPaths: string[]) => {
setExpandedPaths(new Set(allPaths));
};
const collapseAll = () => {
setExpandedPaths(new Set());
};
return {
expandedPaths,
togglePath,
expandAll,
collapseAll,
};
};

View File

@@ -1,26 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch.
// 8s gives electron-updater's startAutoCheck(5000) time to emit
// 'update-available' first. The `onUpdateAvailable` handler also cancels
// any pending startup timeout, so even on slow networks where the event
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
// Debug logging for update checks
const debugLog = (...args: unknown[]) => {
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
console.log('[UpdateCheck]', ...args);
}
};
export interface UpdateState {
isChecking: boolean;
@@ -29,12 +26,6 @@ export interface UpdateState {
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
// Auto-download state — driven by electron-updater IPC events
autoDownloadStatus: AutoDownloadStatus;
downloadPercent: number;
downloadError: string | null;
/** Manual check state — driven by user clicking "Check for Updates" */
manualCheckStatus: ManualCheckStatus;
}
export interface UseUpdateCheckResult {
@@ -42,9 +33,6 @@ export interface UseUpdateCheckResult {
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
startDownload: () => void;
isUpdateDemoMode: boolean;
}
/**
@@ -53,13 +41,7 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
const autoUpdateEnabled = options?.autoUpdateEnabled ??
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
export function useUpdateCheck(): UseUpdateCheckResult {
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
@@ -67,44 +49,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
latestRelease: null,
error: null,
lastCheckedAt: null,
autoDownloadStatus: 'idle',
downloadPercent: 0,
downloadError: null,
manualCheckStatus: 'idle',
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track current version in a ref to avoid stale closure in checkNow
const currentVersionRef = useRef(updateState.currentVersion);
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flag: true when we suppressed auto-download because the version was dismissed.
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
// in the progress/downloaded/error callbacks.
const dismissedAutoDownloadRef = useRef(false);
// Keep currentVersionRef in sync so checkNow always reads the latest version
useEffect(() => {
currentVersionRef.current = updateState.currentVersion;
}, [updateState.currentVersion]);
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
useEffect(() => {
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
}, [updateState.autoDownloadStatus]);
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
useEffect(() => {
return () => {
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
}
};
}, []);
// Get current app version
useEffect(() => {
@@ -122,145 +71,6 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
void loadVersion();
}, []);
// Hydrate auto-download status from the main process so windows opened
// after the download started (e.g. Settings) immediately reflect the
// current state instead of showing stale 'idle'.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getUpdateStatus?.().then((snapshot) => {
if (!snapshot || snapshot.status === 'idle') return;
// Respect dismissed versions: if the user dismissed this release,
// don't surface download progress/ready state in late-opening windows.
// Also set the dismissed ref so subsequent IPC events are suppressed.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (snapshot.version && snapshot.version === dismissedVersion) {
dismissedAutoDownloadRef.current = true;
return;
}
// 'available' means an update was found but auto-download is disabled.
// Surface the version info (hasUpdate + latestRelease) but keep
// autoDownloadStatus at 'idle' so the manual download path shows.
const isAvailableOnly = snapshot.status === 'available';
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
downloadError: isAvailableOnly ? null : snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
tagName: `v${snapshot.version}`,
name: `v${snapshot.version}`,
body: '',
htmlUrl: '',
publishedAt: new Date().toISOString(),
assets: [],
} : prev.latestRelease) : prev.latestRelease,
};
});
});
}, []);
// Subscribe to electron-updater auto-download IPC events.
// These fire automatically when autoDownload=true in the main process.
useEffect(() => {
const bridge = netcattyBridge.get();
// When electron-updater confirms no update in its feed, don't write
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
// fallback for an hour. Let performCheck write it on success so the
// GitHub check can still discover releases not yet in the updater feed.
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
// No-op for now — the GitHub fallback will handle lastCheckedAt.
});
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
// Cancel any pending startup GitHub API check — electron-updater is
// now authoritative and we don't want a duplicate toast.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Check if this version was dismissed by the user
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isDismissed = dismissedVersion === info.version;
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
// When auto-update is disabled, autoDownload=false in the main process
// so no download will start. Don't transition to 'downloading' or the
// UI will be stuck at 0%. Keep status idle and let the manual download
// link surface instead.
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
downloadError: shouldTrackDownload ? null : prev.downloadError,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
version: info.version,
tagName: `v${info.version}`,
name: `v${info.version}`,
body: info.releaseNotes || '',
htmlUrl: '',
publishedAt: info.releaseDate || new Date().toISOString(),
assets: [],
} : prev.latestRelease,
}));
});
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
// If we suppressed the download for a dismissed version, ignore progress.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: Math.round(p.percent),
}));
});
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
// If the download was for a dismissed version, don't transition to
// 'ready' — that would trigger the "Update ready" toast.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'ready',
downloadPercent: 100,
}));
});
const cleanupError = bridge?.onUpdateError?.((payload) => {
// If we suppressed the download for a dismissed version, ignore errors.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: payload.error,
}));
});
return () => {
cleanupNotAvailable?.();
cleanupAvailable?.();
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
@@ -309,16 +119,8 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Only advance last-check time and cache release on successful checks.
// Failed checks (result.error set, no latestRelease) must not update
// the timestamp — otherwise stale cached release data persists for an
// hour while the throttle prevents re-checking.
if (!result.error) {
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
if (result.latestRelease) {
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
}
}
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
@@ -354,135 +156,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
}
}, []);
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
// Prevent concurrent checks (performCheck owns isCheckingRef)
if (isCheckingRef.current) {
debugLog('checkNow: already checking, skipping');
return null;
}
// Cancel any pending startup auto-check to avoid racing with
// electron-updater's startAutoCheck — concurrent checkForUpdates()
// calls are rejected by electron-updater and would surface a false error.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Clear any pending "up-to-date" auto-reset timer
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
manualCheckResetTimeoutRef.current = null;
}
// Reset dismissed flag so a manual retry can surface download events again
dismissedAutoDownloadRef.current = false;
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
setUpdateState((prev) => {
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
if (prev.autoDownloadStatus === 'error') {
autoDownloadStatusRef.current = 'idle';
}
return {
...prev,
manualCheckStatus: 'checking',
error: null,
// P2: reset download error state so auto-download can retry on next available update
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
};
});
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
// Dev/invalid build — can't determine update status, reset to idle
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'idle',
}));
return null;
}
// Delegate to performCheck (GitHub API) — completely independent of
// electron-updater's startAutoCheck() in the main process.
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
const result = await performCheck(effectiveVersion);
// Determine manual check status. performCheck already suppressed dismissed
// versions in state (hasUpdate=false), so we must respect that here too —
// otherwise a dismissed release would be reported as 'available' and could
// trigger a background download via checkForUpdate below.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isAvailable = result !== null && !result.error && result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
const nextStatus: ManualCheckStatus =
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
setUpdateState((prev) => ({
...prev,
manualCheckStatus: nextStatus,
}));
if (nextStatus === 'up-to-date') {
// Auto-reset "up-to-date" badge back to idle after 5s
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
// Trigger electron-updater as a fallback. This covers two cases:
// 1. 'available': GitHub found an update but electron-updater hasn't
// started a download yet — kick it off.
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
// electron-updater feed may still be reachable. Without this,
// environments where api.github.com is blocked would never attempt
// the auto-download path.
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
if (res?.error && res?.supported !== false) {
// Surface actual download-feed errors; unsupported platforms
// (res.supported === false) should keep autoDownloadStatus at
// 'idle' so the manual download link shows.
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error,
}));
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && res?.available) {
// GitHub API failed but electron-updater found an update.
// Respect dismissed versions before surfacing.
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (res.version && res.version === dismissed) {
// User dismissed this version — don't re-surface
} else {
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'available',
hasUpdate: true,
error: null,
}));
}
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'up-to-date',
}));
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
}
}).catch(() => {
// Bridge unavailable — ignore; the manual download link remains visible
});
}
return result;
}, [performCheck]);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
@@ -511,50 +189,6 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
const installUpdate = useCallback(() => {
netcattyBridge.get()?.installUpdate?.();
}, []);
const startDownload = useCallback(async () => {
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
const bridge = netcattyBridge.get();
try {
const checkResult = await bridge?.checkForUpdate?.();
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
if (checkResult.supported === false) {
openReleasePage();
return;
}
if (checkResult.available === false) {
openReleasePage();
return;
}
} catch {
return;
}
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: 0,
downloadError: null,
}));
void bridge?.downloadUpdate?.().then((res) => {
if (res && !res.success) {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error || 'Download failed',
}));
}
}).catch(() => {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: 'Download failed',
}));
});
}, [openReleasePage]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -585,12 +219,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
@@ -599,38 +233,8 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
return;
}
// Hydrate cached release info so update status is visible across windows.
// When auto-update is disabled, hydrate release data (for the Settings UI)
// but don't set hasUpdate (which would trigger the toast in App.tsx).
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
if (lastCheck) {
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
const release = JSON.parse(cachedRelease) as ReleaseInfo;
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
const showUpdate = isNewer && release.version !== dismissedVersion;
setUpdateState((prev) => ({
...prev,
latestRelease: prev.latestRelease ?? release,
hasUpdate: prev.hasUpdate || showUpdate,
lastCheckedAt: lastCheck,
}));
} catch {
// Ignore corrupted cache
}
}
}
// Respect auto-update toggle — skip automatic check when disabled.
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
// autoUpdateEnabled dependency) can re-trigger this effect.
if (!autoUpdateEnabled) {
return;
}
// Check if we've checked recently
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
@@ -640,43 +244,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(async () => {
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stillEnabled === 'false') {
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
return;
}
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
debugLog('Skipping startup check — auto-download already active');
return;
}
// If the main process check is still in flight, reschedule the
// fallback instead of permanently skipping it — the auto-check may
// fail silently (check-phase errors aren't broadcast to the renderer).
try {
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
if (snapshot?.isChecking) {
debugLog('Main process check still in flight — rescheduling fallback');
startupCheckTimeoutRef.current = setTimeout(async () => {
if (autoDownloadStatusRef.current !== 'idle') return;
// Re-check if the main process check is still running to avoid
// duplicate notifications on very slow networks.
try {
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
} catch { /* fall through */ }
debugLog('=== Rescheduled fallback check triggered ===');
void performCheck(updateState.currentVersion);
}, 5000);
return;
}
} catch {
// Bridge unavailable — fall through to GitHub check
}
startupCheckTimeoutRef.current = setTimeout(() => {
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
@@ -686,15 +254,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
}, [updateState.currentVersion, performCheck]);
return {
updateState,
checkNow,
dismissUpdate,
openReleasePage,
installUpdate,
startDownload,
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
};
}

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
GroupConfig,
Host,
Identity,
KeyCategory,
KnownHost,
ManagedSource,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -18,29 +16,17 @@ import {
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_IDENTITIES,
STORAGE_KEY_KEYS,
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptGroupConfigs,
decryptHosts,
decryptIdentities,
decryptKeys,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
@@ -48,9 +34,7 @@ type ExportableVaultData = {
identities?: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
};
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
@@ -102,7 +86,6 @@ const safeParse = <T,>(value: string | null): T | null => {
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
@@ -112,52 +95,21 @@ export const useVaultState = () => {
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
// persists if its version still matches the latest.
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
// its sequence still matches, preventing stale decrypts from overwriting
// newer data when multiple events arrive in quick succession.
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
}, []);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
@@ -180,20 +132,6 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
}, []);
const updateManagedSources = useCallback((data: ManagedSource[]) => {
setManagedSources(data);
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
const ver = ++groupConfigsWriteVersion.current;
encryptGroupConfigs(data).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
@@ -202,8 +140,6 @@ export const useVaultState = () => {
updateSnippetPackages([]);
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
updateGroupConfigs([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
@@ -213,8 +149,6 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
]);
const addShellHistoryEntry = useCallback(
@@ -327,11 +261,7 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
return updated;
});
@@ -339,138 +269,76 @@ export const useVaultState = () => {
}, []);
useEffect(() => {
const init = async () => {
try {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
if (savedHosts) {
const sanitized = savedHosts.map(sanitizeHost);
setHosts(sanitized);
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
} else {
updateHosts(INITIAL_HOSTS);
}
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
} finally {
setIsInitialized(true);
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
};
init();
setKeys(migratedKeys);
// Persist migrated keys
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
if (savedIdentities) setIdentities(savedIdentities);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -483,17 +351,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
// Bump write version to invalidate any in-flight encrypt from this
// window — the cross-window data is newer and must not be overwritten.
++hostsWriteVersion.current;
const seq = ++hostsReadSeq.current;
const writeAtStart = hostsWriteVersion.current;
decryptHosts(next).then((dec) => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
});
setHosts(next.map(sanitizeHost));
return;
}
@@ -506,25 +364,13 @@ export const useVaultState = () => {
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
++keysWriteVersion.current;
const seq = ++keysReadSeq.current;
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
});
setKeys(migratedKeys);
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
++identitiesWriteVersion.current;
const seq = ++identitiesReadSeq.current;
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
});
setIdentities(next);
return;
}
@@ -561,25 +407,6 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs(next);
return;
}
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
setManagedSources(next);
return;
}
if (key === STORAGE_KEY_GROUP_CONFIGS) {
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
++groupConfigsWriteVersion.current;
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
});
return;
}
};
@@ -587,31 +414,13 @@ export const useVaultState = () => {
return () => window.removeEventListener("storage", handleStorage);
}, []);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
return next;
});
}, []);
@@ -623,11 +432,9 @@ export const useVaultState = () => {
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
[hosts, keys, identities, snippets, customGroups, knownHosts],
);
const importData = useCallback(
@@ -637,9 +444,7 @@ export const useVaultState = () => {
if (payload.identities) updateIdentities(payload.identities);
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
},
[
updateHosts,
@@ -647,9 +452,7 @@ export const useVaultState = () => {
updateIdentities,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
updateKnownHosts,
updateGroupConfigs,
],
);
@@ -662,7 +465,6 @@ export const useVaultState = () => {
);
return {
isInitialized,
hosts,
keys,
identities,
@@ -672,8 +474,6 @@ export const useVaultState = () => {
knownHosts,
shellHistory,
connectionLogs,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
updateIdentities,
@@ -681,8 +481,6 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,
@@ -691,7 +489,6 @@ export const useVaultState = () => {
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
exportData,
importDataFromString,

View File

@@ -1,360 +0,0 @@
/**
* Sync Payload Builders — Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
*
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
* must use these helpers to guarantee every field is included and no data is
* silently dropped.
*/
import type {
GroupConfig,
Host,
Identity,
KnownHost,
PortForwardingRule,
SftpBookmark,
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
/** All vault-owned data that participates in cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
/**
* Returns true when the payload contains any meaningful user data worth
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
/** Called after synced settings have been written to localStorage. */
onSettingsApplied?: () => void;
}
// ---------------------------------------------------------------------------
// Settings sync helpers
// ---------------------------------------------------------------------------
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
/**
* Collect all syncable settings from localStorage.
*/
export function collectSyncableSettings(): SyncPayload['settings'] {
const settings: SyncPayload['settings'] = {};
// Theme & Appearance
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
if (lightUi) settings.lightUiThemeId = lightUi;
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
if (darkUi) settings.darkUiThemeId = darkUi;
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accent) settings.customAccent = accent;
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
if (uiFont) settings.uiFontFamilyId = uiFont;
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
if (lang) settings.uiLanguage = lang;
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
if (css != null) settings.customCSS = css;
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (termSettingsRaw) {
try {
const full = JSON.parse(termSettingsRaw);
const subset: Record<string, unknown> = {};
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in full) subset[key] = full[key];
}
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
if (customThemesRaw) {
try {
const parsed = JSON.parse(customThemesRaw);
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
} catch { /* ignore */ }
}
// Keyboard
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (kb) {
try {
settings.customKeyBindings = JSON.parse(kb);
} catch { /* ignore */ }
}
// Editor
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
// SFTP
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
if (showRecent != null) settings.showRecentHosts = showRecent;
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
return Object.keys(settings).length > 0 ? settings : undefined;
}
/**
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
// Terminal settings — merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
let existing: Record<string, unknown> = {};
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (raw) {
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
// Custom terminal themes
if (settings.customTerminalThemes != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
}
// Keyboard
if (settings.customKeyBindings != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
}
// Editor
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
// SFTP
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
/**
* Build a complete `SyncPayload` from local data.
*
* Port-forwarding rules are optional because they are managed by a separate
* state hook (`usePortForwardingState`). Callers should strip transient
* runtime fields (status, error, lastUsedAt) before passing them in.
*/
export function buildSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
vaultImport.groupConfigs = payload.groupConfigs;
}
importers.importVaultData(JSON.stringify(vaultImport));
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load Diff

309
components/AuthDialog.tsx Normal file
View File

@@ -0,0 +1,309 @@
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
import React, { useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
interface AuthDialogProps {
host: Host;
keys: SSHKey[];
onSubmit: (auth: {
username: string;
authMethod: "password" | "key";
password?: string;
keyId?: string;
saveCredentials: boolean;
}) => void;
onCancel: () => void;
}
const AuthDialog: React.FC<AuthDialogProps> = ({
host,
keys,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [username, setUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
const handleSubmit = () => {
onSubmit({
username,
authMethod,
password: authMethod === "password" ? password : undefined,
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
saveCredentials,
});
};
const isValid =
username.trim() &&
((authMethod === "password" && password.trim()) ||
(authMethod === "key" && selectedKeyId));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-border/50">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-12 w-12"
/>
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
</div>
</div>
{/* Progress indicator */}
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<User size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div
className={cn(
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
username.trim()
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{authMethod === "password" ? (
<Lock size={14} />
) : (
<Key size={14} />
)}
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{">_"}
</div>
</div>
</div>
{/* Auth method tabs */}
<div className="px-6">
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "password"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("password")}
>
<Lock size={14} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "key"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("key")}
>
<Key size={14} />
{t("terminal.auth.sshKey")}
</button>
</div>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* Username field (shown when no username on host) */}
{!host.username && (
<div className="space-y-2">
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
<Input
id="auth-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("terminal.auth.username.placeholder")}
autoFocus
/>
</div>
)}
{/* Password field */}
{authMethod === "password" && (
<div className="space-y-2">
<Label htmlFor="auth-password">
{t("terminal.auth.passwordLabel")}
</Label>
<div className="relative">
<Input
id="auth-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
className="pr-10"
autoFocus={!!host.username}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) {
handleSubmit();
}
}}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
)}
{/* Key selection */}
{authMethod === "key" && (
<div className="space-y-2">
<Label>{t("terminal.auth.selectKey")}</Label>
{keys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
{t("terminal.auth.noKeysHint")}
</div>
) : (
<div className="space-y-2">
{keys
.filter((k) => k.category === "key")
.slice(0, 5)
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKeyId === key.id
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50",
)}
onClick={() => setSelectedKeyId(key.id)}
>
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"bg-primary/20 text-primary",
)}
>
<Key size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{key.label}
</div>
<div className="text-xs text-muted-foreground">
{t("auth.keyType", { type: key.type })}
</div>
</div>
</button>
))}
{keys.filter((k) => k.category === "key").length > 5 && (
<Popover
open={isKeySelectOpen}
onOpenChange={setIsKeySelectOpen}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
{t("auth.showAllKeys")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ScrollArea className="h-64">
<div className="p-2 space-y-1">
{keys
.filter((k) => k.category === "key")
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
selectedKeyId === key.id
? "bg-primary/10"
: "hover:bg-secondary",
)}
onClick={() => {
setSelectedKeyId(key.id);
setIsKeySelectOpen(false);
}}
>
<Key size={14} className="text-primary" />
<span className="text-sm truncate">
{key.label}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{key.type}
</span>
</button>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={handleSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => {
setSaveCredentials(false);
handleSubmit();
}}
disabled={!isValid}
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
};
export default AuthDialog;

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,9 @@ import {
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react";
import React, { memo, useCallback, useMemo } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
@@ -64,7 +63,6 @@ interface LogItemProps {
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
return (
<div
@@ -94,14 +92,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>
@@ -149,11 +147,7 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
onOpenLogView,
}) => {
const { t } = useI18n();
const INITIAL_RENDER_LIMIT = 30;
const LOAD_MORE_COUNT = 30;
// Track how many items to show
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
const RENDER_LIMIT = 100;
// Sort logs by newest first
const filteredLogs = useMemo(() => {
@@ -161,14 +155,10 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
}, [logs]);
const displayedLogs = useMemo(() => {
return filteredLogs.slice(0, renderLimit);
}, [filteredLogs, renderLimit]);
return filteredLogs.slice(0, RENDER_LIMIT);
}, [filteredLogs]);
const hasMore = filteredLogs.length > renderLimit;
const handleLoadMore = useCallback(() => {
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
}, []);
const hasMore = filteredLogs.length > RENDER_LIMIT;
const handleToggleSaved = useCallback(
(id: string) => onToggleSaved(id),
@@ -230,12 +220,9 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
<>
{renderedItems}
{hasMore && (
<button
onClick={handleLoadMore}
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
>
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
</button>
<div className="text-center py-4 text-sm text-muted-foreground">
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
</div>
)}
</>
)}

View File

@@ -1,143 +0,0 @@
import { Search } from 'lucide-react';
import React, { useMemo, useState, useEffect } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
interface CreateWorkspaceDialogProps {
isOpen: boolean;
onClose: () => void;
hosts: Host[];
onCreate: (name: string, selectedHosts: Host[]) => void;
}
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
isOpen,
onClose,
hosts,
onCreate,
}) => {
const { t } = useI18n();
const [name, setName] = useState('');
const [search, setSearch] = useState('');
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const filteredHosts = useMemo(() => {
if (!search.trim()) return hosts;
const term = search.toLowerCase();
return hosts.filter(h =>
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term) ||
(h.group || '').toLowerCase().includes(term)
);
}, [hosts, search]);
const toggleHost = (hostId: string) => {
setSelectedHostIds(prev => {
const next = new Set(prev);
if (next.has(hostId)) {
next.delete(hostId);
} else {
next.add(hostId);
}
return next;
});
};
const handleCreate = () => {
const selected = hosts.filter(h => selectedHostIds.has(h.id));
onCreate(name, selected);
onClose();
};
useEffect(() => {
if (isOpen) {
setName('');
setSearch('');
setSelectedHostIds(new Set());
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="border rounded-md flex-1 min-h-[200px]">
<ScrollArea className="h-full max-h-[300px]">
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', { defaultValue: 'No hosts found' })}
</div>
) : (
filteredHosts.map(host => {
const isSelected = selectedHostIds.has(host.id);
return (
<div
key={host.id}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
onClick={() => toggleHost(host.id)}
>
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
</div>
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', { defaultValue: 'Create' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,6 @@
import { Server, Usb } from "lucide-react";
import { Server } from "lucide-react";
import React, { memo } from "react";
import { getEffectiveHostDistro } from "../domain/host";
import { normalizeDistroId } from "../domain/host";
import { cn } from "../lib/utils";
import { Host } from "../types";
@@ -17,21 +17,6 @@ export const DISTRO_LOGOS: Record<string, string> = {
redhat: "/distro/redhat.svg",
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
// Network device vendors — auto-detected from the SSH server
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
cisco: "/distro/cisco.svg",
juniper: "/distro/juniper.svg",
huawei: "/distro/huawei.svg",
hpe: "/distro/hpe.svg",
mikrotik: "/distro/mikrotik.svg",
fortinet: "/distro/fortinet.svg",
paloalto: "/distro/paloalto.svg",
zyxel: "/distro/zyxel.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -47,20 +32,6 @@ export const DISTRO_COLORS: Record<string, string> = {
redhat: "bg-[#EE0000]",
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
// Network device vendor brand colors
cisco: "bg-[#1BA0D7]",
juniper: "bg-[#0A6EB4]",
huawei: "bg-[#CF0A2C]",
hpe: "bg-[#01A982]",
mikrotik: "bg-[#293239]",
fortinet: "bg-[#EE3124]",
paloalto: "bg-[#FA582D]",
zyxel: "bg-[#00497A]",
default: "bg-slate-600",
};
@@ -77,15 +48,16 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
className,
size = "md",
}) => {
const distro = getEffectiveHostDistro(host);
const distro =
normalizeDistroId(host.distro) || (host.distro || "").toLowerCase();
const logo = DISTRO_LOGOS[distro];
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
// Size variants - all use rounded corners for consistency
const sizeClasses = {
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
sm: "h-6 w-6 rounded-md",
md: "h-11 w-11 rounded-xl",
lg: "h-14 w-14 rounded-xl",
};
const iconSizes = {
@@ -97,34 +69,19 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const containerClass = sizeClasses[size];
const iconSize = iconSizes[size];
// Show USB icon for serial hosts
if (host.protocol === 'serial') {
return (
<div
className={cn(
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
<Usb className={iconSize} />
</div>
);
}
if (logo && !errored) {
return (
<div
className={cn(
containerClass,
"flex items-center justify-center overflow-hidden",
"flex items-center justify-center border border-border/40 overflow-hidden",
bg,
className,
)}
>
<img
src={logo}
alt={distro || host.os}
alt={host.distro || host.os}
className={cn("object-contain invert brightness-0", iconSize)}
onError={() => setErrored(true)}
/>

View File

@@ -45,6 +45,7 @@ export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
try {
const result = await onSelectSystemApp();
if (result) {
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
onSelect('system-app', rememberChoice, result);
onClose();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
import React,{ useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { GroupNode } from '../types';
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
interface GroupTreeItemProps {
node: GroupNode;
depth: number;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onSelectGroup: (path: string) => void;
selectedGroup: string | null;
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
node,
depth,
expandedPaths,
onToggle,
onSelectGroup,
selectedGroup,
onEditGroup,
onNewHost,
onNewSubfolder,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 12 + 12}px`;
const isSelected = selectedGroup === node.path;
const childNodes = useMemo(() => {
return node.children
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
: [];
}, [node.children]);
return (
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
style={{ paddingLeft }}
onClick={() => onSelectGroup(node.path)}
>
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{hasChildren && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={12} />
</div>
)}
</div>
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</div>
<span className="truncate flex-1">{node.name}</span>
{node.hosts.length > 0 && (
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{hasChildren && (
<CollapsibleContent>
{childNodes.map((child) => (
<GroupTreeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
onSelectGroup={onSelectGroup}
selectedGroup={selectedGroup}
onEditGroup={onEditGroup}
onNewHost={onNewHost}
onNewSubfolder={onNewSubfolder}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
);
};

File diff suppressed because it is too large Load Diff

335
components/HostForm.tsx Executable file
View File

@@ -0,0 +1,335 @@
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel,
}) => {
const { t } = useI18n();
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: "",
hostname: "",
port: 22,
username: "root",
tags: [],
os: "linux",
group: "General",
identityFileId: "",
},
);
const [authType, setAuthType] = useState<"password" | "key">(
initialData?.identityFileId ? "key" : "password",
);
const [tagInput, setTagInput] = useState("");
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
}));
};
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === "password") {
setFormData((prev) => ({ ...prev, identityFileId: "" }));
} else if (
authType === "key" &&
!formData.identityFileId &&
availableKeys.length > 0
) {
// Default to first key if none selected
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || "General",
identityFileId:
authType === "key" ? formData.identityFileId : undefined,
createdAt: initialData?.createdAt || Date.now(),
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
<Input
id="label"
placeholder={t("hostForm.placeholder.label")}
value={formData.label}
onChange={(e) =>
setFormData({ ...formData, label: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
<Input
id="hostname"
placeholder={t("hostForm.placeholder.hostname")}
value={formData.hostname}
onChange={(e) =>
setFormData({ ...formData, hostname: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) })
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
<Select
value={formData.os}
onValueChange={(val: "linux" | "windows" | "macos") =>
setFormData({ ...formData, os: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
<Input
id="group"
placeholder={t("hostForm.placeholder.group")}
value={formData.group}
onChange={(e) =>
setFormData({ ...formData, group: e.target.value })
}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
<div className="flex gap-2">
<Input
id="tags"
placeholder={t("hostForm.placeholder.addTag")}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
<Plus size={16} />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{formData.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div className="space-y-3 pt-2">
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "password"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("password")}
>
<Lock size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "key"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("key")}
>
<Key size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
</div>
</div>
{authType === "key" && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select
value={formData.identityFileId || ""}
onValueChange={(val) =>
setFormData({ ...formData, identityFileId: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.label} ({key.type})
</SelectItem>
))}
{availableKeys.length === 0 && (
<SelectItem value="none" disabled>
{t("hostForm.auth.noKeys")}
</SelectItem>
)}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
{t("hostForm.auth.noKeysHint")}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;

View File

@@ -1,603 +0,0 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { sanitizeHost } from '../domain/host';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
interface HostTreeViewProps {
groupTree: GroupNode[];
hosts: Host[];
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths?: Set<string>;
onTogglePath?: (path: string) => void;
onExpandAll?: (paths: string[]) => void;
onCollapseAll?: () => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
interface TreeNodeProps {
node: GroupNode;
depth: number;
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
sortMode,
expandedPaths,
onToggle,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => {
if (!node.children) return [];
const nodes = Object.values(node.children) as unknown as GroupNode[];
return nodes.sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [node.children, sortMode]);
const sortedHosts = useMemo(() => {
return [...node.hosts].sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [node.hosts, sortMode]);
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{(hasChildren || node.hosts.length > 0) && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={14} />
</div>
)}
</div>
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
</div>
<span className="truncate flex-1 font-semibold">{node.name}</span>
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
<FileSymlink size={10} />
Managed
</span>
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{hostsCountInNode}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
</button>
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(node.path)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<CollapsibleContent>
{/* Child Groups */}
{childNodes.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={onToggle}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={depth + 1}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
</CollapsibleContent>
</Collapsible>
</div>
);
};
interface HostTreeItemProps {
host: Host;
depth: number;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const isTelnet = host.protocol === 'telnet';
const displayUsername = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim() || '')
: (host.username?.trim() || '');
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => {
if (isMultiSelectMode && toggleHostSelection) {
toggleHostSelection(host.id);
} else {
onConnect(safeHost);
}
}}
>
{isMultiSelectMode && (
<div className="mr-2 flex-shrink-0" onClick={(e) => {
e.stopPropagation();
toggleHostSelection?.(host.id);
}}>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{host.protocol && host.protocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{host.protocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={13} />
</button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupTree,
hosts,
sortMode = 'az',
expandedPaths: externalExpandedPaths,
onTogglePath: externalOnTogglePath,
onExpandAll: externalOnExpandAll,
onCollapseAll: externalOnCollapseAll,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
const togglePath = externalOnTogglePath || localTreeState.togglePath;
const expandAll = externalOnExpandAll || localTreeState.expandAll;
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
// Get all possible group paths for expand/collapse all functionality
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: GroupNode[]) => {
nodeList.forEach(node => {
paths.push(node.path);
if (node.children) {
traverse(Object.values(node.children) as GroupNode[]);
}
});
};
traverse(nodes);
return paths;
};
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
const handleExpandAll = () => {
expandAll(allGroupPaths);
};
const handleCollapseAll = () => {
collapseAll();
};
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
const ungroupedHosts = useMemo(() => {
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
return hosts_without_group.sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [hosts, sortMode]);
// Sort group tree based on sort mode
const sortedGroupTree = useMemo(() => {
return [...groupTree].sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
<Button
variant="ghost"
size="sm"
onClick={handleExpandAll}
className="h-7 px-2 text-xs"
>
<Expand size={12} className="mr-1" />
{t("vault.tree.expandAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCollapseAll}
className="h-7 px-2 text-xs"
>
<Minimize2 size={12} className="mr-1" />
{t("vault.tree.collapseAll")}
</Button>
</div>
)}
{/* Group tree */}
{sortedGroupTree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={togglePath}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
{/* Ungrouped hosts at root level */}
{ungroupedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={0}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Empty state */}
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Server size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-sm">{t("vault.hosts.empty")}</p>
</div>
)}
</div>
);
};

411
components/KeyManager.tsx Executable file
View File

@@ -0,0 +1,411 @@
import {
Key,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Shield,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { KeyType } from "../domain/models";
import { cn } from "../lib/utils";
import { SSHKey } from "../types";
import { Button } from "./ui/button";
import { Card, CardDescription, CardTitle } from "./ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const { t } = useI18n();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const handleGenerate = () => {
// Simulate Key Generation
const mockKey =
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as KeyType) || "RSA",
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
source: draftKey.source || (generateMode ? "generated" : "imported"),
category: draftKey.category || "key",
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode("edit");
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode("new");
setGenerateMode(isGenerate);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: isGenerate
? "Click generate to create a new key pair..."
: "",
publicKey: "",
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter((k) => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || "").toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return "Generated By netcatty";
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
draftKey.label || "netcatty"
)
.replace(/\s+/g, "")
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === "list" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openPanelNew(true)}
>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">
Keys
</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your keys
</h3>
<p className="text-sm text-center max-w-sm">
Import or generate SSH keys for secure authentication.
</p>
</div>
)}
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group cursor-pointer soft-card elevate rounded-xl",
viewMode === "grid"
? "h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">
{key.label}
</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
SHA256:{key.id.substring(0, 16)}...
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{panelMode === "new"
? t("keychain.keyDialog.newTitle")
: t("keychain.keyDialog.editTitle")}
</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === "new"
? t("keychain.keyDialog.newDesc")
: t("keychain.keyDialog.editDesc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("keychain.field.label")}</Label>
<Input
value={draftKey.label}
onChange={(e) =>
setDraftKey({ ...draftKey, label: e.target.value })
}
placeholder={t("keychain.field.labelPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("keychain.field.privateKeyRequired")}</Label>
<Textarea
value={draftKey.privateKey}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleGenerate}
>
{t("keychain.generate.generate")}
</Button>
)}
</div>
<div className="space-y-2">
<Label>{t("keychain.field.publicKey")}</Label>
<Textarea
value={derivedPublicKey}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("terminal.auth.certificate")}{" "}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{t("common.optional")}
</span>
</Label>
<Textarea
placeholder={t("keychain.field.certificatePlaceholder")}
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">
{t("keychain.import.dropHint")}
</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || t("keychain.import.importedKeyLabel"),
privateKey:
draftKey.privateKey ||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
});
}}
>
{t("keychain.import.importFromFile")}
</Button>
</div>
<DialogFooter>
{panelMode === "edit" && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
{t("common.delete")}
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(false)}
>
{t("common.cancel")}
</Button>
<Button type="submit">
{panelMode === "new"
? t("keychain.import.saveKey")
: t("keychain.keyDialog.updateKey")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;

View File

@@ -1,227 +0,0 @@
/**
* Keyboard Interactive Authentication Modal
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface KeyboardInteractivePrompt {
prompt: string;
echo: boolean;
}
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
const updated = [...prev];
updated[index] = value;
return updated;
});
}, []);
const toggleShowPassword = useCallback((index: number) => {
setShowPasswords((prev) => {
const updated = [...prev];
updated[index] = !updated[index];
return updated;
});
}, []);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting]
);
if (!request) return null;
const title = request.name?.trim() || t("keyboard.interactive.title");
const description =
request.instructions?.trim() ||
(request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc"));
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
return (
<div key={index} className="space-y-2">
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
</Label>
<div className="relative">
<Input
id={`ki-prompt-${index}`}
type={isPassword && !showPassword ? "password" : "text"}
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
/>
{isPassword && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("keyboard.interactive.verifying")}
</>
) : (
t("keyboard.interactive.submit")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default KeyboardInteractiveModal;

View File

@@ -23,7 +23,6 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -69,7 +68,6 @@ interface KeychainManagerProps {
identities?: Identity[];
hosts?: Host[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
onUpdate: (key: SSHKey) => void;
onDelete: (id: string) => void;
@@ -85,7 +83,6 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
identities = [],
hosts = [],
customGroups = [],
managedSources = [],
onSave,
onUpdate,
onDelete,
@@ -450,6 +447,11 @@ echo $3 >> "$FILE"`);
[onDeleteIdentity, panel, closePanel],
);
// Copy to clipboard
const _copyToClipboard = useCallback((_text: string) => {
navigator.clipboard.writeText(_text);
}, []);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.certificate) return <BadgeCheck size={16} />;
@@ -501,6 +503,46 @@ echo $3 >> "$FILE"`);
[],
);
// Handle drag and drop
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
setDraftKey((prev) => ({
...prev,
privateKey: content,
label: prev.label || label,
type: detectedType,
}));
}
};
reader.readAsText(file);
}, []);
const _handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
},
[],
);
return (
<div className="h-full flex relative">
{/* Hidden file input */}
@@ -515,12 +557,12 @@ echo $3 >> "$FILE"`);
{/* Main Content */}
<div
className={cn(
"flex-1 flex flex-col min-h-0 transition-all duration-200",
"flex-1 overflow-y-auto transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}
@@ -621,7 +663,7 @@ echo $3 >> "$FILE"`);
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-48" align="start" alignToParent>
<DropdownContent className="w-44" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
@@ -684,10 +726,8 @@ echo $3 >> "$FILE"`);
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
{/* Keys Section */}
<div className="space-y-3 p-3">
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("keychain.section.keys")}
@@ -819,7 +859,6 @@ echo $3 >> "$FILE"`);
</div>
</div>
)}
</div>
</div>
{/* Slide-out Panel */}
@@ -874,8 +913,6 @@ echo $3 >> "$FILE"`);
<ImportKeyPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
onImport={handleImport}
/>
)}
@@ -1074,8 +1111,6 @@ echo $3 >> "$FILE"`);
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
});
// Check result - code 0, null, or undefined with no stderr is success
@@ -1247,7 +1282,6 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
/>

View File

@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const RENDER_LIMIT = 100; // Limit rendered items for performance
// Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async (silent = false) => {
const handleScanSystem = useCallback(async () => {
setIsScanning(true);
try {
const content = await readKnownHosts();
if (content === undefined) {
if (!silent) toast.error(
toast.error(
t("knownHosts.toast.scanUnavailable"),
t("vault.nav.knownHosts"),
);
return;
}
if (!content) {
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
return;
}
const parsed = parseKnownHostsFile(content);
if (parsed.length === 0) {
if (!silent) toast.info(
toast.info(
t("knownHosts.toast.scanNoEntries"),
t("vault.nav.knownHosts"),
);
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
if (newHosts.length > 0) {
onImportFromFile(newHosts);
if (!silent) toast.success(
toast.success(
t("knownHosts.toast.scanImported", { count: newHosts.length }),
t("vault.nav.knownHosts"),
);
} else {
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
}
} catch (err) {
logger.error("Failed to scan system known_hosts:", err);
if (!silent) toast.error(
toast.error(
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
t("vault.nav.knownHosts"),
);
@@ -307,12 +307,13 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
}
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
// Auto-scan on first mount
useEffect(() => {
if (!hasScannedRef.current) {
hasScannedRef.current = true;
// Delay scan slightly to not block initial render
const timer = setTimeout(() => {
handleScanSystem(true);
handleScanSystem();
}, 100);
return () => clearTimeout(timer);
}
@@ -514,7 +515,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
variant="ghost"
size="sm"
className="h-9 px-3 text-xs"
onClick={() => handleScanSystem()}
onClick={handleScanSystem}
disabled={isScanning}
>
<RefreshCw
@@ -571,7 +572,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => handleScanSystem()}
onClick={handleScanSystem}
disabled={isScanning}
>
<RefreshCw

View File

@@ -2,13 +2,12 @@ import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { FileText, Download, Palette, X } from "lucide-react";
import { FileText, Palette, X } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, TerminalTheme } from "../types";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { Button } from "./ui/button";
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
@@ -35,36 +34,14 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const fitAddonRef = useRef<FitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const explicitThemeId = useMemo(() => {
if (!log.themeId) return undefined;
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|| customThemes.some((theme) => theme.id === log.themeId);
return exists ? log.themeId : undefined;
}, [customThemes, log.themeId]);
useEffect(() => {
if (log.themeId && !explicitThemeId) {
onUpdateLog(log.id, { themeId: undefined });
}
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (previewTheme) {
return previewTheme;
}
if (explicitThemeId) {
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|| customThemes.find(t => t.id === explicitThemeId)
|| defaultTerminalTheme;
if (log.themeId) {
return TERMINAL_THEMES.find(t => t.id === log.themeId) || defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
}, [log.themeId, defaultTerminalTheme]);
const currentFontSize = log.fontSize ?? defaultFontSize;
@@ -85,41 +62,11 @@ const LogViewComponent: React.FC<LogViewProps> = ({
onUpdateLog(log.id, { themeId });
}, [log.id, onUpdateLog]);
useEffect(() => {
if (!themeModalOpen) {
setPreviewTheme(null);
}
}, [themeModalOpen]);
// Handle font size change
const handleFontSizeChange = useCallback((fontSize: number) => {
onUpdateLog(log.id, { fontSize });
}, [log.id, onUpdateLog]);
// Handle export
const handleExport = useCallback(async () => {
if (!log.terminalData || isExporting) return;
setIsExporting(true);
try {
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
const bridge = netcattyBridge.get();
if (bridge?.exportSessionLog) {
await bridge.exportSessionLog({
terminalData: log.terminalData,
hostLabel: log.hostLabel,
hostname: log.hostname,
startTime: log.startTime,
format: 'txt',
});
}
} catch (err) {
console.error('Failed to export session log:', err);
} finally {
setIsExporting(false);
}
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
// Initialize terminal
useEffect(() => {
if (!containerRef.current || !isVisible) return;
@@ -269,21 +216,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
</div>
</div>
<div className="flex items-center gap-2">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
title={t("logView.export")}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
</Button>
)}
{/* Theme & font customization button */}
<Button
variant="ghost"
@@ -317,13 +249,10 @@ const LogViewComponent: React.FC<LogViewProps> = ({
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={explicitThemeId}
displayThemeId={currentTheme.id}
currentThemeId={currentTheme.id}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
onFontSizeChange={handleFontSizeChange}
onPreviewThemeChange={setPreviewTheme}
/>
</div>
);

View File

@@ -1,169 +0,0 @@
/**
* Passphrase Modal
* Modal for requesting passphrase for encrypted SSH keys
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface PassphraseRequest {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
request,
onSubmit,
onCancel,
onSkip,
}) => {
const { t } = useI18n();
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleSkip = useCallback(() => {
if (!request || !onSkip) return;
onSkip(request.requestId);
}, [request, onSkip]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting && passphrase) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting, passphrase]
);
if (!request) return null;
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="passphrase-input">
{t("passphrase.label")}
</Label>
<div className="relative">
<Input
id="passphrase-input"
type={showPassphrase ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className="pr-10"
autoFocus
disabled={isSubmitting}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => setShowPassphrase(!showPassphrase)}
disabled={isSubmitting}
>
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
{onSkip && (
<Button
variant="ghost"
onClick={handleSkip}
disabled={isSubmitting}
>
{t("passphrase.skip")}
</Button>
)}
</div>
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("passphrase.unlocking")}
</>
) : (
t("passphrase.unlock")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default PassphraseModal;

View File

@@ -14,14 +14,11 @@ import React, { useCallback, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
GroupConfig,
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -67,8 +64,6 @@ interface PortForwardingProps {
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -79,8 +74,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
keys,
identities = [],
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -117,8 +110,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
async (rule: PortForwardingRule) => {
const _rawHost = hosts.find((h) => h.id === rule.hostId);
if (!_rawHost) {
const _host = hosts.find((h) => h.id === rule.hostId);
if (!_host) {
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
toast.error(
t("pf.error.hostNotFound"),
@@ -127,10 +120,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
return;
}
const _host = _rawHost.group
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
: _rawHost;
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
@@ -138,9 +127,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
hosts,
keys,
identities,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
@@ -169,7 +156,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
[hosts, keys, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel
@@ -317,6 +304,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const label =
newFormDraft.label?.trim() ||
(() => {
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
switch (newFormDraft.type) {
case "local":
return `Local:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
@@ -554,6 +543,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
};
// Handle skip wizard (just save with defaults)
const _skipWizard = () => {
setShowWizard(false);
resetWizard();
};
// Render wizard panel content
const hasRules = filteredRules.length > 0;
@@ -703,7 +698,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<RuleCard
key={rule.id}
rule={rule}
host={hosts.find((h) => h.id === rule.hostId)}
viewMode={viewMode}
isSelected={selectedRuleId === rule.id}
isPending={pendingOperations.has(rule.id)}
@@ -850,7 +844,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
/>

View File

@@ -1,185 +0,0 @@
/**
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
* App root and triggered by the `netcatty:snippets:add` window event.
*
* Intentionally minimal: label + command + package only. Advanced fields
* (target hosts, shortkey, tags) can be set later via the full Snippets
* manager. This keeps the user in their terminal context instead of
* navigating to the Vault view just to add a command.
*/
import { Package } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { Snippet } from '../domain/models';
import { Button } from './ui/button';
import { Combobox } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
packages: string[];
onCreateSnippet: (snippet: Snippet) => void;
onCreatePackage?: (packagePath: string) => void;
}
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
snippets,
packages,
onCreateSnippet,
onCreatePackage,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
// Listen for the global "add snippet" request dispatched by the
// terminal-side ScriptsSidePanel + button. We reset form state on
// every open so stale input from a previous cancel does not leak.
useEffect(() => {
const handler = () => {
setLabel('');
setCommand('');
setPackagePath('');
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
return () => window.removeEventListener('netcatty:snippets:add', handler);
}, []);
// Auto-focus the label input once the dialog renders, so the user can
// start typing immediately after clicking the + button.
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
return () => window.clearTimeout(id);
}, [open]);
// Derive combobox options from the union of existing packages (from
// props) and any package path referenced by an existing snippet, so
// the user can reuse anything they see in the main snippets view.
const packageOptions = useMemo(() => {
const set = new Set<string>();
for (const p of packages) {
if (p) set.add(p);
}
for (const s of snippets) {
if (s.package) set.add(s.package);
}
return Array.from(set).sort().map((value) => ({ value, label: value }));
}, [packages, snippets]);
const canSave = label.trim().length > 0 && command.trim().length > 0;
const handleSave = useCallback(() => {
if (!canSave) return;
const trimmedPackage = packagePath.trim();
// If the user typed a brand new package name, surface it to the parent
// so it can be added to the user's package list alongside the snippet.
if (trimmedPackage && !packages.includes(trimmedPackage)) {
onCreatePackage?.(trimmedPackage);
}
onCreateSnippet({
id: crypto.randomUUID(),
label: label.trim(),
command, // preserve whitespace in multi-line commands
tags: [],
package: trimmedPackage || '',
targets: [],
});
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
e.preventDefault();
handleSave();
}
},
[canSave, handleSave],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.empty.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
</Label>
<Input
id="quick-add-snippet-label"
ref={labelInputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<Textarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px] font-mono text-xs"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
<Package size={12} /> {t('snippets.field.package')}
</Label>
<Combobox
value={packagePath}
onValueChange={setPackagePath}
options={packageOptions}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate
onCreateNew={setPackagePath}
createText={t('snippets.field.createPackage')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default QuickAddSnippetDialog;

View File

@@ -13,9 +13,8 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Host, KnownHost, SSHKey } from "../types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -31,6 +30,7 @@ interface QuickConnectWizardProps {
open: boolean;
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
@@ -42,6 +42,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
open,
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
@@ -68,7 +69,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials] = useState(true);
const [saveCredentials, _setSaveCredentials] = useState(true);
// Check if host is in known hosts
const _existingKnownHost = useMemo(() => {
return knownHosts.find(
(kh) =>
kh.hostname === target.hostname &&
(kh.port === port || (!kh.port && port === 22)),
);
}, [knownHosts, target.hostname, port]);
// Reset state when target changes
React.useEffect(() => {
@@ -532,11 +542,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
case "auth":
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
}
};

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