Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfa2d4dd0 | ||
|
|
7c5478b2a5 | ||
|
|
338ba94d42 | ||
|
|
b7b2e91fab | ||
|
|
cd723000fc | ||
|
|
fff031eb25 | ||
|
|
2f1fd399cf | ||
|
|
43c4d4c430 | ||
|
|
835a1231a6 | ||
|
|
cd512d0800 | ||
|
|
0c5ae13692 | ||
|
|
6727248924 | ||
|
|
0eee7bf95a | ||
|
|
b2406ec8a5 | ||
|
|
5fde9c2d61 | ||
|
|
06a6a0ac12 | ||
|
|
024e60ead1 | ||
|
|
fe71790f0a | ||
|
|
9371b3d01b | ||
|
|
5a1d279efd | ||
|
|
8b0cbf02c3 | ||
|
|
d19fe45a14 | ||
|
|
344946b096 | ||
|
|
fcd15707d2 | ||
|
|
42c82e46ea | ||
|
|
0e1c3b621a | ||
|
|
3cd3bbaaf7 | ||
|
|
8bfb50fcbb | ||
|
|
c39ef879c3 | ||
|
|
b3d5785477 | ||
|
|
05de49f7da | ||
|
|
f77c2b2de9 | ||
|
|
f79f27d737 | ||
|
|
ec35daa0dd | ||
|
|
ed0775d9d2 | ||
|
|
1f31629ce0 | ||
|
|
cc4a904dea | ||
|
|
e9e1d87ff5 | ||
|
|
a6b07f39ad | ||
|
|
6892e11952 | ||
|
|
ec9be922cb | ||
|
|
6e961b0efd | ||
|
|
d3fe2f9f53 | ||
|
|
88760b763e | ||
|
|
6dfe543ab5 | ||
|
|
c000996cb4 | ||
|
|
f70b604996 | ||
|
|
b973382f9f | ||
|
|
eeb300295d | ||
|
|
be36ccd167 | ||
|
|
71b13a77a3 | ||
|
|
808d021ebe | ||
|
|
d03117733d | ||
|
|
1816c3d0df | ||
|
|
b192ee1764 | ||
|
|
0b9cb86c4e | ||
|
|
bcd44f0177 | ||
|
|
d8d29d1709 | ||
|
|
0820569166 | ||
|
|
545506ac86 | ||
|
|
29fca33ffd | ||
|
|
216ea7f177 | ||
|
|
b280caded2 | ||
|
|
2d4f260f0b | ||
|
|
e69bc53aa4 | ||
|
|
a55da77471 | ||
|
|
33d3a86d83 | ||
|
|
f73c060351 | ||
|
|
304ebf1e3b | ||
|
|
2788dbdff5 | ||
|
|
84fe0134c9 | ||
|
|
06dc7400f2 | ||
|
|
d1a59ed40c | ||
|
|
f90aa81b2c | ||
|
|
950819746e | ||
|
|
4a3a4b9d9b | ||
|
|
726ff82a9e | ||
|
|
7e8682d10d | ||
|
|
b2447b06d2 | ||
|
|
ed8a6a6cf2 | ||
|
|
f0f5803a6d | ||
|
|
f53bc05cb3 | ||
|
|
3136100514 | ||
|
|
847df7a023 | ||
|
|
150724fc7c | ||
|
|
8949394756 | ||
|
|
7f3214e088 | ||
|
|
eaab7d72cb | ||
|
|
63a7c06037 | ||
|
|
72887c35b5 | ||
|
|
4373a8ce14 | ||
|
|
007fe47310 | ||
|
|
9109fc2f6e | ||
|
|
961f79d3d8 | ||
|
|
494fc27454 | ||
|
|
a85324c9fb | ||
|
|
860739bb97 | ||
|
|
a6494bfb78 | ||
|
|
1fa11c2c2d | ||
|
|
35b8990a9c | ||
|
|
67536c9424 | ||
|
|
4dbbb96e4d | ||
|
|
5cb8b348b3 | ||
|
|
06efcfe384 | ||
|
|
4877c934fa | ||
|
|
c542520dee | ||
|
|
0b61d10953 | ||
|
|
347361bc7b | ||
|
|
746c336ee1 | ||
|
|
6373762399 | ||
|
|
27b8d4a410 | ||
|
|
27773c58db | ||
|
|
ecb48e89a5 | ||
|
|
d609d8edb3 | ||
|
|
5f91fbbab8 | ||
|
|
89c3c7f83a | ||
|
|
ee391bcc32 | ||
|
|
26fd5023f5 | ||
|
|
49543abcff | ||
|
|
6bab971de8 | ||
|
|
392a57f95b | ||
|
|
85e3e8b26f | ||
|
|
9747498833 | ||
|
|
520e2c3f9d | ||
|
|
cb5333e336 | ||
|
|
d3153148c8 | ||
|
|
899cb109b4 | ||
|
|
d031bf355d | ||
|
|
489b7711f5 | ||
|
|
65877fd912 | ||
|
|
117ec260b6 | ||
|
|
c76ff7ac9a | ||
|
|
17da21b1cd | ||
|
|
733e36a728 | ||
|
|
35174246cc | ||
|
|
ab13670eaa | ||
|
|
4f3e39e378 | ||
|
|
2281d1df68 | ||
|
|
e570185e2f | ||
|
|
12884165b5 | ||
|
|
11f82defc3 | ||
|
|
ac9175b770 | ||
|
|
71c6f68934 | ||
|
|
01bee794ee | ||
|
|
29dc01306d | ||
|
|
0dcfd1489b | ||
|
|
72f61141c4 | ||
|
|
37150ea379 | ||
|
|
5706af3f33 | ||
|
|
6871c82ab8 | ||
|
|
b90ff692eb | ||
|
|
ce71725dba | ||
|
|
fb5c4aaa58 | ||
|
|
45c059ae53 | ||
|
|
1d67eb40c4 | ||
|
|
c6e3989a1b | ||
|
|
ace081414f | ||
|
|
049a609bca | ||
|
|
44409e6d32 | ||
|
|
5246489ef9 | ||
|
|
83d0d917ad | ||
|
|
73557d0af1 | ||
|
|
aa67455c8c | ||
|
|
c7d2482996 | ||
|
|
d2391f5472 | ||
|
|
9be84c71f5 | ||
|
|
effb98b91a | ||
|
|
77fd7a42a8 | ||
|
|
a86a5e6839 | ||
|
|
7ed4940e18 | ||
|
|
410d1ef097 | ||
|
|
c386ee2e2e | ||
|
|
4c08888b60 | ||
|
|
2ea4c88680 | ||
|
|
0ba75f9af0 | ||
|
|
4610348b0d | ||
|
|
8d11b71bc1 | ||
|
|
6683001032 | ||
|
|
3b313ff933 | ||
|
|
eaa27461fa | ||
|
|
20b65366be | ||
|
|
b8c08ba3ca | ||
|
|
981c5de90d | ||
|
|
0097d65a6e | ||
|
|
e4aa03c474 | ||
|
|
b94386236c | ||
|
|
0883585704 | ||
|
|
5b38f4663d | ||
|
|
a6a6dd1aac | ||
|
|
506c60ea44 | ||
|
|
9d9b24fe7b | ||
|
|
584b9859ef | ||
|
|
b005065949 | ||
|
|
a4fdb6758d | ||
|
|
a2b5c9d067 |
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
@@ -59,12 +59,12 @@ jobs:
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
# macOS code signing & notarization (ignored on other platforms)
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# 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 }}
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -84,13 +84,14 @@ jobs:
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — builds directly on ubuntu-latest (no container).
|
||||
# v1.0.39 used a debian:bullseye container which broke native module
|
||||
# packaging (node-pty .node file missing from asar.unpacked). Reverted
|
||||
# to the v1.0.38 approach. See #264.
|
||||
# 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-latest
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -35,5 +35,25 @@ coverage
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
# Claude Code
|
||||
/.claude/
|
||||
/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
|
||||
|
||||
70
App.tsx
70
App.tsx
@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -180,6 +181,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
@@ -283,12 +290,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -304,13 +314,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// Show toast notification when update is available
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
@@ -320,17 +334,54 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
void openSettingsWindow();
|
||||
// Dismiss the update so the toast doesn't re-fire on every render.
|
||||
// On unsupported platforms (where autoDownloadStatus stays 'idle')
|
||||
// this is the only way to suppress the notification for this version.
|
||||
// On supported platforms this toast only shows before auto-download
|
||||
// starts, and the Settings window's own useUpdateCheck will pick up
|
||||
// the download state via IPC events independently of the dismiss.
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
|
||||
if (updateState.autoDownloadStatus === 'ready') {
|
||||
const version = updateState.latestRelease?.version ?? '';
|
||||
toast.info(
|
||||
t('update.readyToInstall.message', { version }),
|
||||
{
|
||||
title: t('update.readyToInstall.title'),
|
||||
duration: 0,
|
||||
actionLabel: t('update.restartNow'),
|
||||
onClick: () => installUpdate(),
|
||||
}
|
||||
);
|
||||
} else if (updateState.autoDownloadStatus === 'error') {
|
||||
toast.error(
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.openReleases'),
|
||||
onClick: () => openReleasePage(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
@@ -399,7 +450,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
@@ -1161,6 +1212,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
@@ -1234,6 +1286,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={knownHosts}
|
||||
@@ -1266,6 +1319,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSplitSession={splitSession}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
32
CHANGELOG.md
Normal file
32
CHANGELOG.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 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 重复
|
||||
@@ -34,6 +34,7 @@ const en: Messages = {
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
'common.right': 'Right',
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.az': 'A-z',
|
||||
@@ -49,6 +50,7 @@ const en: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -109,6 +111,12 @@ const en: Messages = {
|
||||
'settings.update.manualDownload': 'Download from GitHub',
|
||||
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
|
||||
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
|
||||
'settings.update.lastCheckedJustNow': 'just now',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -136,6 +144,8 @@ const en: Messages = {
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
@@ -177,6 +187,12 @@ const en: Messages = {
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.viewInSettings': 'View in Settings',
|
||||
'update.readyToInstall.title': 'Update Ready',
|
||||
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
@@ -247,13 +263,24 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
|
||||
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
|
||||
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
|
||||
'terminal.osc52.readPrompt.allow': 'Allow',
|
||||
'terminal.osc52.readPrompt.deny': 'Deny',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
@@ -585,7 +612,11 @@ const en: Messages = {
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.transfers': 'Transfers',
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -819,8 +850,8 @@ const en: Messages = {
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
@@ -1111,6 +1142,7 @@ const en: Messages = {
|
||||
'cloudSync.webdav.password': 'Password',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Show secret',
|
||||
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
|
||||
'cloudSync.webdav.validation.token': 'Token is required.',
|
||||
@@ -1386,6 +1418,7 @@ const en: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
@@ -1462,6 +1495,164 @@ const en: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
'ai.providers.apiKeyConfigured': 'API key configured',
|
||||
'ai.providers.noApiKey': 'No API key',
|
||||
'ai.providers.configure': 'Configure',
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApprovalTitle': 'Permission Required',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
'ai.chat.recommendAllow': 'Allow',
|
||||
'ai.chat.recommendConfirm': 'Confirm',
|
||||
'ai.chat.recommendDeny': 'Deny',
|
||||
'ai.chat.exportConversation': 'Export conversation',
|
||||
'ai.chat.exportAs': 'Export As',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Plain Text',
|
||||
'ai.chat.thinking': 'Thinking',
|
||||
'ai.chat.thoughtFor': 'Thought for {duration}',
|
||||
'ai.chat.thought': 'Thought',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': 'Detected on this machine',
|
||||
'ai.chat.rescan': 'Re-scan',
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before actions',
|
||||
'ai.chat.permAutoDesc': 'Execute freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Context',
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Web Search',
|
||||
'ai.webSearch.enable': 'Enable Web Search',
|
||||
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
|
||||
'ai.webSearch.provider': 'Search Provider',
|
||||
'ai.webSearch.provider.description': 'Choose a web search API provider.',
|
||||
'ai.webSearch.apiKey': 'API Key',
|
||||
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
|
||||
'ai.webSearch.apiHost': 'API Host',
|
||||
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
|
||||
'ai.webSearch.maxResults': 'Max Results',
|
||||
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -22,6 +22,7 @@ const zhCN: Messages = {
|
||||
'common.use': '使用',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
@@ -36,6 +37,7 @@ const zhCN: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -93,6 +95,12 @@ const zhCN: Messages = {
|
||||
'settings.update.manualDownload': '前往 GitHub 下载',
|
||||
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
|
||||
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
|
||||
'settings.update.lastCheckedJustNow': '刚刚',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -120,6 +128,8 @@ const zhCN: Messages = {
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
@@ -161,6 +171,12 @@ const zhCN: Messages = {
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.viewInSettings': '在设置中查看',
|
||||
'update.readyToInstall.title': '更新已就绪',
|
||||
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
@@ -423,7 +439,11 @@ const zhCN: Messages = {
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.transfers': '传输',
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -529,8 +549,8 @@ const zhCN: Messages = {
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
@@ -792,6 +812,7 @@ const zhCN: Messages = {
|
||||
'cloudSync.webdav.password': '密码',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': '显示密钥',
|
||||
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
|
||||
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
|
||||
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
|
||||
'cloudSync.webdav.validation.token': '请输入 Token。',
|
||||
@@ -1119,12 +1140,23 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
|
||||
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
|
||||
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
|
||||
'terminal.osc52.readPrompt.allow': '允许',
|
||||
'terminal.osc52.readPrompt.deny': '拒绝',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
@@ -1401,6 +1433,7 @@ const zhCN: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
@@ -1477,6 +1510,164 @@ const zhCN: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已配置',
|
||||
'ai.providers.noApiKey': '未设置 API Key',
|
||||
'ai.providers.configure': '配置',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApprovalTitle': '需要权限确认',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
'ai.chat.recommendAllow': '允许',
|
||||
'ai.chat.recommendConfirm': '确认',
|
||||
'ai.chat.recommendDeny': '拒绝',
|
||||
'ai.chat.exportConversation': '导出对话',
|
||||
'ai.chat.exportAs': '导出为',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '纯文本',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本机检测到',
|
||||
'ai.chat.rescan': '重新扫描',
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自主',
|
||||
'ai.chat.permObserverDesc': '只读模式',
|
||||
'ai.chat.permConfirmDesc': '操作前询问',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '网络搜索',
|
||||
'ai.webSearch.enable': '启用网络搜索',
|
||||
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
|
||||
'ai.webSearch.provider': '搜索供应商',
|
||||
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
|
||||
'ai.webSearch.apiKey': 'API 密钥',
|
||||
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
|
||||
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大结果数',
|
||||
'ai.webSearch.maxResults.description': '搜索返回的最大结果数(1-20)。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -30,7 +30,8 @@ class CustomThemeStore {
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
/** 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)) {
|
||||
@@ -39,7 +40,7 @@ class CustomThemeStore {
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
this.notify();
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
|
||||
52
application/state/sftp/sharedRemoteHostCache.ts
Normal file
52
application/state/sftp/sharedRemoteHostCache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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(),
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export interface SftpPane {
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
@@ -22,7 +23,10 @@ export interface SftpSideTabs {
|
||||
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
|
||||
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
|
||||
|
||||
export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
export const createEmptyPane = (
|
||||
id?: string,
|
||||
showHiddenFiles = false,
|
||||
): SftpPane => ({
|
||||
id: id || crypto.randomUUID(),
|
||||
connection: null,
|
||||
files: [],
|
||||
@@ -32,6 +36,7 @@ export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles,
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
@@ -53,4 +58,6 @@ export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncodin
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
@@ -24,14 +25,16 @@ interface UseSftpConnectionsParams {
|
||||
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) => SftpPane;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -55,22 +58,24 @@ export const useSftpConnections = ({
|
||||
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") => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!sideTabs.activeTabId) {
|
||||
if (!sideTabs.activeTabId || options?.forceNewTab) {
|
||||
const newPane = createEmptyPane();
|
||||
activeTabId = newPane.id;
|
||||
setTabs((prev) => ({
|
||||
@@ -89,6 +94,14 @@ export const useSftpConnections = ({
|
||||
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
|
||||
@@ -96,18 +109,22 @@ export const useSftpConnections = ({
|
||||
const filenameEncoding: SftpFilenameEncoding =
|
||||
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
|
||||
|
||||
if (currentPane?.connection) {
|
||||
clearCacheForConnection(currentPane.connection.id);
|
||||
}
|
||||
if (currentPane?.connection && !currentPane.connection.isLocal) {
|
||||
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
|
||||
if (oldSftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
// 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) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,22 +179,33 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
}
|
||||
} 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: "/",
|
||||
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,
|
||||
files: prev.reconnecting ? prev.files : [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -238,72 +266,137 @@ export const useSftpConnections = ({
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
|
||||
let startPath = "/";
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
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");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
break;
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
|
||||
if (!sharedHostCache) {
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
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");
|
||||
}
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
} else if (credentials.username) {
|
||||
try {
|
||||
const homeFiles = await netcattyBridge.get()?.listSftp(
|
||||
sftpId,
|
||||
`/home/${credentials.username}`,
|
||||
filenameEncoding,
|
||||
);
|
||||
if (homeFiles) startPath = `/home/${credentials.username}`;
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
} else if (credentials.username) {
|
||||
try {
|
||||
const homeFiles = await netcattyBridge.get()?.listSftp(
|
||||
sftpId,
|
||||
`/home/${credentials.username}`,
|
||||
filenameEncoding,
|
||||
);
|
||||
if (homeFiles) {
|
||||
startPath = `/home/${credentials.username}`;
|
||||
homeDir = startPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
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;
|
||||
|
||||
@@ -314,7 +407,7 @@ export const useSftpConnections = ({
|
||||
...prev.connection,
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir: startPath,
|
||||
homeDir,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
@@ -346,6 +439,7 @@ export const useSftpConnections = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
@@ -355,13 +449,17 @@ export const useSftpConnections = ({
|
||||
const initialConnectDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
|
||||
if (
|
||||
autoConnectLocalOnMount &&
|
||||
!initialConnectDoneRef.current &&
|
||||
leftTabs.tabs.length === 0
|
||||
) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
}
|
||||
}, [connect, leftTabs.tabs.length]);
|
||||
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
@@ -412,7 +510,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
|
||||
@@ -7,11 +7,13 @@ 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 };
|
||||
@@ -20,6 +22,8 @@ interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => 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;
|
||||
@@ -38,10 +42,16 @@ interface SftpExternalOperationsResult {
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string }
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
@@ -53,6 +63,8 @@ export const useSftpExternalOperations = (
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload = false,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
@@ -63,6 +75,10 @@ export const useSftpExternalOperations = (
|
||||
// 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);
|
||||
@@ -324,6 +340,7 @@ export const useSftpExternalOperations = (
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
activeFileWatchCountRef.current += 1;
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch:", err);
|
||||
}
|
||||
@@ -337,7 +354,9 @@ export const useSftpExternalOperations = (
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
const createUploadCallbacks = useCallback((
|
||||
connectionId: string,
|
||||
targetPath: string
|
||||
targetPath: string,
|
||||
targetHostId?: string,
|
||||
targetConnectionKey?: string,
|
||||
): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
@@ -349,6 +368,8 @@ export const useSftpExternalOperations = (
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
@@ -374,6 +395,8 @@ export const useSftpExternalOperations = (
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
@@ -505,7 +528,12 @@ export const useSftpExternalOperations = (
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await uploadFromDataTransfer(
|
||||
@@ -532,6 +560,7 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
},
|
||||
[
|
||||
connectionCacheKeyMapRef,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
@@ -541,6 +570,90 @@ export const useSftpExternalOperations = (
|
||||
],
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 current directory and invalidate the upload target's
|
||||
// cache entry. If the user navigated away during the upload, the
|
||||
// invalidation ensures returning to the target path triggers a fresh
|
||||
// listing instead of serving stale cached data.
|
||||
const livePane = getActivePane(side);
|
||||
if (livePane?.connection) {
|
||||
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
|
||||
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side);
|
||||
}
|
||||
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) {
|
||||
@@ -566,7 +679,9 @@ export const useSftpExternalOperations = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { 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 { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
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;
|
||||
@@ -15,6 +17,7 @@ interface UseSftpPaneActionsParams {
|
||||
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;
|
||||
@@ -50,6 +53,7 @@ interface UseSftpPaneActionsResult {
|
||||
}
|
||||
|
||||
export const useSftpPaneActions = ({
|
||||
hosts,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
@@ -59,6 +63,7 @@ export const useSftpPaneActions = ({
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
@@ -68,6 +73,43 @@ export const useSftpPaneActions = ({
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
// 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",
|
||||
@@ -92,8 +134,9 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -104,6 +147,13 @@ export const useSftpPaneActions = ({
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -114,11 +164,53 @@ export const useSftpPaneActions = ({
|
||||
error: null,
|
||||
selectedFiles: new 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;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
// 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(activeTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, 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, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
@@ -164,13 +256,42 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Another navigation on this side superseded this request.
|
||||
// Only restore if no newer navigation has occurred on this specific tab
|
||||
// AND the tab still belongs to the same connection (connect/disconnect
|
||||
// bump navSeqRef but not tabNavSeqRef).
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
// Tab was reconnected or disconnected; don't restore stale state.
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -180,18 +301,52 @@ export const useSftpPaneActions = ({
|
||||
loading: false,
|
||||
selectedFiles: new 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) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
}));
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (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,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SftpTabsState {
|
||||
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;
|
||||
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;
|
||||
@@ -33,7 +34,11 @@ export interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
export const useSftpTabsState = (): SftpTabsState => {
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
} = {}): SftpTabsState => {
|
||||
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
@@ -45,8 +50,10 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
|
||||
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;
|
||||
@@ -58,14 +65,14 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
const pane = leftTabs.activeTabId
|
||||
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
|
||||
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);
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
|
||||
}, [rightTabs]);
|
||||
|
||||
const updateTab = useCallback(
|
||||
@@ -88,9 +95,24 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
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();
|
||||
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
@@ -236,6 +258,7 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
|
||||
581
application/state/useAIState.ts
Normal file
581
application/state/useAIState.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
ProviderConfig,
|
||||
HostAIPermission,
|
||||
ExternalAgentConfig,
|
||||
ChatMessage,
|
||||
AISessionScope,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
||||
);
|
||||
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
||||
);
|
||||
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
||||
);
|
||||
|
||||
// ── Permission Model ──
|
||||
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
});
|
||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||
);
|
||||
|
||||
// ── External Agents ──
|
||||
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
||||
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
||||
);
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
|
||||
);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
setAgentModelMapRaw(prev => {
|
||||
const next = { ...prev, [agentId]: modelId };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveProviderId = useCallback((id: string) => {
|
||||
setActiveProviderIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
||||
}, []);
|
||||
|
||||
const setActiveModelId = useCallback((id: string) => {
|
||||
setActiveModelIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
||||
}, []);
|
||||
|
||||
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
||||
// Sync to MCP Server bridge (observer mode blocks write operations)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetPermissionMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
|
||||
setHostPermissionsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDefaultAgentId = useCallback((id: string) => {
|
||||
setDefaultAgentIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
||||
}, []);
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so ACP agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
// Sync to MCP Server bridge
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandTimeout?.(value);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
// Sync to MCP Server bridge (used by ACP agent path)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
// ── Cross-window sync via storage events ──
|
||||
// When the settings window updates localStorage, the main window picks up changes.
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
switch (e.key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setProvidersRaw(parsed ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
||||
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
||||
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_PERMISSION_MODE: {
|
||||
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setExternalAgentsRaw(agents ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setMaxIterationsRaw(iters);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
|
||||
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (perms != null && !Array.isArray(perms)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
|
||||
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const debouncedPersistSessions = useCallback(() => {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Flush pending debounced writes on unmount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
persistSessions(sessionsRef.current);
|
||||
}
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'New Chat',
|
||||
agentId: agentId || defaultAgentId,
|
||||
scope,
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, session.id);
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Trim oldest messages if exceeding limit (keep system messages)
|
||||
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId || s.messages.length === 0) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const idx = s.messages.findIndex(m => m.id === messageId);
|
||||
if (idx === -1) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const clearSessionMessages = useCallback((sessionId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders(prev => prev.filter(p => p.id !== id));
|
||||
// Use the raw setter to avoid stale closure over setActiveProviderId
|
||||
setActiveProviderIdRaw(prevId => {
|
||||
if (prevId === id) {
|
||||
const next = '';
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
|
||||
return next;
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
// Provider config
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
// External agents
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
|
||||
// Safety
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Web search
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
};
|
||||
}
|
||||
101
application/state/useAgentDiscovery.ts
Normal file
101
application/state/useAgentDiscovery.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -15,9 +15,11 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
@@ -27,15 +29,19 @@ interface AutoSyncConfig {
|
||||
identities?: SyncPayload['identities'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
// Callbacks
|
||||
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'];
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
@@ -50,12 +56,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
// If port-forwarding hook state is still [] (async init in progress),
|
||||
// fall back to localStorage to avoid uploading an empty array that
|
||||
// overwrites the cloud snapshot.
|
||||
const isSyncRunningRef = useRef(false);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
@@ -70,62 +73,64 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
config.keys,
|
||||
config.identities,
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
const getDataHash = useCallback(() => {
|
||||
// Same fallback as buildPayload
|
||||
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 data = {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// 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(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -147,6 +152,7 @@ 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) {
|
||||
@@ -165,7 +171,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = getDataHash();
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -175,13 +181,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, t]);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
@@ -191,12 +199,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
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;
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
@@ -235,6 +240,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
@@ -252,7 +263,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type S3Config,
|
||||
formatLastSync,
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
@@ -181,13 +182,13 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const hasAnyConnectedProvider = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).some(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
);
|
||||
}, [state.providers]);
|
||||
|
||||
const connectedProviderCount = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).filter(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
@@ -519,7 +520,7 @@ export const useProviderStatus = (provider: CloudProvider) => {
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: connection.status === 'connected',
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
|
||||
66
application/state/useImageUpload.ts
Normal file
66
application/state/useImageUpload.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* useImageUpload - Handle image paste/drop with base64 conversion
|
||||
*
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:image/...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png"
|
||||
}
|
||||
|
||||
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 function useImageUpload() {
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
|
||||
const addImages = useCallback(async (files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newImages: UploadedImage[] = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `screenshot-${Date.now()}.png`;
|
||||
const mediaType = file.type || 'image/png';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useImageUpload] Failed to convert:', err);
|
||||
}
|
||||
return { id, filename, dataUrl, base64Data, mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setImages([]);
|
||||
}, []);
|
||||
|
||||
return { images, addImages, removeImage, clearImages };
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string }[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ export const usePortForwardingAutoStart = ({
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -377,7 +377,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
|
||||
@@ -569,6 +569,7 @@ export const useSessionState = () => {
|
||||
workspaceId: workspace.id,
|
||||
// Store the command to run after connection
|
||||
startupCommand: snippet.command,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
@@ -39,7 +41,7 @@ import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
|
||||
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
const getSystemPreference = (): 'light' | 'dark' =>
|
||||
@@ -110,14 +112,15 @@ const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boo
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -126,7 +129,7 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
|
||||
const computedAccentForeground = theme === 'dark'
|
||||
const computedAccentForeground = resolvedTheme === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
|
||||
@@ -145,7 +148,7 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(theme);
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
@@ -263,7 +266,17 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -323,7 +336,7 @@ export const useSettingsState = () => {
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -331,9 +344,63 @@ export const useSettingsState = () => {
|
||||
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
|
||||
}, []);
|
||||
|
||||
const rehydrateAllFromStorage = useCallback(() => {
|
||||
// Theme & appearance (already have helper)
|
||||
syncAppearanceFromStorage();
|
||||
syncCustomCssFromStorage();
|
||||
|
||||
// UI Font
|
||||
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (storedFont) setUiFontFamilyId(storedFont);
|
||||
|
||||
// Language
|
||||
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (storedLang) setUiLanguage(storedLang as UILanguage);
|
||||
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (storedTermSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedTermSettings);
|
||||
setTerminalSettings(parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
|
||||
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -456,6 +523,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -621,11 +694,25 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -733,7 +820,7 @@ export const useSettingsState = () => {
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
@@ -754,7 +841,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
@@ -769,6 +862,41 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -911,6 +1039,21 @@ export const useSettingsState = () => {
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,15 @@ export const useSftpState = (
|
||||
identities: Identity[],
|
||||
options?: SftpStateOptions
|
||||
) => {
|
||||
const tabsState = useSftpTabsState();
|
||||
const createPane = useCallback(
|
||||
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
|
||||
createEmptyPane(id, showHiddenFiles),
|
||||
[options?.defaultShowHiddenFiles],
|
||||
);
|
||||
|
||||
const tabsState = useSftpTabsState({
|
||||
defaultShowHiddenFiles: options?.defaultShowHiddenFiles,
|
||||
});
|
||||
const {
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
@@ -49,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
@@ -92,12 +101,26 @@ export const useSftpState = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearDirCacheEntry = useCallback((connectionId: string, path: string) => {
|
||||
// Remove all encoding variants of this path from the cache
|
||||
for (const key of dirCacheRef.current.keys()) {
|
||||
if (key.startsWith(`${connectionId}::`) && key.endsWith(`::${path}`)) {
|
||||
dirCacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
right: false,
|
||||
});
|
||||
|
||||
// Map connectionId → cache key, set at connect time so each tab's
|
||||
// navigateTo can use the correct cache key even when multiple tabs
|
||||
// share the same hostId with different session-time overrides.
|
||||
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Store last connected host info for reconnection
|
||||
const lastConnectedHostRef = useRef<{
|
||||
left: Host | "local" | null;
|
||||
@@ -140,10 +163,12 @@ export const useSftpState = (
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
createEmptyPane: createPane,
|
||||
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -164,6 +189,7 @@ export const useSftpState = (
|
||||
renameFile,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
hosts,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
@@ -173,6 +199,7 @@ export const useSftpState = (
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
@@ -205,6 +232,13 @@ export const useSftpState = (
|
||||
[clearCacheForConnection, getActivePane, navigateTo, updateActiveTab],
|
||||
);
|
||||
|
||||
const setShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
setTabShowHiddenFiles(side, tabId, showHiddenFiles);
|
||||
},
|
||||
[setTabShowHiddenFiles],
|
||||
);
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
@@ -233,12 +267,16 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
@@ -270,6 +308,7 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
@@ -281,6 +320,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
@@ -315,6 +355,7 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
@@ -326,6 +367,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
@@ -364,6 +406,8 @@ export const useSftpState = (
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
methodsRef.current.setFilenameEncoding(...args),
|
||||
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
|
||||
methodsRef.current.setShowHiddenFiles(...args),
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
@@ -376,6 +420,8 @@ export const useSftpState = (
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
methodsRef.current.uploadExternalEntries(...args),
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
@@ -387,7 +433,8 @@ export const useSftpState = (
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
}), []); // Empty deps - these wrappers never change
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
// Return object with stable method references but reactive state
|
||||
// State changes will cause re-renders, but method references stay stable
|
||||
|
||||
28
application/state/useStoredString.ts
Normal file
28
application/state/useStoredString.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,13 +1,17 @@
|
||||
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 } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } 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
|
||||
const STARTUP_CHECK_DELAY_MS = 5000;
|
||||
// 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;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
|
||||
|
||||
export interface UpdateState {
|
||||
isChecking: boolean;
|
||||
hasUpdate: boolean;
|
||||
@@ -26,6 +34,12 @@ 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 {
|
||||
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
|
||||
checkNow: () => Promise<UpdateCheckResult | null>;
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +56,13 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
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');
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -49,11 +70,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
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(() => {
|
||||
@@ -71,6 +125,145 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
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 });
|
||||
|
||||
@@ -119,8 +312,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Latest release version:', result.latestRelease?.version);
|
||||
const now = Date.now();
|
||||
|
||||
// Save last check time
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, 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));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this version was dismissed
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
@@ -156,11 +357,135 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 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 dismissUpdate = useCallback(() => {
|
||||
if (updateState.latestRelease?.version) {
|
||||
@@ -189,6 +514,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, [updateState.latestRelease]);
|
||||
|
||||
const installUpdate = useCallback(() => {
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -219,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
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;
|
||||
}
|
||||
@@ -233,8 +562,38 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
// 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 now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
@@ -244,7 +603,43 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(() => {
|
||||
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
|
||||
}
|
||||
debugLog('=== Delayed check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -254,12 +649,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, performCheck]);
|
||||
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
checkNow,
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type ExportableVaultData = {
|
||||
identities?: Identity[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
};
|
||||
|
||||
@@ -557,9 +558,10 @@ export const useVaultState = () => {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -569,6 +571,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);
|
||||
},
|
||||
[
|
||||
@@ -577,6 +580,7 @@ export const useVaultState = () => {
|
||||
updateIdentities,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateKnownHosts,
|
||||
],
|
||||
);
|
||||
|
||||
778
components/AIChatSidePanel.tsx
Normal file
778
components/AIChatSidePanel.tsx
Normal file
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* AIChatSidePanel - Main AI chat interface side panel
|
||||
*
|
||||
* Zed-style agent panel with agent selector, scoped chat sessions,
|
||||
* message list, input area, and session history drawer.
|
||||
*
|
||||
* Core logic is decomposed into focused hooks:
|
||||
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
||||
* - useToolApproval: tool approval workflow, timeouts, resume logic
|
||||
* - useConversationExport: export formats & object URL lifecycle
|
||||
*/
|
||||
|
||||
import {
|
||||
History,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useImageUpload } from '../application/state/useImageUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId: setActiveSessionIdForScope,
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
defaultAgentId,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// ── Per-scope state ──
|
||||
// Derive scope key for per-scope isolation
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
// Per-scope input values
|
||||
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
|
||||
const inputValue = inputValueMap[scopeKey] ?? '';
|
||||
const setInputValue = useCallback((val: string) => {
|
||||
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
|
||||
}, [scopeKey]);
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
} = useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
});
|
||||
|
||||
// ── Tool approval hook ──
|
||||
const {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
} = useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
});
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
|
||||
// Sync provider configs to main process so it can decrypt API keys server-side.
|
||||
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
|
||||
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
|
||||
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
return () => {
|
||||
controllers.forEach(c => c.abort());
|
||||
controllers.clear();
|
||||
// Clear pending approval (clears timeout too via setPendingApproval)
|
||||
setPendingApproval(null);
|
||||
};
|
||||
}, [abortControllersRef, setPendingApproval]);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
const config = enableAgent(agent);
|
||||
setExternalAgents?.((prev) => [...prev, config]);
|
||||
},
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
const { handleExport } = useConversationExport(activeSession);
|
||||
|
||||
// Active provider info
|
||||
const activeProvider = useMemo(
|
||||
() => providers.find((p) => p.id === activeProviderId),
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
|
||||
return stored;
|
||||
}
|
||||
// Default to first preset; for models with thinking levels, use the default level
|
||||
if (agentModelPresets.length > 0) {
|
||||
const first = agentModelPresets[0];
|
||||
if (first.thinkingLevels?.length) {
|
||||
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
|
||||
}
|
||||
return first.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [currentAgentId, agentModelMap, agentModelPresets]);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Filtered sessions for history (matching current scope type)
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[sessions, scopeType, scopeTargetId],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
const scope: AISessionScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
};
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
setShowHistory(false);
|
||||
setInputValue('');
|
||||
}, [
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
currentAgentId,
|
||||
createSession,
|
||||
setActiveSessionId,
|
||||
setInputValue,
|
||||
]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void openSettingsWindow();
|
||||
}, [openSettingsWindow]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Shared helpers for handleSend sub-flows
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
const s = sessionsRef.current.find(x => x.id === sessionId);
|
||||
if (s && (!s.title || s.title === 'New Chat')) {
|
||||
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = inputValueRef.current.trim();
|
||||
const sendScopeKey = scopeKey;
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const isExternalAgent = currentAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
const errSessionId = ensureSession();
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
terminalSessions,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
const controller = abortControllersRef.current.get(activeSessionId);
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
// Clear statusText on the last message so stale status indicators disappear
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
setPendingApproval(null);
|
||||
}
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
// Restore agent selector to match the session's bound agent
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
setShowHistory(false);
|
||||
},
|
||||
[setActiveSessionId, sessions],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const bridge = getNetcattyBridge();
|
||||
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
|
||||
deleteSession(sessionId, scopeKey);
|
||||
// Active session clearing is handled by deleteSession with scopeKey
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
// Preserve the current session in history and start a new one with the selected agent
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, agentId);
|
||||
setActiveSessionId(session.id);
|
||||
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
export default AIChatSidePanel;
|
||||
export { AIChatSidePanel };
|
||||
export type { AIChatSidePanelProps };
|
||||
@@ -35,7 +35,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -681,10 +681,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
const disconnectOtherProviders = async (current: CloudProvider) => {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const isActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
if (isActive(sync.providers[provider].status)) {
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -732,6 +731,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [webdavPassword, setWebdavPassword] = useState('');
|
||||
const [webdavToken, setWebdavToken] = useState('');
|
||||
const [showWebdavSecret, setShowWebdavSecret] = useState(false);
|
||||
const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false);
|
||||
const [webdavError, setWebdavError] = useState<string | null>(null);
|
||||
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(null);
|
||||
const [isSavingWebdav, setIsSavingWebdav] = useState(false);
|
||||
@@ -854,6 +854,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setWebdavUsername(config?.username || '');
|
||||
setWebdavPassword(config?.password || '');
|
||||
setWebdavToken(config?.token || '');
|
||||
setWebdavAllowInsecure(config?.allowInsecure || false);
|
||||
setShowWebdavSecret(false);
|
||||
setWebdavError(null);
|
||||
setWebdavErrorDetail(null);
|
||||
@@ -904,6 +905,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(),
|
||||
password: webdavAuthType === 'token' ? undefined : webdavPassword,
|
||||
token: webdavAuthType === 'token' ? webdavToken.trim() : undefined,
|
||||
allowInsecure: webdavAllowInsecure ? true : undefined,
|
||||
};
|
||||
|
||||
setIsSavingWebdav(true);
|
||||
@@ -1061,13 +1063,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.github)}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1077,13 +1079,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
@@ -1093,13 +1095,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
@@ -1109,13 +1111,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.webdav)}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1126,13 +1128,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.s3)}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1338,6 +1340,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
{t('cloudSync.webdav.showSecret')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webdavAllowInsecure}
|
||||
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.webdav.allowInsecure')}
|
||||
</label>
|
||||
|
||||
{webdavError && (
|
||||
<p className="text-sm text-red-500">{webdavError}</p>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,11 @@ 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",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
@@ -32,6 +37,11 @@ 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]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -99,6 +100,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -113,7 +115,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
|
||||
@@ -61,6 +61,17 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
@@ -89,6 +100,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
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 = useMemo(() => countAllHostsInNode(node), [node]);
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
@@ -171,7 +183,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
{hostsCountInNode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActi
|
||||
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
|
||||
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Dialog, DialogContent } from "./ui/dialog";
|
||||
|
||||
interface SFTPModalProps {
|
||||
host: Host;
|
||||
@@ -93,6 +92,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
@@ -204,6 +204,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
loading,
|
||||
setLoading,
|
||||
@@ -384,6 +385,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
@@ -475,8 +477,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
handleUploadEntries(initialEntriesToUpload);
|
||||
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
|
||||
void handleUploadEntries(initialEntriesToUpload);
|
||||
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
@@ -541,6 +543,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const hasFiles = files.length > 0;
|
||||
const hasDisplayFiles = sortedFiles.length > 0;
|
||||
const {
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
@@ -644,10 +648,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
|
||||
<SftpModalHeader
|
||||
onClose={handleClose}
|
||||
t={t}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
@@ -685,6 +692,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
onToggleShowHiddenFiles={() =>
|
||||
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
|
||||
}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
@@ -693,7 +704,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
isLocalSession={isLocalSession}
|
||||
files={files}
|
||||
hasFiles={hasFiles}
|
||||
hasDisplayFiles={hasDisplayFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
dragActive={dragActive}
|
||||
loading={loading}
|
||||
@@ -743,7 +755,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
<SftpModalDialogs
|
||||
t={t}
|
||||
@@ -798,7 +810,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
221
components/ScriptsSidePanel.tsx
Normal file
221
components/ScriptsSidePanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
|
||||
*
|
||||
* Shows snippets organized by package hierarchy with breadcrumb navigation.
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onSnippetClick,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1);
|
||||
return cleanPath.split('/')[0];
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
sn.command.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [snippets, selectedPackage, search]);
|
||||
|
||||
// Also filter packages by search when at root level
|
||||
const filteredPackages = useMemo(() => {
|
||||
if (!search.trim()) return displayedPackages;
|
||||
const s = search.toLowerCase();
|
||||
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
|
||||
}, [displayedPackages, search]);
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('snippets.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
|
||||
<button
|
||||
className={cn(
|
||||
"hover:text-primary transition-colors truncate",
|
||||
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedPackage(null)}
|
||||
>
|
||||
{t('terminal.toolbar.library')}
|
||||
</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<React.Fragment key={b.path}>
|
||||
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors truncate"
|
||||
onClick={() => setSelectedPackage(b.path)}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
{!hasAnyContent && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{filteredPackages.map((pkg) => (
|
||||
<button
|
||||
key={pkg.path}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<Package size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{pkg.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('snippets.package.count', { count: pkg.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsApplicationTab() {
|
||||
interface SettingsApplicationTabProps {
|
||||
updateState: UpdateState;
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
|
||||
const [hasAutoChecked, setHasAutoChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Auto check for updates when entering this page
|
||||
useEffect(() => {
|
||||
if (hasAutoChecked) return;
|
||||
if (updateState.isChecking) return;
|
||||
|
||||
// In demo mode or when we have a valid version, auto-check
|
||||
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
|
||||
if (!canCheck) return;
|
||||
|
||||
setHasAutoChecked(true);
|
||||
void checkNow();
|
||||
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
|
||||
t('update.available.message', { version: result.latestRelease.version }),
|
||||
t('update.available.title')
|
||||
);
|
||||
// Open the release page
|
||||
openReleasePage();
|
||||
// Don't auto-open the release page here — checkNow() already triggers
|
||||
// electron-updater on supported platforms, and the Settings > System tab
|
||||
// shows a "Manual Download" link on unsupported platforms.
|
||||
} else if (result) {
|
||||
setLastCheckResult('upToDate');
|
||||
toast.success(
|
||||
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
</span>
|
||||
{/* Update available badge - inline with version */}
|
||||
{updateState.hasUpdate && updateState.latestRelease && (
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
|
||||
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
|
||||
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
|
||||
"transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
v{updateState.latestRelease.version} {t('update.downloadNow')}
|
||||
v{updateState.latestRelease.version}{' '}
|
||||
{updateState.autoDownloadStatus === 'ready'
|
||||
? t('update.restartNow')
|
||||
: updateState.autoDownloadStatus === 'downloading'
|
||||
? `${updateState.downloadPercent}%`
|
||||
: t('update.downloadNow')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
@@ -15,24 +17,48 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
class AITabErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
|
||||
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
|
||||
<div>{this.state.error.message}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
@@ -53,8 +79,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -64,6 +90,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
onSettingsApplied={onSettingsApplied}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -71,6 +98,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -149,6 +178,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<Sparkles size={14} /> AI
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
@@ -165,7 +200,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && <SettingsApplicationTab />}
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsApplicationTab
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("appearance") && (
|
||||
<SettingsAppearanceTab
|
||||
@@ -218,9 +260,40 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsFileAssociationsTab />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
@@ -237,6 +310,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
618
components/SftpSidePanel.tsx
Normal file
618
components/SftpSidePanel.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
|
||||
*
|
||||
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
|
||||
* to provide a unified SFTP experience. Renders a single pane (left side only).
|
||||
*
|
||||
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
|
||||
* the main SftpView tab. Instead manages pane visibility internally.
|
||||
*
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
|
||||
import { SftpPaneView } from "./sftp/SftpPaneView";
|
||||
import { SftpOverlays } from "./sftp/SftpOverlays";
|
||||
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
|
||||
import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
pendingUpload?: {
|
||||
requestId: string;
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
targetPath?: string;
|
||||
entries: DropEntry[];
|
||||
} | null;
|
||||
onPendingUploadHandled?: (requestId: string) => void;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
toast.error(t('sftp.autoSync.error', { error: payload.error }));
|
||||
logger.error("[SFTP] File auto-sync failed", payload);
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
getOpenerForFileRef.current = getOpenerForFile;
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((paneId: string) => {
|
||||
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
const {
|
||||
leftPanes,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
// Auto-connect when activeHost changes.
|
||||
// Uses sftpRef to avoid re-triggering on every sftp state change.
|
||||
const connectedKeyRef = useRef<string | null>(null);
|
||||
// Store the Host object used for the current connection so the header
|
||||
// can show session-time overrides even during deferred host switches.
|
||||
const connectedHostObjRef = useRef<Host | null>(null);
|
||||
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
||||
const handledPendingUploadIdRef = useRef<string | null>(null);
|
||||
// Maps tab IDs to the connectionKey used to create them, so we can
|
||||
// correctly identify tabs when the same host ID has different overrides.
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
const pendingConnectionKeyRef = useRef<string | null>(null);
|
||||
const prevIsVisibleRef = useRef(isVisible);
|
||||
|
||||
// Reset location guard when the panel is reopened so the terminal cwd
|
||||
// is re-applied even if it matches the previous session's path.
|
||||
useEffect(() => {
|
||||
if (isVisible && !prevIsVisibleRef.current) {
|
||||
lastAppliedInitialLocationKeyRef.current = null;
|
||||
}
|
||||
prevIsVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Navigate SFTP to the terminal's current working directory
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd();
|
||||
if (cwd) {
|
||||
sftpRef.current.navigateTo("left", cwd);
|
||||
}
|
||||
}, [onGetTerminalCwd]);
|
||||
|
||||
// Track whether there's active work that should block connection switching.
|
||||
// Computed outside the effect so it can be in the dependency array.
|
||||
const hasActiveTransfers = useMemo(
|
||||
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
|
||||
[sftp.transfers],
|
||||
);
|
||||
// Block host-following while any connection-sensitive UI or operation
|
||||
// is active: text editor, permissions dialog, file-opener dialog, or
|
||||
// auto-synced external file watches.
|
||||
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost) return;
|
||||
|
||||
const s = sftpRef.current;
|
||||
|
||||
// Serial terminals don't support SFTP — disconnect any existing
|
||||
// connection (remote or local) so the panel doesn't remain bound to
|
||||
// a previous host.
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
// Serial terminals don't support SFTP. Just clear the tracked
|
||||
// connection key so switching back to a remote terminal will
|
||||
// trigger auto-connect. Don't disconnect existing tabs — they
|
||||
// may be reused when focus returns.
|
||||
connectedKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Local terminals connect to the local file browser
|
||||
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
|
||||
if (hasActiveWork) return;
|
||||
const leftConn = s.leftPane.connection;
|
||||
if (leftConn?.isLocal) {
|
||||
// Already connected locally
|
||||
connectedKeyRef.current = "local";
|
||||
return;
|
||||
}
|
||||
// Check for an existing local tab to reuse
|
||||
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
|
||||
tab.connection?.isLocal && tab.connection.status === "connected",
|
||||
);
|
||||
if (existingLocalTab) {
|
||||
s.selectTab("left", existingLocalTab.id);
|
||||
connectedKeyRef.current = "local";
|
||||
return;
|
||||
}
|
||||
connectedKeyRef.current = "local";
|
||||
// Preserve existing remote tab when switching to local
|
||||
const needsNewTab = !!(leftConn && leftConn.status === "connected");
|
||||
if (needsNewTab) {
|
||||
s.connect("left", "local", { forceNewTab: true });
|
||||
} else if (leftConn) {
|
||||
// Await disconnect before connecting locally to avoid the async
|
||||
// disconnect wiping out the fresh local connection.
|
||||
void s.disconnect("left").then(() => s.connect("left", "local"));
|
||||
} else {
|
||||
s.connect("left", "local");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Build a connection key that accounts for session-time overrides
|
||||
// (same host ID may have different port/protocol in different workspace panes).
|
||||
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
|
||||
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
|
||||
if (connectedKeyRef.current === connectionKey) return;
|
||||
|
||||
// Don't switch connections while transfers or editor are active
|
||||
if (hasActiveWork) return;
|
||||
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
||||
hostId: activeHost.id,
|
||||
hostLabel: activeHost.label,
|
||||
protocol: activeHost.protocol,
|
||||
hostname: activeHost.hostname,
|
||||
});
|
||||
|
||||
// Check if an existing SFTP tab matches this exact endpoint.
|
||||
// We track which connectionKey was used to create each tab so that
|
||||
// tabs for the same host ID with different session-time overrides
|
||||
// (port/protocol) are not incorrectly reused.
|
||||
const tabs = s.leftTabs.tabs;
|
||||
const existingTab = tabs.find((tab) => {
|
||||
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
|
||||
// Don't reuse errored tabs — they need a fresh connection
|
||||
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
|
||||
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
|
||||
});
|
||||
if (existingTab) {
|
||||
s.selectTab("left", existingTab.id);
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new tab when there's already an active connection to a different
|
||||
// host, so the previous tab is preserved for instant switching on focus change.
|
||||
const currentConn = s.leftPane.connection;
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
// Store the pending key so the effect below can map it once the tab is created
|
||||
pendingConnectionKeyRef.current = connectionKey;
|
||||
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
|
||||
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
|
||||
|
||||
// Track the active tab's connectionKey after connect() creates or reuses it.
|
||||
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
|
||||
useEffect(() => {
|
||||
const activeTabId = sftp.leftTabs.activeTabId;
|
||||
if (activeTabId && pendingConnectionKeyRef.current) {
|
||||
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
|
||||
pendingConnectionKeyRef.current = null;
|
||||
}
|
||||
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
|
||||
|
||||
// Clear the remembered connection key when the pane disconnects or the
|
||||
// session is lost, so re-opening SFTP for the same terminal reconnects.
|
||||
// Also reset the file-watch counter — watches are bound to the SFTP session,
|
||||
// so they stop when the session disconnects.
|
||||
useEffect(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
||||
connectedKeyRef.current = null;
|
||||
if (sftp.activeFileWatchCountRef) {
|
||||
sftp.activeFileWatchCountRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !initialLocation) return;
|
||||
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
|
||||
|
||||
const activePane = sftpRef.current.leftPane;
|
||||
const connection = activePane.connection;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
// Include full endpoint key so that same-hostId sessions with
|
||||
// different overrides each get their initial location applied.
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !activeHost) return;
|
||||
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
||||
if (pendingUpload.hostId !== activeHost.id) return;
|
||||
|
||||
const activePane = sftp.leftPane;
|
||||
const connection = activePane.connection;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
||||
|
||||
const runUpload = async () => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
|
||||
targetPath: pendingUpload.targetPath,
|
||||
});
|
||||
if (results.some((result) => result.cancelled)) {
|
||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
|
||||
const successCount = results.filter((result) => result.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
const message =
|
||||
successCount === 1
|
||||
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
|
||||
: `${t("sftp.uploadFiles")}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
|
||||
failedFiles.forEach((failed) => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
||||
toast.error(
|
||||
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
|
||||
"SFTP",
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
|
||||
handledPendingUploadIdRef.current = null;
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
onPendingUploadHandled?.(pendingUpload.requestId);
|
||||
}
|
||||
};
|
||||
|
||||
void runUpload();
|
||||
}, [
|
||||
activeHost,
|
||||
onPendingUploadHandled,
|
||||
pendingUpload,
|
||||
sftp.leftPane,
|
||||
t,
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!connection || connection.isLocal) return;
|
||||
|
||||
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
await sftpRef.current.navigateTo("left", revealPath, { force: true });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
(task: TransferTask) => {
|
||||
if (task.status !== "completed") return false;
|
||||
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
|
||||
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.isLocal) return false;
|
||||
|
||||
if (task.targetHostId) {
|
||||
if (connection.hostId !== task.targetHostId) return false;
|
||||
// If the transfer recorded a full endpoint key, use it to
|
||||
// distinguish same-hostId uploads with different session overrides.
|
||||
if (task.targetConnectionKey) {
|
||||
return connectedKeyRef.current === task.targetConnectionKey;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return connection.id === task.targetConnectionId;
|
||||
},
|
||||
[sftp.leftPane.connection],
|
||||
);
|
||||
|
||||
// When the auto-connect effect defers a switch (active transfers or open
|
||||
// editor), the panel still operates on the current connection, not
|
||||
// activeHost. Use the connected host for the header so the label matches
|
||||
// what browse/edit/delete actions actually target.
|
||||
const displayHost = useMemo(() => {
|
||||
const conn = sftp.leftPane.connection;
|
||||
if (conn && !conn.isLocal) {
|
||||
// Prefer the stored Host object from connect time — it preserves
|
||||
// session-time overrides that the vault host may lack.
|
||||
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
|
||||
return connectedHostObjRef.current;
|
||||
}
|
||||
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
|
||||
}
|
||||
return activeHost;
|
||||
}, [sftp.leftPane.connection, hosts, activeHost]);
|
||||
|
||||
// Determine the active pane to render (without using global activeTabStore)
|
||||
const activeLeftPaneId = sftp.leftTabs.activeTabId;
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DistroAvatar
|
||||
host={displayHost}
|
||||
fallback={displayHost.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
className="h-5 w-5 rounded-sm shrink-0"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* File browser pane - render only the active pane */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{leftPanes.map((pane, idx) => {
|
||||
// Manage visibility locally instead of via activeTabStore
|
||||
const isActive = activeLeftPaneId
|
||||
? pane.id === activeLeftPaneId
|
||||
: idx === 0;
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div key={pane.id} className="absolute inset-0 z-10">
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SftpTransferQueue
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderOverlays && (
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showTransferQueue={false}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
showHostPickerRight={showHostPickerRight}
|
||||
hostSearchLeft={hostSearchLeft}
|
||||
hostSearchRight={hostSearchRight}
|
||||
setShowHostPickerLeft={setShowHostPickerLeft}
|
||||
setShowHostPickerRight={setShowHostPickerRight}
|
||||
setHostSearchLeft={setHostSearchLeft}
|
||||
setHostSearchRight={setHostSearchRight}
|
||||
handleHostSelectLeft={handleHostSelectLeft}
|
||||
handleHostSelectRight={handleHostSelectRight}
|
||||
permissionsState={permissionsState}
|
||||
setPermissionsState={setPermissionsState}
|
||||
showTextEditor={showTextEditor}
|
||||
setShowTextEditor={setShowTextEditor}
|
||||
textEditorTarget={textEditorTarget}
|
||||
setTextEditorTarget={setTextEditorTarget}
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</SftpContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.renderOverlays === next.renderOverlays &&
|
||||
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
|
||||
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
|
||||
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
|
||||
SftpSidePanel.displayName = "SftpSidePanel";
|
||||
@@ -81,7 +81,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload]);
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
@@ -107,7 +108,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles: sftpShowHiddenFiles,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
@@ -118,6 +118,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
|
||||
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -225,7 +233,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -277,6 +284,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
@@ -333,6 +341,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onBulkSave: (snippets: Snippet[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onBulkSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
// Bulk-save all snippets to avoid stale-closure overwrites
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
// Validate: duplicate (case-insensitive), excluding the package being renamed
|
||||
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* No Auto Run */}
|
||||
<label className="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingSnippet.noAutoRun ?? false}
|
||||
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import type { CloudProvider } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider } from '../domain/sync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -122,12 +122,11 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
const isProviderActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
if (isProviderActive(sync.providers.github.status)) return 'github';
|
||||
if (isProviderActive(sync.providers.google.status)) return 'google';
|
||||
if (isProviderActive(sync.providers.onedrive.status)) return 'onedrive';
|
||||
if (isProviderActive(sync.providers.webdav.status)) return 'webdav';
|
||||
if (isProviderActive(sync.providers.s3.status)) return 's3';
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -136,9 +135,9 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.isSyncing) return 'syncing';
|
||||
if (sync.lastError) return 'error';
|
||||
if (sync.hasAnyConnectedProvider) return 'synced';
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -21,10 +21,14 @@ import {
|
||||
TerminalSettings,
|
||||
KeyBinding,
|
||||
} from "../types";
|
||||
import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
import SFTPModal from "./SFTPModal";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -114,10 +118,8 @@ interface TerminalProps {
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
serialConfig?: SerialConfig;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
hotkeyScheme?: "disabled" | "mac" | "pc";
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
@@ -137,6 +139,14 @@ interface TerminalProps {
|
||||
) => void;
|
||||
onSplitHorizontal?: () => void;
|
||||
onSplitVertical?: () => void;
|
||||
onOpenSftp?: (
|
||||
host: Host,
|
||||
initialPath?: string,
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
@@ -175,10 +185,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
serialConfig,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
@@ -193,6 +201,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
@@ -221,26 +232,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
// Merge global rules with host-level rules
|
||||
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
|
||||
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
|
||||
// Check if highlighting is enabled at either global or host level
|
||||
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
// Host-level toggle: undefined = inherit global, true/false = explicit override
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
|
||||
// If host explicitly disabled highlighting, disable everything for this terminal
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
|
||||
// Merge rules: include only rules from enabled sources
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
...(effectiveGlobalEnabled ? globalRules : []),
|
||||
...(effectiveHostEnabled ? hostRules : [])
|
||||
];
|
||||
|
||||
// Enable highlighting if either global or host-level is enabled
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
|
||||
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
}
|
||||
@@ -272,7 +287,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
|
||||
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
// isScriptsOpen state removed - scripts now handled by side panel
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
@@ -281,7 +296,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
@@ -297,7 +311,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Drag and drop state
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
||||
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
||||
@@ -359,6 +373,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// OSC-52 clipboard read prompt
|
||||
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
|
||||
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
|
||||
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
|
||||
// Reject if terminal is not visible (background tab) — user can't see the prompt
|
||||
if (!isVisibleRef.current) return Promise.resolve(false);
|
||||
// Reject if another prompt is already pending (avoid resolver overwrite)
|
||||
if (osc52ReadResolverRef.current) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
osc52ReadResolverRef.current = resolve;
|
||||
setOsc52ReadPromptVisible(true);
|
||||
});
|
||||
}, []);
|
||||
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
|
||||
setOsc52ReadPromptVisible(false);
|
||||
osc52ReadResolverRef.current?.(allowed);
|
||||
osc52ReadResolverRef.current = null;
|
||||
// Restore focus to terminal
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -415,9 +450,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
terminalSettings,
|
||||
terminalSettingsRef,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
isVisibleRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -454,6 +493,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
let disposed = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
setProgressLogs([]);
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
@@ -486,6 +526,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -500,12 +541,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
...(effectiveGlobalEnabled ? globalRules : []),
|
||||
...(effectiveHostEnabled ? hostRules : [])
|
||||
];
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
|
||||
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
|
||||
const term = runtime.term;
|
||||
@@ -631,12 +674,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
|
||||
}, [status, auth.needsAuth, host.protocol, host.hostname]);
|
||||
|
||||
const safeFit = () => {
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
if (options?.requireVisible && !isVisibleRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
// Terminal is hidden — invalidate the cached size so that when it
|
||||
// becomes visible again, a non-forced fit won't be suppressed by a
|
||||
// stale size match (e.g. after font metrics changed while hidden).
|
||||
lastFittedSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("Fit failed", err);
|
||||
@@ -703,13 +768,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
|
||||
|
||||
@@ -727,15 +793,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
const timer = setTimeout(() => safeFit(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
termRef.current?.scrollToBottom();
|
||||
});
|
||||
}
|
||||
pendingOutputScrollRef.current = false;
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -806,17 +882,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !fitAddonRef.current) return;
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isResizing) return;
|
||||
if (isResizing || !isVisibleRef.current) return;
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 250);
|
||||
});
|
||||
|
||||
@@ -831,7 +907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (prevIsResizingRef.current && !isResizing && isVisible) {
|
||||
const timer = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -841,7 +917,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inWorkspace, isVisible]);
|
||||
@@ -881,7 +957,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
term.onSelectionChange(onSelectionChange);
|
||||
const disposable = term.onSelectionChange(onSelectionChange);
|
||||
return () => disposable.dispose();
|
||||
}, [terminalSettings?.copyOnSelect]);
|
||||
|
||||
// Track whether the terminal application has enabled mouse tracking
|
||||
@@ -943,11 +1020,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (!isVisibleRef.current) return;
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 250);
|
||||
};
|
||||
|
||||
@@ -964,6 +1042,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
@@ -973,16 +1057,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scrollOnPasteRef,
|
||||
});
|
||||
|
||||
const handleSnippetClick = (cmd: string) => {
|
||||
if (sessionRef.current) {
|
||||
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
|
||||
setIsScriptsOpen(false);
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
if (sessionRef.current) {
|
||||
@@ -991,30 +1065,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
|
||||
if (showSFTP) {
|
||||
setShowSFTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the current working directory from the terminal session
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
|
||||
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
};
|
||||
|
||||
@@ -1142,30 +1214,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pathsText = paths.join(' ');
|
||||
// Write the paths to the terminal
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText);
|
||||
scrollToBottomAfterProgrammaticInput(pathsText);
|
||||
termRef.current.focus();
|
||||
}
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload
|
||||
// Get current working directory for SFTP initial path
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
|
||||
setPendingUploadEntries(dropEntries);
|
||||
// Use flushSync to ensure sftpInitialPath is updated synchronously
|
||||
// before setShowSFTP(true) triggers the modal open
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
@@ -1176,18 +1243,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const renderControls = (opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
snippets={snippets}
|
||||
host={host}
|
||||
defaultThemeId={terminalTheme.id}
|
||||
defaultFontFamilyId={fontFamilyId}
|
||||
defaultFontSize={fontSize}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
onOpenTheme={onOpenTheme ?? (() => {})}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
@@ -1621,7 +1680,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: effectiveTheme.colors.background }}
|
||||
>
|
||||
<div
|
||||
@@ -1646,6 +1705,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleOsc52ReadResponse(false);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
|
||||
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
|
||||
{t("terminal.osc52.readPrompt.deny")}
|
||||
</Button>
|
||||
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
|
||||
{t("terminal.osc52.readPrompt.allow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
@@ -1699,6 +1781,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (sessionRef.current) {
|
||||
const payload = text + '\r';
|
||||
terminalBackend.writeToSession(sessionRef.current, payload);
|
||||
scrollToBottomAfterProgrammaticInput(payload);
|
||||
onBroadcastInput?.(payload, sessionRef.current);
|
||||
}
|
||||
}}
|
||||
@@ -1710,78 +1793,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SFTPModal
|
||||
host={host}
|
||||
credentials={(() => {
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
|
||||
// Build proxy config if present
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build jump hosts array if host chain is configured
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => allHosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
username: resolvedAuth.username,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
password: resolvedAuth.password,
|
||||
privateKey: resolvedAuth.key?.privateKey,
|
||||
certificate: resolvedAuth.key?.certificate,
|
||||
passphrase: resolvedAuth.passphrase,
|
||||
publicKey: resolvedAuth.key?.publicKey,
|
||||
keyId: resolvedAuth.keyId,
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => {
|
||||
setShowSFTP(false);
|
||||
setPendingUploadEntries([]);
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
onUpdateHost={onUpdateHost}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Circle, LayoutGrid, Server } from 'lucide-react';
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
@@ -6,15 +6,25 @@ import { collectSessionIds } from '../domain/workspace';
|
||||
import { SplitDirection } from '../domain/workspace';
|
||||
import { KeyBinding, TerminalSettings } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
type WorkspaceRect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type SplitHint = {
|
||||
@@ -33,11 +43,34 @@ type ResizerHandle = {
|
||||
splitArea: { w: number; h: number };
|
||||
};
|
||||
|
||||
type PendingSftpUpload = {
|
||||
requestId: string;
|
||||
hostId: string;
|
||||
/** Full connection identity (id:hostname:port:protocol) for session-override awareness */
|
||||
connectionKey: string;
|
||||
targetPath?: string;
|
||||
entries: DropEntry[];
|
||||
};
|
||||
|
||||
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
let changed = false;
|
||||
const next = new Map<string, T>();
|
||||
for (const [id, value] of source) {
|
||||
if (validIds.has(id)) {
|
||||
next.set(id, value);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : source;
|
||||
};
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
knownHosts?: KnownHost[];
|
||||
@@ -69,6 +102,14 @@ interface TerminalLayerProps {
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDoubleClickBehavior: 'open' | 'transfer';
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -76,6 +117,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
sessions,
|
||||
workspaces,
|
||||
knownHosts = [],
|
||||
@@ -106,6 +148,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
updateHosts,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -184,6 +233,166 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// Workspace-level compose bar state
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
const activeTabIdRef = useRef(activeTabId);
|
||||
activeTabIdRef.current = activeTabId;
|
||||
const activeWorkspaceRef = useRef(activeWorkspace);
|
||||
activeWorkspaceRef.current = activeWorkspace;
|
||||
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
|
||||
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
|
||||
|
||||
// Side panel state - per-tab tracking of which sub-panel is active
|
||||
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
|
||||
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
|
||||
const stored = window.localStorage.getItem('netcatty_side_panel_width');
|
||||
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
|
||||
});
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
||||
);
|
||||
const sftpResizingRef = useRef(false);
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
|
||||
// The host to pass to the SFTP panel - stored when the user opens SFTP
|
||||
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
|
||||
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
|
||||
Map<string, { hostId: string; path: string }>
|
||||
>(new Map());
|
||||
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
|
||||
Map<string, PendingSftpUpload>
|
||||
>(new Map());
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const handleOpenSftp = useCallback((host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], sourceSessionId?: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
// When SFTP is opened from a non-focused workspace pane (toolbar click
|
||||
// or drag-drop), switch focus first so the SFTP panel binds to the
|
||||
// correct host.
|
||||
if (sourceSessionId) {
|
||||
const ws = activeWorkspaceRef.current;
|
||||
if (ws && ws.focusedSessionId !== sourceSessionId) {
|
||||
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
|
||||
const isOpen = currentPanel === 'sftp';
|
||||
const currentHost = sftpHostForTabRef.current.get(tabId);
|
||||
const shouldKeepOpen = !!pendingUploadEntries?.length;
|
||||
// Compare full endpoint identity so that session-time overrides
|
||||
// (different port/protocol for the same host ID) trigger a switch
|
||||
// instead of toggling the panel closed.
|
||||
const isSameEndpoint = currentHost
|
||||
&& currentHost.id === host.id
|
||||
&& currentHost.hostname === host.hostname
|
||||
&& currentHost.port === host.port
|
||||
&& currentHost.protocol === host.protocol
|
||||
&& currentHost.username === host.username
|
||||
&& currentHost.sftpSudo === host.sftpSudo;
|
||||
|
||||
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing) {
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, 'sftp');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Store or remove the host for this tab.
|
||||
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing) {
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, host);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (initialPath) {
|
||||
next.set(tabId, { hostId: host.id, path: initialPath });
|
||||
} else {
|
||||
next.delete(tabId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing || !pendingUploadEntries?.length) {
|
||||
// Clear any stale pending upload on close or when opening without new files
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, {
|
||||
requestId: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
connectionKey: buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
|
||||
targetPath: initialPath,
|
||||
entries: pendingUploadEntries,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.requestId !== requestId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Side panel resize handler
|
||||
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
sftpResizingRef.current = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidePanelWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
|
||||
setSidePanelWidth(lastWidth);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
sftpResizingRef.current = false;
|
||||
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [sidePanelWidth, sidePanelPosition]);
|
||||
|
||||
// Pre-compute host lookup map for O(1) access
|
||||
const hostMap = useMemo(() => {
|
||||
@@ -226,6 +435,89 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return map;
|
||||
}, [sessions, hostMap]);
|
||||
|
||||
const validTerminalTabIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) ids.add(session.id);
|
||||
for (const workspace of workspaces) ids.add(workspace.id);
|
||||
return ids;
|
||||
}, [sessions, workspaces]);
|
||||
|
||||
const onSplitSessionRef = useRef(onSplitSession);
|
||||
onSplitSessionRef.current = onSplitSession;
|
||||
const splitHorizontalHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
const splitVerticalHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
|
||||
for (const [id] of splitHorizontalHandlersRef.current) {
|
||||
if (!validSessionIds.has(id)) {
|
||||
splitHorizontalHandlersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id] of splitVerticalHandlersRef.current) {
|
||||
if (!validSessionIds.has(id)) {
|
||||
splitVerticalHandlersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!splitHorizontalHandlersRef.current.has(session.id)) {
|
||||
splitHorizontalHandlersRef.current.set(session.id, () => {
|
||||
onSplitSessionRef.current?.(session.id, 'horizontal');
|
||||
});
|
||||
}
|
||||
if (!splitVerticalHandlersRef.current.has(session.id)) {
|
||||
splitVerticalHandlersRef.current.set(session.id, () => {
|
||||
onSplitSessionRef.current?.(session.id, 'vertical');
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
const onToggleWorkspaceViewModeRef = useRef(onToggleWorkspaceViewMode);
|
||||
onToggleWorkspaceViewModeRef.current = onToggleWorkspaceViewMode;
|
||||
const workspaceFocusHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const onToggleBroadcastRef = useRef(onToggleBroadcast);
|
||||
onToggleBroadcastRef.current = onToggleBroadcast;
|
||||
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const validWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
|
||||
|
||||
for (const [id] of workspaceFocusHandlersRef.current) {
|
||||
if (!validWorkspaceIds.has(id)) {
|
||||
workspaceFocusHandlersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id] of workspaceBroadcastHandlersRef.current) {
|
||||
if (!validWorkspaceIds.has(id)) {
|
||||
workspaceBroadcastHandlersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspaceFocusHandlersRef.current.has(workspace.id)) {
|
||||
workspaceFocusHandlersRef.current.set(workspace.id, () => {
|
||||
onToggleWorkspaceViewModeRef.current?.(workspace.id);
|
||||
});
|
||||
}
|
||||
if (!workspaceBroadcastHandlersRef.current.has(workspace.id)) {
|
||||
workspaceBroadcastHandlersRef.current.set(workspace.id, () => {
|
||||
onToggleBroadcastRef.current?.(workspace.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
}, [validTerminalTabIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
const wTotal = size?.width || 1;
|
||||
@@ -435,6 +727,245 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isFocusMode = activeWorkspace?.viewMode === 'focus';
|
||||
const focusedSessionId = activeWorkspace?.focusedSessionId;
|
||||
|
||||
// Resolve the SFTP host for the current tab.
|
||||
// Uses the stored host from when the user opened SFTP, but updates when
|
||||
// the focused session changes in workspace mode.
|
||||
const sftpActiveHost = useMemo((): Host | null => {
|
||||
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
|
||||
// For workspace: follow focus
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
// For solo session: use stored host (from when SFTP was opened)
|
||||
return sftpHostForTab.get(activeTabId) ?? null;
|
||||
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, focusedSessionId, sessionHostsMap, sftpHostForTab]);
|
||||
|
||||
// Keep sftpHostForTab in sync with focus changes in workspace mode
|
||||
// so that the toggle check uses the currently displayed host.
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !sftpActiveHost) return;
|
||||
if (sidePanelOpenTabs.get(activeTabId) !== 'sftp') return;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
if (stored?.id === sftpActiveHost.id
|
||||
&& stored?.hostname === sftpActiveHost.hostname
|
||||
&& stored?.port === sftpActiveHost.port
|
||||
&& stored?.protocol === sftpActiveHost.protocol) return;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeTabId, sftpActiveHost);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId, sftpActiveHost, sidePanelOpenTabs, sftpHostForTab]);
|
||||
|
||||
const mountedSftpTabIds = useMemo(
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
return result.success && result.cwd ? result.cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
// Always clean up SFTP state (it may be mounted in the background
|
||||
// while scripts/theme tab was active)
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId]);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
if (!activeTabId) return;
|
||||
const currentPanel = sidePanelOpenTabsRef.current.get(activeTabId);
|
||||
|
||||
// If already on this tab, do nothing — user must click X to close
|
||||
if (currentPanel === tab) return;
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
if (tab === 'sftp' && !sftpHostForTabRef.current.has(activeTabId)) {
|
||||
let host: Host | null = null;
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
host = sessionHostsMap.get(focusedSessionId) ?? null;
|
||||
} else if (activeSession) {
|
||||
host = sessionHostsMap.get(activeSession.id) ?? null;
|
||||
}
|
||||
if (!host) return;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeTabId, host);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Note: When switching away from SFTP, we keep the SFTP host state
|
||||
// so the SftpSidePanel stays mounted (hidden) and preserves connections.
|
||||
// SFTP state is only cleaned up when the panel is fully closed.
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeTabId, tab);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId, activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
handleSwitchSidePanelTab('sftp');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open scripts side panel (called from Terminal toolbar)
|
||||
const handleOpenScripts = useCallback(() => {
|
||||
handleSwitchSidePanelTab('scripts');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open AI chat side panel
|
||||
const handleOpenAI = useCallback(() => {
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Listen for global AI panel toggle (from TopTabs button)
|
||||
useEffect(() => {
|
||||
const handler = () => handleOpenAI();
|
||||
window.addEventListener('netcatty:toggle-ai-panel', handler);
|
||||
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
|
||||
}, [handleOpenAI]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
if (!sessionId) return;
|
||||
const payload = noAutoRun ? command : `${command}\r`;
|
||||
terminalBackend.writeToSession(sessionId, payload);
|
||||
// Re-focus the terminal so the user can interact immediately
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
|
||||
// Resolve theme change handler for the focused session
|
||||
const focusedHost = useMemo((): Host | null => {
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
return sessionHostsMap.get(focusedSessionId) ?? null;
|
||||
}
|
||||
if (activeSession) {
|
||||
return sessionHostsMap.get(activeSession.id) ?? null;
|
||||
}
|
||||
return null;
|
||||
}, [activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
|
||||
|
||||
const isFocusedHostLocal = useMemo(() => {
|
||||
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
|
||||
}, [focusedHost]);
|
||||
|
||||
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, theme: themeId });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
// Current theme/font/size for the focused session (for ThemeSidePanel)
|
||||
const focusedThemeId = focusedHost?.theme ?? terminalTheme.id;
|
||||
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
|
||||
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
|
||||
|
||||
// AI Chat state
|
||||
const aiState = useAIState();
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
// On mount: clean up orphaned AI sessions after a short delay
|
||||
// (allows sessions/workspaces to fully initialize)
|
||||
const hasCleanedUpRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasCleanedUpRef.current) return;
|
||||
// Guard: wait until both sessions AND workspaces have loaded to avoid
|
||||
// racing with partial state (e.g. sessions loaded but workspaces not yet).
|
||||
if (sessions.length === 0 || workspaces.length === 0) return;
|
||||
hasCleanedUpRef.current = true;
|
||||
const activeIds = new Set<string>();
|
||||
for (const s of sessions) activeIds.add(s.id);
|
||||
for (const w of workspaces) activeIds.add(w.id);
|
||||
cleanupOrphanedSessions(activeIds);
|
||||
}, [sessions, workspaces, cleanupOrphanedSessions]);
|
||||
|
||||
// Build terminal session context for the AI chat panel
|
||||
const aiTerminalSessions = useMemo(() => {
|
||||
const sessionIds = activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root)
|
||||
: activeSession ? [activeSession.id] : [];
|
||||
|
||||
const result = sessionIds.map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: s?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || s?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: s?.status === 'connected',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -620,48 +1151,265 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full relative z-20",
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-full w-2 cursor-ew-resize z-30",
|
||||
sidePanelPosition === 'left' ? "right-[-3px]" : "left-[-3px]",
|
||||
)}
|
||||
onMouseDown={handleSidePanelResizeStart}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col overflow-hidden",
|
||||
!isSidePanelOpenForCurrentTab && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
<div className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'sftp'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
onClick={handleToggleSftpFromBar}
|
||||
title="SFTP"
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'scripts'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
onClick={handleOpenScripts}
|
||||
title="Scripts"
|
||||
>
|
||||
<Zap size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'theme'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
onClick={handleOpenTheme}
|
||||
title="Theme"
|
||||
>
|
||||
<Palette size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'ai'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
onClick={handleOpenAI}
|
||||
title="AI Chat"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
|
||||
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
|
||||
>
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
onClick={handleCloseSidePanel}
|
||||
title="Close panel"
|
||||
>
|
||||
<X size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{/* SFTP sub-panel */}
|
||||
{mountedSftpTabIds.map((tabId) => {
|
||||
const isVisibleSftpPanel = activeTabId === tabId && activeSidePanelTab === 'sftp';
|
||||
return (
|
||||
<SftpSidePanel
|
||||
key={tabId}
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
activeHost={isVisibleSftpPanel ? sftpActiveHost : null}
|
||||
initialLocation={
|
||||
isVisibleSftpPanel
|
||||
? (sftpInitialLocationForTab.get(tabId) ?? null)
|
||||
: null
|
||||
}
|
||||
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
|
||||
isVisible={isVisibleSftpPanel}
|
||||
renderOverlays={isVisibleSftpPanel}
|
||||
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
|
||||
onPendingUploadHandled={(requestId) => handlePendingUploadHandled(tabId, requestId)}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={isVisibleSftpPanel ? sftpAutoSync : false}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scripts sub-panel */}
|
||||
{activeSidePanelTab === 'scripts' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<ScriptsSidePanel
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onSnippetClick={handleSnippetClickForFocusedSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme sub-panel */}
|
||||
{activeSidePanelTab === 'theme' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<ThemeSidePanel
|
||||
currentThemeId={focusedThemeId}
|
||||
currentFontFamilyId={focusedFontFamilyId}
|
||||
currentFontSize={focusedFontSize}
|
||||
onThemeChange={handleThemeChangeForFocusedSession}
|
||||
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
|
||||
onFontSizeChange={handleFontSizeChangeForFocusedSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat sub-panel */}
|
||||
{activeSidePanelTab === 'ai' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
|
||||
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
|
||||
scopeHostIds={activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root).map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
return s?.hostId;
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
className="absolute inset-0 z-30"
|
||||
onDragOver={(e) => {
|
||||
if (isFocusMode) return;
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hint = computeSplitHint(e);
|
||||
setDropHint(hint);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleWorkspaceDrop(e);
|
||||
}}
|
||||
>
|
||||
{dropHint && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
|
||||
style={{
|
||||
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
|
||||
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
|
||||
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
|
||||
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
|
||||
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
className="absolute inset-0 z-30"
|
||||
onDragOver={(e) => {
|
||||
if (isFocusMode) return;
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hint = computeSplitHint(e);
|
||||
setDropHint(hint);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleWorkspaceDrop(e);
|
||||
}}
|
||||
>
|
||||
{dropHint && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
|
||||
style={{
|
||||
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
|
||||
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
|
||||
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
|
||||
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sessions.map(session => {
|
||||
// Use pre-computed host to avoid creating new objects on every render
|
||||
const host = sessionHostsMap.get(session.id)!;
|
||||
@@ -694,6 +1442,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// Check if this pane is the focused one in the workspace
|
||||
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
|
||||
const workspaceFocusHandler = activeWorkspace
|
||||
? workspaceFocusHandlersRef.current.get(activeWorkspace.id)
|
||||
: undefined;
|
||||
const workspaceBroadcastHandler = activeWorkspace
|
||||
? workspaceBroadcastHandlersRef.current.get(activeWorkspace.id)
|
||||
: undefined;
|
||||
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
|
||||
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -732,13 +1488,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
noAutoRun={session.noAutoRun}
|
||||
serialConfig={session.serialConfig}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onOpenSftp={handleOpenSftp}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onOpenTheme={handleOpenTheme}
|
||||
onCloseSession={handleCloseSession}
|
||||
onStatusChange={handleStatusChange}
|
||||
onSessionExit={handleSessionExit}
|
||||
@@ -747,12 +1504,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost={handleUpdateHost}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleCommandExecuted}
|
||||
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
|
||||
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
|
||||
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
|
||||
onExpandToFocus={inActiveWorkspace && !isFocusMode ? workspaceFocusHandler : undefined}
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
|
||||
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
|
||||
onToggleComposeBar={inActiveWorkspace ? () => setIsComposeBarOpen(prev => !prev) : undefined}
|
||||
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
|
||||
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
/>
|
||||
@@ -843,6 +1600,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
@@ -851,11 +1609,18 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { normalizeDistroId } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalSession, Workspace } from '../types';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
@@ -16,6 +18,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
hosts: Host[];
|
||||
sessions: TerminalSession[];
|
||||
orphanSessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
@@ -38,6 +41,82 @@ interface TopTabsProps {
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
const localOsId = (() => {
|
||||
if (typeof navigator === 'undefined') return 'linux';
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac/i.test(ua)) return 'macos';
|
||||
if (/Win/i.test(ua)) return 'windows';
|
||||
return 'linux';
|
||||
})();
|
||||
|
||||
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
|
||||
|
||||
// Serial protocol → USB icon
|
||||
if (protocol === 'serial' || host?.protocol === 'serial') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
const logo = DISTRO_LOGOS[localOsId];
|
||||
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
||||
if (logo) {
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={localOsId}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<TerminalSquare className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generic server icon for remote, terminal for unknown
|
||||
if (host && host.protocol !== 'local') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalSquare className={fallbackIcon} />;
|
||||
});
|
||||
SessionTabIcon.displayName = 'SessionTabIcon';
|
||||
|
||||
const sessionStatusDot = (status: TerminalSession['status']) => {
|
||||
const tone = status === 'connected'
|
||||
? "bg-emerald-400"
|
||||
@@ -56,12 +135,19 @@ const WindowControls: React.FC = memo(() => {
|
||||
// Check initial maximized state
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
|
||||
// Listen for window resize to update maximized state
|
||||
// Listen for window resize to update maximized state (debounced to avoid IPC storm)
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleResize = () => {
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
}, 200);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
};
|
||||
}, [fetchIsMaximized]);
|
||||
|
||||
const handleMinimize = () => {
|
||||
@@ -78,17 +164,17 @@ const WindowControls: React.FC = memo(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center app-drag">
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
@@ -101,7 +187,7 @@ const WindowControls: React.FC = memo(() => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -113,6 +199,7 @@ WindowControls.displayName = 'WindowControls';
|
||||
|
||||
const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
theme,
|
||||
hosts,
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
@@ -235,6 +322,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return map;
|
||||
}, [logViews]);
|
||||
|
||||
const hostMap = useMemo(() => {
|
||||
const map = new Map<string, Host>();
|
||||
for (const h of hosts) map.set(h.id, h);
|
||||
return map;
|
||||
}, [hosts]);
|
||||
|
||||
// Pre-compute session counts per workspace for O(1) access
|
||||
const workspacePaneCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -376,26 +469,29 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
activeTabId === session.id
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
|
||||
}}
|
||||
style={shiftStyle}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
|
||||
</div>
|
||||
@@ -445,23 +541,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
|
||||
}}
|
||||
style={shiftStyle}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
||||
@@ -495,12 +594,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-tab-id={logView.id}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
|
||||
<span className="truncate">
|
||||
@@ -536,36 +640,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full bg-secondary border-b border-border/60 app-drag"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={dragRegionNoSelect}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 flex items-center gap-2 app-drag"
|
||||
className="h-9 flex items-end gap-0 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isVaultActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
|
||||
>
|
||||
<Shield size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isSftpActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
|
||||
>
|
||||
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,7 +703,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={tabsContainerRef}
|
||||
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{renderOrderedTabs()}
|
||||
@@ -603,7 +712,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 app-no-drag"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
@@ -611,7 +720,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
{/* Draggable spacer - fixed width handle at the end */}
|
||||
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
|
||||
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
|
||||
</div>
|
||||
|
||||
{/* Right fade mask */}
|
||||
@@ -628,7 +737,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 app-no-drag"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
@@ -637,7 +746,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
)}
|
||||
|
||||
{/* Fixed right controls */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
@@ -653,9 +771,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
|
||||
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -665,6 +783,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
return (
|
||||
prev.theme === next.theme &&
|
||||
prev.hosts === next.hosts &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
|
||||
@@ -152,7 +152,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
|
||||
@@ -715,6 +715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
|
||||
// Generate all possible group paths from the tree (including all intermediate nodes)
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
|
||||
const traverse = (nodes: Record<string, GroupNode>) => {
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.path) {
|
||||
paths.add(node.path);
|
||||
}
|
||||
if (node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Traverse the tree
|
||||
traverse(buildGroupTree);
|
||||
|
||||
return Array.from(paths).sort();
|
||||
}, [buildGroupTree]);
|
||||
|
||||
const findGroupNode = (path: string | null): GroupNode | null => {
|
||||
if (!path)
|
||||
@@ -879,6 +899,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1718,7 +1749,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.hosts.length })}
|
||||
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2170,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: [...snippets, s],
|
||||
)
|
||||
}
|
||||
onBulkSave={onUpdateSnippets}
|
||||
onDelete={(id) =>
|
||||
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||||
}
|
||||
@@ -2270,12 +2302,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
groups={allGroupPaths}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
@@ -2310,12 +2337,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<SerialHostDetailsPanel
|
||||
initialData={editingHost}
|
||||
allTags={allTags}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
|
||||
89
components/ai-elements/conversation.tsx
Normal file
89
components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex flex-col gap-4 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
'shadow-sm',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
87
components/ai-elements/message.tsx
Normal file
87
components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import { code } from '@streamdown/code';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const streamdownPlugins = { cjk, code };
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
MessageResponse.displayName = 'MessageResponse';
|
||||
283
components/ai-elements/prompt-input.tsx
Normal file
283
components/ai-elements/prompt-input.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
|
||||
*
|
||||
* Simplified: no file attachments, screenshots, drag-drop, command palette,
|
||||
* hover cards, referenced sources, or tabs. Core input + footer + submit.
|
||||
*/
|
||||
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '../ui/input-group';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInput (form wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
||||
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
({ className, onSubmit, children, ...props }, ref) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const textarea = form.querySelector('textarea');
|
||||
const text = textarea?.value?.trim() ?? '';
|
||||
if (!text) return;
|
||||
onSubmit(text, e);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<form ref={ref} onSubmit={handleSubmit} className={className} {...props}>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInput.displayName = 'PromptInput';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTextarea
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
|
||||
/** Called when Enter is pressed (without Shift) to trigger form submit */
|
||||
onSubmitRequest?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
|
||||
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// CJK composition guard
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmitRequest?.();
|
||||
// Trigger form submit
|
||||
const form = internalRef.current?.closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmitRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={setRef}
|
||||
className={className}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputFooter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<InputGroupAddon
|
||||
ref={ref}
|
||||
align="block-end"
|
||||
className={cn('gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputFooter.displayName = 'PromptInputFooter';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTools (left side of footer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputButton (toolbar button with optional tooltip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
|
||||
tooltip?: ReactNode;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
|
||||
const button = <InputGroupButton ref={ref} {...props} />;
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputButton.displayName = 'PromptInputButton';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSubmit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
status?: PromptInputStatus;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
||||
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
|
||||
const isRunning = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning && onStop) {
|
||||
onStop();
|
||||
}
|
||||
}, [isRunning, onStop]);
|
||||
|
||||
const icon =
|
||||
status === 'submitted' ? (
|
||||
<Spinner size={14} />
|
||||
) : status === 'streaming' ? (
|
||||
<Square size={14} />
|
||||
) : status === 'error' ? (
|
||||
<X size={14} />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
);
|
||||
|
||||
const tooltipLabel =
|
||||
status === 'submitted'
|
||||
? 'Waiting...'
|
||||
: status === 'streaming'
|
||||
? 'Stop'
|
||||
: status === 'error'
|
||||
? 'Error'
|
||||
: 'Send';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
type={isRunning ? 'button' : 'submit'}
|
||||
onClick={isRunning ? handleClick : undefined}
|
||||
variant="ghost"
|
||||
disabled={disabled && !isRunning}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
|
||||
isRunning
|
||||
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
|
||||
: disabled
|
||||
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</InputGroupButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
65
components/ai-elements/tool-call.tsx
Normal file
65
components/ai-elements/tool-call.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
<span className="flex-1" />
|
||||
{statusIcon}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{result !== undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
<pre className={cn(
|
||||
'text-[11px] font-mono whitespace-pre-wrap break-all',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
components/ai/AgentIconBadge.tsx
Normal file
220
components/ai/AgentIconBadge.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type AgentLike = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'builtin' | 'external';
|
||||
icon?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'google'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
src: string;
|
||||
badgeClassName: string;
|
||||
imageClassName: string;
|
||||
};
|
||||
|
||||
const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
catty: {
|
||||
src: '/ai/agents/catty.svg',
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
claude: {
|
||||
src: '/ai/agents/claude.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
anthropic: {
|
||||
src: '/ai/providers/anthropic.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
gemini: {
|
||||
src: '/ai/agents/gemini.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
google: {
|
||||
src: '/ai/providers/google.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
ollama: {
|
||||
src: '/ai/providers/ollama.svg',
|
||||
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
openrouter: {
|
||||
src: '/ai/providers/openrouter.svg',
|
||||
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
zed: {
|
||||
src: '/ai/agents/zed.svg',
|
||||
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
atom: {
|
||||
src: '/ai/agents/atom.svg',
|
||||
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
terminal: {
|
||||
src: '/ai/agents/terminal.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
plus: {
|
||||
src: '/ai/agents/plus.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeToken(value?: string): string {
|
||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (agent === 'add-more') {
|
||||
return 'plus';
|
||||
}
|
||||
|
||||
if (agent.type === 'builtin') {
|
||||
return 'catty';
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
normalizeToken(agent.icon),
|
||||
normalizeToken(agent.command),
|
||||
normalizeToken(agent.name),
|
||||
normalizeToken(agent.id),
|
||||
].filter(Boolean);
|
||||
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('codex') ||
|
||||
token.includes('openai') ||
|
||||
token.includes('chatgpt'),
|
||||
)
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('gemini') ||
|
||||
token.includes('google') ||
|
||||
token.includes('googlegemini'),
|
||||
)
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('ollama'))) {
|
||||
return 'ollama';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('openrouter'))) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('zed'))) {
|
||||
return 'zed';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('factory'))) {
|
||||
return 'atom';
|
||||
}
|
||||
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
: size === 'sm'
|
||||
? 'h-7 w-7 rounded-lg'
|
||||
: size === 'lg'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-8 w-8 rounded-lg';
|
||||
const imageSize =
|
||||
size === 'xs'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'sm'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'lg'
|
||||
? 'h-5 w-5'
|
||||
: 'h-4 w-4';
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
visual.badgeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(imageSize, visual.imageClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentIconBadge;
|
||||
280
components/ai/AgentSelector.tsx
Normal file
280
components/ai/AgentSelector.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* AgentSelector - Dropdown for switching between AI agents
|
||||
*
|
||||
* Dark, grouped agent menu with local SVG branding for built-in,
|
||||
* discovered, and external agents.
|
||||
*/
|
||||
|
||||
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents?: DiscoveredAgent[];
|
||||
isDiscovering?: boolean;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
||||
onRediscover?: () => void;
|
||||
onManageAgents?: () => void;
|
||||
}
|
||||
|
||||
const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
{
|
||||
id: 'catty',
|
||||
name: 'Catty Agent',
|
||||
type: 'builtin',
|
||||
description: 'Built-in terminal assistant',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
||||
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
||||
{children}
|
||||
</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AgentMenuRow: React.FC<{
|
||||
agent: AgentInfo;
|
||||
isActive?: boolean;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ agent, isActive, subtitle, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
isActive && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate">{agent.name}</span>
|
||||
{subtitle && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
available: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
|
||||
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-foreground/86">{agent.name}</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
title={`Enable ${agent.name}`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents = [],
|
||||
isDiscovering = false,
|
||||
onSelectAgent,
|
||||
onEnableDiscoveredAgent,
|
||||
onRediscover,
|
||||
onManageAgents,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
.filter((agent) => agent.enabled)
|
||||
.map(
|
||||
(agent): AgentInfo => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
available: true,
|
||||
}),
|
||||
),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
// Discovered agents not yet added to external agents
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
const allAgents = useMemo(
|
||||
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
|
||||
[enabledExternalAgents],
|
||||
);
|
||||
|
||||
const currentAgent = useMemo(
|
||||
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
|
||||
[allAgents, currentAgentId],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(agentId: string) => {
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelectAgent],
|
||||
);
|
||||
|
||||
const handleEnableDiscovered = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
onEnableDiscoveredAgent?.(agent);
|
||||
// After enabling, auto-select it
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEnableDiscoveredAgent, onSelectAgent],
|
||||
);
|
||||
|
||||
const handleManageAgents = useCallback(() => {
|
||||
setOpen(false);
|
||||
onManageAgents?.();
|
||||
}, [onManageAgents]);
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
className="opacity-78"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
|
||||
{currentAgent.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{enabledExternalAgents.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
|
||||
{enabledExternalAgents.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
subtitle={agent.command}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{unconfiguredDiscovered.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title={t('ai.chat.rescan')}
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('ai.chat.detectedOnMachine')}
|
||||
</SectionLabel>
|
||||
{unconfiguredDiscovered.map((agent) => (
|
||||
<DiscoveredAgentRow
|
||||
key={agent.command}
|
||||
agent={agent}
|
||||
onEnable={() => handleEnableDiscovered(agent)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<button
|
||||
onClick={handleManageAgents}
|
||||
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
>
|
||||
<Settings size={16} className="opacity-72 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AgentSelector);
|
||||
559
components/ai/ChatInput.tsx
Normal file
559
components/ai/ChatInput.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* ChatInput - Zed-style bottom input area for the AI chat panel
|
||||
*
|
||||
* Thin wrapper around the AI Elements prompt-input components.
|
||||
* Bordered textarea with monospace placeholder, expand toggle,
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedImage } from '../../application/state/useImageUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
providerName?: string;
|
||||
modelName?: string;
|
||||
agentName?: string;
|
||||
placeholder?: string;
|
||||
/** Available model presets for the current agent */
|
||||
modelPresets?: AgentModelPreset[];
|
||||
/** Currently selected model ID */
|
||||
selectedModelId?: string;
|
||||
/** Callback when user selects a model */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
/** Attached images */
|
||||
images?: UploadedImage[];
|
||||
/** Callback to add images (paste/drop) */
|
||||
onAddImages?: (files: File[]) => void;
|
||||
/** Callback to remove an image */
|
||||
onRemoveImage?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
onPermissionModeChange?: (mode: AIPermissionMode) => void;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming = false,
|
||||
disabled = false,
|
||||
providerName,
|
||||
modelName,
|
||||
agentName,
|
||||
placeholder,
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setHoveredModelId(null);
|
||||
setShowHostSubmenu(false);
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
newValue.length > value.length &&
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
|
||||
}
|
||||
setActiveMenu('atMention');
|
||||
} else if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
const name = host.label || host.hostname;
|
||||
const lastAt = value.lastIndexOf('@');
|
||||
const newValue = lastAt >= 0
|
||||
? value.slice(0, lastAt) + `@${name} `
|
||||
: value + `@${name} `;
|
||||
onChange(newValue);
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length > 0) {
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
: t('ai.chat.placeholderDefault');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(_text: string, _event: FormEvent<HTMLFormElement>) => {
|
||||
onSend();
|
||||
},
|
||||
[onSend],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
>
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{img.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
{showAtMention && hosts.length > 0 && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
title="Attach"
|
||||
aria-label="Attach file"
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<FileText size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowHostSubmenu(true)}
|
||||
onMouseLeave={() => setShowHostSubmenu(false)}
|
||||
onFocus={() => setShowHostSubmenu(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
aria-expanded={showHostSubmenu && hosts.length > 0}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showHostSubmenu && hosts.length > 0 && (
|
||||
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const mention = `@${host.label || host.hostname} `;
|
||||
onChange(value + mention);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
<span className="truncate max-w-[82px]">{modelLabel}</span>
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="relative"
|
||||
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
|
||||
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!hasThinking) {
|
||||
onModelSelect?.(preset.id);
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
<div role="listbox" aria-label="Thinking level" className="absolute left-full top-0 ml-1 min-w-[120px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{preset.thinkingLevels!.map(level => {
|
||||
const fullId = `${preset.id}/${level}`;
|
||||
const isLevelSelected = selectedModelId === fullId;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isLevelSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/* Permission mode chip — only for Catty Agent */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
title={t('ai.safety.permissionMode')}
|
||||
aria-label="Permission mode"
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{([
|
||||
{ mode: 'autonomous' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
|
||||
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
|
||||
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
|
||||
]).map(({ mode, icon: Icon, color, label, desc }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={permissionMode === mode}
|
||||
onClick={() => {
|
||||
onPermissionModeChange(mode);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{permissionMode === mode
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />
|
||||
}
|
||||
<Icon size={12} className={`${color} shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground/85">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={!value.trim() || disabled}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatInput);
|
||||
196
components/ai/ChatMessageList.tsx
Normal file
196
components/ai/ChatMessageList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ChatMessageList - Renders the list of chat messages
|
||||
*
|
||||
* Claude-Code-style: user messages in bordered bubbles (right-aligned),
|
||||
* assistant responses as plain text (left-aligned, no border/bg).
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '../ai-elements/conversation';
|
||||
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { InlineApprovalCard } from './InlineApprovalCard';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onApprove?: (messageId: string) => void;
|
||||
onReject?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-[13px] text-muted-foreground/40 text-center">
|
||||
{t('ai.chat.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-1.5 px-4 py-2">
|
||||
{visibleMessages.map((message) => {
|
||||
if (message.role === 'tool') {
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isLastAssistant = message === lastAssistantMessage;
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
content={message.thinking}
|
||||
isStreaming={!!isThisStreaming && !message.content}
|
||||
durationMs={message.thinkingDurationMs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{message.images.map((img, i) => (
|
||||
<img
|
||||
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
|
||||
src={`data:${img.mediaType};base64,${img.base64Data}`}
|
||||
alt={img.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
|
||||
: <MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inline approval card */}
|
||||
{message.pendingApproval && (
|
||||
<InlineApprovalCard
|
||||
toolName={message.pendingApproval.toolName}
|
||||
toolArgs={message.pendingApproval.toolArgs}
|
||||
status={message.pendingApproval.status}
|
||||
onApprove={() => onApprove?.(message.id)}
|
||||
onReject={() => onReject?.(message.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
<div className="py-1">
|
||||
<span className="thinking-shimmer text-xs">{message.statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error info */}
|
||||
{message.errorInfo && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onApprove !== next.onApprove) return false;
|
||||
if (prev.onReject !== next.onReject) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
// Shallow-compare each message by reference
|
||||
for (let i = 0; i < prev.messages.length; i++) {
|
||||
if (prev.messages[i] !== next.messages[i]) {
|
||||
// For the last message during streaming, compare by content to avoid
|
||||
// re-renders when only the array reference changed but content is the same
|
||||
const p = prev.messages[i];
|
||||
const n = next.messages[i];
|
||||
if (
|
||||
p.id !== n.id ||
|
||||
p.content !== n.content ||
|
||||
p.thinking !== n.thinking ||
|
||||
p.role !== n.role ||
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.pendingApproval !== n.pendingApproval ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default React.memo(ChatMessageList, areMessagesEqual);
|
||||
82
components/ai/ConversationExport.tsx
Normal file
82
components/ai/ConversationExport.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ConversationExport - Dropdown button for exporting chat sessions
|
||||
*
|
||||
* Small download icon button with a dropdown offering Markdown, JSON,
|
||||
* and Plain Text export formats.
|
||||
*/
|
||||
|
||||
import { Download, FileJson, FileText, FileType } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
onExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
|
||||
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
|
||||
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
|
||||
];
|
||||
|
||||
const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
session,
|
||||
onExport,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const handleExport = useCallback(
|
||||
(format: 'md' | 'json' | 'txt') => {
|
||||
onExport(format);
|
||||
},
|
||||
[onExport],
|
||||
);
|
||||
|
||||
const hasMessages = session && session.messages.length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
{t('ai.chat.exportAs')}
|
||||
</div>
|
||||
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => handleExport(format)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConversationExport);
|
||||
169
components/ai/ExecutionPlan.tsx
Normal file
169
components/ai/ExecutionPlan.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
193
components/ai/InlineApprovalCard.tsx
Normal file
193
components/ai/InlineApprovalCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
|
||||
*
|
||||
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
|
||||
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
|
||||
*/
|
||||
|
||||
import { Check, ShieldAlert, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface InlineApprovalCardProps {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
|
||||
toolName,
|
||||
toolArgs,
|
||||
status,
|
||||
onApprove,
|
||||
onReject,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const isPending = status === 'pending';
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
// Use refs to always access the latest callbacks without re-registering the listener
|
||||
const onApproveRef = useRef(onApprove);
|
||||
const onRejectRef = useRef(onReject);
|
||||
onApproveRef.current = onApprove;
|
||||
onRejectRef.current = onReject;
|
||||
|
||||
const isDisabled = !isPending || responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onApproveRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onRejectRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
|
||||
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
}
|
||||
}, [isDisabled, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus approve button and auto-scroll into view when mounted as pending
|
||||
useEffect(() => {
|
||||
if (isPending && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
approveBtnRef.current?.focus();
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
let formattedArgs: string;
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolArgs, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolArgs);
|
||||
}
|
||||
|
||||
// Extract target session info if present
|
||||
const sessionId = toolArgs?.sessionId as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={0}
|
||||
role="alertdialog"
|
||||
aria-label="Tool execution approval required"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
|
||||
isPending
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: status === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: 'border-red-500/20 bg-red-500/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<ShieldAlert
|
||||
size={13}
|
||||
className={
|
||||
isPending
|
||||
? 'text-yellow-500/70 shrink-0'
|
||||
: status === 'approved'
|
||||
? 'text-green-400/70 shrink-0'
|
||||
: 'text-red-400/70 shrink-0'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground/70">
|
||||
{t('ai.chat.toolApprovalTitle')}
|
||||
</span>
|
||||
{!isPending && (
|
||||
<Badge
|
||||
className={`ml-auto text-[10px] px-1.5 py-0 ${
|
||||
status === 'approved'
|
||||
? 'bg-green-600/20 text-green-400 border-green-600/30'
|
||||
: 'bg-red-600/20 text-red-400 border-red-600/30'
|
||||
}`}
|
||||
>
|
||||
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool info */}
|
||||
<div className="px-3 pb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{toolName}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions or hint */}
|
||||
{isPending && (
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground/30">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineApprovalCard.displayName = 'InlineApprovalCard';
|
||||
|
||||
export default InlineApprovalCard;
|
||||
export { InlineApprovalCard };
|
||||
export type { InlineApprovalCardProps };
|
||||
200
components/ai/PermissionDialog.tsx
Normal file
200
components/ai/PermissionDialog.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
138
components/ai/ThinkingBlock.tsx
Normal file
138
components/ai/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ThinkingBlock - Collapsible thinking/reasoning display
|
||||
*
|
||||
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
|
||||
* - When done: auto-collapses to "Thought for Xs", click to expand
|
||||
* - Content area has max-height with scroll and top gradient fade
|
||||
*/
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}m ${remaining}s`;
|
||||
}
|
||||
|
||||
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
durationMs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = useState(isStreaming);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const wasStreamingRef = useRef(false);
|
||||
const startRef = useRef(Date.now());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-collapse when streaming ends
|
||||
useEffect(() => {
|
||||
if (wasStreamingRef.current && !isStreaming) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
// Expand when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setIsExpanded(true);
|
||||
startRef.current = Date.now();
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Elapsed time ticker
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - startRef.current);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-scroll to bottom while streaming
|
||||
useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, isStreaming, isExpanded]);
|
||||
|
||||
const toggle = useCallback(() => setIsExpanded(e => !e), []);
|
||||
|
||||
const displayDuration = durationMs || elapsed;
|
||||
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
|
||||
|
||||
return (
|
||||
<div className="mb-0.5">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="thinking-block-content"
|
||||
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
!isExpanded && 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
|
||||
) : (
|
||||
displayDuration > 0
|
||||
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
|
||||
: t('ai.chat.thought')
|
||||
)}
|
||||
</span>
|
||||
{isStreaming && elapsed > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !isStreaming && preview && (
|
||||
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
|
||||
{preview}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && content && (
|
||||
<div id="thinking-block-content" className="relative">
|
||||
{/* Top gradient fade */}
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
|
||||
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
|
||||
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ThinkingBlock);
|
||||
769
components/ai/hooks/useAIChatStreaming.ts
Normal file
769
components/ai/hooks/useAIChatStreaming.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
/**
|
||||
* useAIChatStreaming — Encapsulates all streaming logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Catty agent streaming via Vercel AI SDK `streamText`
|
||||
* - External agent streaming (ACP and raw process)
|
||||
* - Text-delta batching via requestAnimationFrame
|
||||
* - Abort controller management
|
||||
* - Stream state tracking (per-session)
|
||||
* - Error reporting
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Shape of a text/text-delta chunk from the Vercel AI SDK fullStream. */
|
||||
interface TextDeltaChunk {
|
||||
type: 'text' | 'text-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
}
|
||||
|
||||
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
|
||||
interface ReasoningChunk {
|
||||
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolCallChunk {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolResultChunk {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolApprovalRequestChunk {
|
||||
type: 'tool-approval-request';
|
||||
approvalId: string;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
|
||||
interface ErrorChunk {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** Union of all stream chunk shapes we handle. */
|
||||
type StreamChunk =
|
||||
| TextDeltaChunk
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolApprovalRequestChunk
|
||||
| ErrorChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
credentialsDecrypt?: (value: string) => Promise<string>;
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
/** Terminal session info used throughout the streaming hooks. */
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).netcatty as PanelBridge | undefined;
|
||||
}
|
||||
|
||||
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
|
||||
export interface ApprovalInfo {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Pending approval context stored between approval request and user response. */
|
||||
export interface PendingApprovalContext {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
sdkMessages: Array<ModelMessage>;
|
||||
approvalInfo: ApprovalInfo;
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingParams {
|
||||
maxIterations: number;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingReturn {
|
||||
/** Set of session IDs currently streaming. */
|
||||
streamingSessionIds: Set<string>;
|
||||
/** Set or unset streaming state for a session. */
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
/** Ref to per-session abort controllers. */
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
/** Process a Catty agent stream, returning approval info if one is requested. */
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => Promise<void>;
|
||||
/** Report a streaming error to the chat. */
|
||||
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToCattyAgent that change frequently (avoids stale closures). */
|
||||
export interface SendToCattyContext {
|
||||
activeProvider: ProviderConfig | undefined;
|
||||
activeModelId: string;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
export interface SendToExternalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
// Per-session streaming state (keyed by sessionId)
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
|
||||
const setStreamingForScope = useCallback((key: string, val: boolean) => {
|
||||
setStreamingSessions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (val) next.add(key); else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Per-scope abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// reportStreamError
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const reportStreamError = useCallback((
|
||||
sessionId: string,
|
||||
abortSignal: AbortSignal,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
|
||||
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [updateLastMessage, addMessageToSession]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// processCattyStream
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const processCattyStream = useCallback(async (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
): Promise<ApprovalInfo | null> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
system: systemPrompt,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
const reader = result.fullStream.getReader();
|
||||
let pendingApprovalInfo: ApprovalInfo | null = null;
|
||||
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText) {
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const cancelPendingFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Use the StreamChunk union for type narrowing instead of unsafe casts
|
||||
const chunk = value as StreamChunk;
|
||||
switch (chunk.type) {
|
||||
case 'text':
|
||||
case 'text-delta': {
|
||||
const typedChunk = chunk as TextDeltaChunk;
|
||||
const text = typedChunk.text ?? typedChunk.textDelta;
|
||||
if (text) {
|
||||
pendingText += text;
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(flushText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning':
|
||||
case 'reasoning-start':
|
||||
case 'reasoning-delta': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ReasoningChunk;
|
||||
const rText = typedChunk.text;
|
||||
if (rText) {
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
thinking: rText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + rText,
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning-end':
|
||||
case 'text-start':
|
||||
case 'text-end':
|
||||
case 'start':
|
||||
case 'finish':
|
||||
case 'start-step':
|
||||
case 'finish-step':
|
||||
break;
|
||||
case 'tool-call': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), {
|
||||
id: typedChunk.toolCallId,
|
||||
name: typedChunk.toolName,
|
||||
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
|
||||
}],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolResultChunk;
|
||||
// Mark the assistant message's tool execution as completed
|
||||
updateMessageById(streamSessionId, activeMsgId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolResults: [{
|
||||
toolCallId: typedChunk.toolCallId,
|
||||
content: typeof toolOutput === 'string'
|
||||
? toolOutput
|
||||
: JSON.stringify(toolOutput),
|
||||
isError: false,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
});
|
||||
lastAddedRole = 'tool';
|
||||
break;
|
||||
}
|
||||
case 'tool-approval-request': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolApprovalRequestChunk;
|
||||
pendingApprovalInfo = {
|
||||
approvalId: typedChunk.approvalId,
|
||||
toolCallId: typedChunk.toolCall.toolCallId,
|
||||
toolName: typedChunk.toolCall.toolName,
|
||||
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
|
||||
};
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: {
|
||||
...pendingApprovalInfo!,
|
||||
status: 'pending' as const,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
return pendingApprovalInfo;
|
||||
}, [maxIterations, addMessageToSession, updateMessageById]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToExternalAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Push terminal session metadata to MCP bridge
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
|
||||
const agentProviderId = openaiProvider?.id;
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
if (needsNewAssistantMsg) {
|
||||
needsNewAssistantMsg = false;
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: agentConfig.name || 'external',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
},
|
||||
onThinkingDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
},
|
||||
onThinkingDone: () => {
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string) => {
|
||||
updateLastMessage(sessionId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
timestamp: Date.now(), executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
},
|
||||
onStatus: (message: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
context.selectedAgentModel,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
|
||||
abortController.signal,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToCattyAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToCattyAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const tools = createCattyTools(bridge, {
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode, context.webSearchConfig ?? undefined);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
hosts: context.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
if (!context.activeProvider) {
|
||||
reportStreamError(sessionId, abortController.signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
// keys never transit the renderer ↔ main IPC boundary.
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig({
|
||||
...context.activeProvider,
|
||||
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
// so the LLM maintains full conversation context
|
||||
const allMessages = currentSession?.messages ?? [];
|
||||
|
||||
// Collect all tool call IDs that have a corresponding tool result,
|
||||
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
|
||||
const resolvedToolCallIds = new Set<string>();
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'tool' && m.toolResults) {
|
||||
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
const findToolName = (toolCallId: string): string => {
|
||||
for (const prev of allMessages) {
|
||||
if (prev.role === 'assistant' && prev.toolCalls) {
|
||||
const tc = prev.toolCalls.find(t => t.id === toolCallId);
|
||||
if (tc) return tc.name;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
|
||||
const contentParts: Array<
|
||||
{ type: 'text'; text: string } |
|
||||
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
> = [];
|
||||
if (m.content) {
|
||||
contentParts.push({ type: 'text' as const, text: m.content });
|
||||
}
|
||||
for (const tc of resolvedCalls) {
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
});
|
||||
}
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
|
||||
}
|
||||
} else if (m.content) {
|
||||
sdkMessages.push({ role: 'assistant', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: findToolName(tr.toolCallId),
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
} finally {
|
||||
// Clear any lingering statusText when the stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
context.autoTitleSession(sessionId, trimmed);
|
||||
}
|
||||
}, [
|
||||
processCattyStream, reportStreamError, setStreamingForScope,
|
||||
updateLastMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
};
|
||||
}
|
||||
76
components/ai/hooks/useConversationExport.ts
Normal file
76
components/ai/hooks/useConversationExport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Export in markdown, JSON, and plain text formats
|
||||
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AISession } from '../../../infrastructure/ai/types';
|
||||
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseConversationExportReturn {
|
||||
/** Trigger a download of the active session in the given format. */
|
||||
handleExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
|
||||
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useConversationExport(
|
||||
activeSession: AISession | null,
|
||||
): UseConversationExportReturn {
|
||||
// Ref to track active object URLs for cleanup on unmount (Issue #19)
|
||||
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clean up object URLs on unmount
|
||||
useEffect(() => {
|
||||
const urls = activeObjectUrlsRef.current;
|
||||
return () => {
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
|
||||
if (!activeSession) return;
|
||||
let content: string;
|
||||
switch (format) {
|
||||
case 'md': content = exportAsMarkdown(activeSession); break;
|
||||
case 'json': content = exportAsJSON(activeSession); break;
|
||||
case 'txt': content = exportAsPlainText(activeSession); break;
|
||||
}
|
||||
const filename = getExportFilename(activeSession, format);
|
||||
// Create a download blob
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// Track URL for cleanup on unmount (Issue #19)
|
||||
activeObjectUrlsRef.current.add(url);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke after a generous delay to ensure download completes, then remove from tracking set
|
||||
const revokeTimeout = setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
activeObjectUrlsRef.current.delete(url);
|
||||
}, 60_000); // 60 seconds to be safe for large files
|
||||
// If component unmounts before timeout, cleanup effect will revoke it
|
||||
void revokeTimeout; // suppress unused warning
|
||||
}, [activeSession]);
|
||||
|
||||
return {
|
||||
handleExport,
|
||||
activeObjectUrlsRef,
|
||||
};
|
||||
}
|
||||
284
components/ai/hooks/useToolApproval.ts
Normal file
284
components/ai/hooks/useToolApproval.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Pending approval context management
|
||||
* - Approval timeout (auto-clear after 5 minutes)
|
||||
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
|
||||
* - Resuming the Catty stream after approval
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
ChatMessage,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
TerminalSessionInfo,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalParams {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalReturn {
|
||||
/** Ref to the current pending approval context (null when none). */
|
||||
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
|
||||
/** Set or clear the pending approval context (manages timeout). */
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
/** Handle a user's approve/reject response from InlineApprovalCard. */
|
||||
handleApprovalResponse: (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
}: UseToolApprovalParams): UseToolApprovalReturn {
|
||||
// Pending approval context — stores SDK state needed to resume after user approves/rejects
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
|
||||
|
||||
// Timeout ID for auto-clearing stale pending approval (Issue #14)
|
||||
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (pendingApprovalTimeoutRef.current) {
|
||||
clearTimeout(pendingApprovalTimeoutRef.current);
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
}
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
pendingApprovalTimeoutRef.current = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
abortControllersRef.current.delete(ctx.sessionId);
|
||||
// Notify the user that the approval timed out
|
||||
updateLastMessage(ctx.sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(ctx.sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: t('ai.chat.approvalTimeout'),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
|
||||
// Handle inline approval response (approve/reject from InlineApprovalCard)
|
||||
const handleApprovalResponse = useCallback(async (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => {
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
// Update the message's pendingApproval status using message ID
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: msg.pendingApproval
|
||||
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (!approved) {
|
||||
// User rejected — add denial text and stop
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
|
||||
statusText: '',
|
||||
executionStatus: 'completed',
|
||||
}));
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
// User approved — construct SDK messages with approval response and resume
|
||||
const resumeMessages: Array<Record<string, unknown>> = [
|
||||
...sdkMessages,
|
||||
// The assistant message that contained the tool call + approval request
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
toolName: approvalInfo.toolName,
|
||||
input: approvalInfo.toolArgs,
|
||||
},
|
||||
{
|
||||
type: 'tool-approval-request',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
},
|
||||
],
|
||||
},
|
||||
// The user's approval response
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-approval-response',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
approved: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Create a new assistant message placeholder for the continuation
|
||||
const newAssistantMsgId = generateId();
|
||||
addMessageToSession(sid, {
|
||||
id: newAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sid, abortController);
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale closure issues (e.g. user changed permission mode during approval wait)
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
|
||||
});
|
||||
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
|
||||
|
||||
if (newApprovalInfo) {
|
||||
// Another approval needed — save context for the next round (with timeout)
|
||||
setPendingApproval({
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages: resumeMessages,
|
||||
approvalInfo: newApprovalInfo,
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty resume] streamText error:', err);
|
||||
if (!abortController.signal.aborted) {
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
updateMessageById(sid, newAssistantMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sid, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(errorStr),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
|
||||
// Clear any lingering statusText when the resumed stream finishes
|
||||
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, abortControllersRef, t, setPendingApproval,
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
};
|
||||
}
|
||||
@@ -34,13 +34,21 @@ export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) =
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string; icon?: React.ReactNode }[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => {
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) => {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
@@ -50,7 +58,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>{selectedOption?.label ?? value}</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Value placeholder={placeholder}>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedOption?.icon}
|
||||
{selectedOption?.label}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
@@ -76,7 +89,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
@@ -120,4 +138,3 @@ export const SettingsTabContent: React.FC<{
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
|
||||
540
components/settings/tabs/SettingsAITab.tsx
Normal file
540
components/settings/tabs/SettingsAITab.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* Settings AI Tab - AI provider configuration, agent CLI detection, and safety settings
|
||||
*
|
||||
* Sub-components live in ./ai/ directory:
|
||||
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
|
||||
* - ModelSelector, ProviderIconBadge
|
||||
* - CodexConnectionCard, ClaudeCodeCard
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
|
||||
import type {
|
||||
AgentPathInfo,
|
||||
CodexIntegrationStatus,
|
||||
CodexLoginSession,
|
||||
} from "./ai/types";
|
||||
import {
|
||||
AGENT_DEFAULTS,
|
||||
getBridge,
|
||||
normalizeCodexBridgeError,
|
||||
} from "./ai/types";
|
||||
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
|
||||
import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SettingsAITabProps {
|
||||
providers: ProviderConfig[];
|
||||
addProvider: (provider: ProviderConfig) => void;
|
||||
updateProvider: (id: string, updates: Partial<ProviderConfig>) => void;
|
||||
removeProvider: (id: string) => void;
|
||||
activeProviderId: string;
|
||||
setActiveProviderId: (id: string) => void;
|
||||
activeModelId: string;
|
||||
setActiveModelId: (id: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
defaultAgentId: string;
|
||||
setDefaultAgentId: (id: string) => void;
|
||||
commandBlocklist: string[];
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
providers,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId: _activeModelId,
|
||||
setActiveModelId,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
|
||||
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
const [codexError, setCodexError] = useState<string | null>(null);
|
||||
|
||||
// Path detection state
|
||||
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [codexCustomPath, setCodexCustomPath] = useState("");
|
||||
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
|
||||
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
const result = await bridge.aiResolveCli({
|
||||
command: agentKey,
|
||||
customPath: customPath.trim(),
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
(providerId: AIProviderId) => {
|
||||
const preset = PROVIDER_PRESETS[providerId];
|
||||
const id = `provider_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
addProvider({
|
||||
id,
|
||||
providerId,
|
||||
name: preset.name,
|
||||
baseURL: preset.defaultBaseURL,
|
||||
enabled: false,
|
||||
});
|
||||
// Auto-open config form
|
||||
setEditingProviderId(id);
|
||||
},
|
||||
[addProvider],
|
||||
);
|
||||
|
||||
// Remove provider with confirmation
|
||||
const handleRemoveProvider = useCallback(
|
||||
(id: string) => {
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
const name = provider?.name || id;
|
||||
const ok = window.confirm(
|
||||
t('confirm.removeProvider', { name }),
|
||||
);
|
||||
if (!ok) return;
|
||||
removeProvider(id);
|
||||
if (editingProviderId === id) {
|
||||
setEditingProviderId(null);
|
||||
}
|
||||
},
|
||||
[removeProvider, editingProviderId, providers, t],
|
||||
);
|
||||
|
||||
// Agent options for default agent
|
||||
const agentOptions = useMemo(() => [
|
||||
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
|
||||
...externalAgents
|
||||
.filter((a) => a.enabled)
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCodexIntegration();
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codexLoginSession || codexLoginSession.state !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetLoginSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void bridge.aiCodexGetLoginSession?.(codexLoginSession.sessionId).then((result) => {
|
||||
if (cancelled || !result?.ok || !result.session) return;
|
||||
|
||||
setCodexLoginSession(result.session);
|
||||
if (result.session.state !== "running") {
|
||||
if (result.session.state === "success") {
|
||||
void refreshCodexIntegration();
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [codexLoginSession, refreshCodexIntegration]);
|
||||
|
||||
const handleStartCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexStartLogin) return;
|
||||
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexStartLogin();
|
||||
if (!result.ok || !result.session) {
|
||||
throw new Error(result.error || "Failed to start Codex login");
|
||||
}
|
||||
setCodexLoginSession(result.session);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCancelCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexCancelLogin || !codexLoginSession) return;
|
||||
|
||||
setCodexError(null);
|
||||
try {
|
||||
const result = await bridge.aiCodexCancelLogin(codexLoginSession.sessionId);
|
||||
if (result.session) {
|
||||
setCodexLoginSession(result.session);
|
||||
}
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
}, [codexLoginSession]);
|
||||
|
||||
const handleOpenCodexLoginUrl = useCallback(() => {
|
||||
const bridge = getBridge();
|
||||
const url = codexLoginSession?.url;
|
||||
if (!bridge?.openExternal || !url) return;
|
||||
// Only allow https:// URLs to prevent opening arbitrary protocols
|
||||
if (!url.startsWith("https://")) return;
|
||||
void bridge.openExternal(url);
|
||||
}, [codexLoginSession]);
|
||||
|
||||
const handleCodexLogout = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexLogout) return;
|
||||
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexLogout();
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to log out from Codex");
|
||||
}
|
||||
setCodexLoginSession(null);
|
||||
await refreshCodexIntegration();
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="ai"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('ai.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* -- Providers Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.providers')}</h3>
|
||||
</div>
|
||||
<AddProviderDropdown onAdd={handleAddProvider} />
|
||||
</div>
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
|
||||
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ai.providers.empty')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isActive={provider.id === activeProviderId}
|
||||
onToggleEnabled={(enabled) => {
|
||||
if (enabled) {
|
||||
// Activate this provider, deactivate all others
|
||||
setActiveProviderId(provider.id);
|
||||
if (provider.defaultModel) {
|
||||
setActiveModelId(provider.defaultModel);
|
||||
}
|
||||
for (const p of providers) {
|
||||
if (p.id === provider.id) {
|
||||
if (!p.enabled) updateProvider(p.id, { enabled: true });
|
||||
} else {
|
||||
if (p.enabled) updateProvider(p.id, { enabled: false });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deactivate this provider
|
||||
if (activeProviderId === provider.id) {
|
||||
setActiveProviderId("");
|
||||
setActiveModelId("");
|
||||
}
|
||||
updateProvider(provider.id, { enabled: false });
|
||||
}
|
||||
}}
|
||||
onEdit={() =>
|
||||
setEditingProviderId(
|
||||
editingProviderId === provider.id ? null : provider.id,
|
||||
)
|
||||
}
|
||||
onRemove={() => handleRemoveProvider(provider.id)}
|
||||
onUpdate={(updates) => {
|
||||
updateProvider(provider.id, updates);
|
||||
// If this is the active provider and model changed, update activeModelId
|
||||
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
|
||||
setActiveModelId(updates.defaultModel || "");
|
||||
}
|
||||
}}
|
||||
isEditing={editingProviderId === provider.id}
|
||||
onCancelEdit={() => setEditingProviderId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* -- Codex Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="openai" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.codex')}</h3>
|
||||
</div>
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
onLogout={() => void handleCodexLogout()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Claude Code Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="claude" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.claude.title')}</h3>
|
||||
</div>
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.defaultAgent')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<SettingRow
|
||||
label={t('ai.defaultAgent')}
|
||||
description={t('ai.defaultAgent.description')}
|
||||
>
|
||||
<Select
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- Web Search Section -- */}
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
setWebSearchConfig={setWebSearchConfig}
|
||||
/>
|
||||
|
||||
{/* -- Safety Section -- */}
|
||||
<SafetySettings
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
commandBlocklist={commandBlocklist}
|
||||
setCommandBlocklist={setCommandBlocklist}
|
||||
commandTimeout={commandTimeout}
|
||||
setCommandTimeout={setCommandTimeout}
|
||||
maxIterations={maxIterations}
|
||||
setMaxIterations={setMaxIterations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAITab;
|
||||
@@ -5,6 +5,7 @@ import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload"
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
import { CloudSyncSettings } from "../../CloudSyncSettings";
|
||||
import { SettingsTabContent } from "../settings-ui";
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString: (data: string) => void;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
onSettingsApplied?: () => void;
|
||||
}) {
|
||||
const {
|
||||
vault,
|
||||
@@ -21,6 +23,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
@@ -44,7 +47,10 @@ export default function SettingsSyncTab(props: {
|
||||
}));
|
||||
}
|
||||
}
|
||||
return buildSyncPayload(vault, effectiveRules);
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
|
||||
|
||||
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
|
||||
}, [vault, portForwardingRules]);
|
||||
|
||||
const onApplyPayload = useCallback(
|
||||
@@ -52,9 +58,10 @@ export default function SettingsSyncTab(props: {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules],
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -6,15 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
checkForUpdate,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
onDownloadProgress,
|
||||
onDownloaded,
|
||||
onError as onUpdateError,
|
||||
getReleasesUrl,
|
||||
} from "../../../infrastructure/services/updateService";
|
||||
import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
@@ -35,6 +27,22 @@ function formatBytes(bytes: number): string {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/** Returns a locale-agnostic relative time string for the given timestamp. */
|
||||
function formatLastChecked(
|
||||
timestamp: number | null,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (!timestamp) return '';
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
|
||||
if (diffMins < 60)
|
||||
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
|
||||
}
|
||||
|
||||
interface SettingsSystemTabProps {
|
||||
sessionLogsEnabled: boolean;
|
||||
setSessionLogsEnabled: (enabled: boolean) => void;
|
||||
@@ -47,6 +55,15 @@ interface SettingsSystemTabProps {
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
globalHotkeyEnabled: boolean;
|
||||
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
||||
autoUpdateEnabled: boolean;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<unknown>;
|
||||
installUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -61,6 +78,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
updateState,
|
||||
checkNow,
|
||||
installUpdate,
|
||||
openReleasePage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
@@ -74,13 +99,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
// Software Update state
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateVersion, setUpdateVersion] = useState('');
|
||||
const [updatePercent, setUpdatePercent] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [updateSupported, setUpdateSupported] = useState(true);
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
// Load app version on mount
|
||||
@@ -93,63 +111,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to auto-update events
|
||||
useEffect(() => {
|
||||
const cleanupProgress = onDownloadProgress((p) => {
|
||||
setUpdatePercent(Math.round(p.percent));
|
||||
});
|
||||
const cleanupDownloaded = onDownloaded(() => {
|
||||
setUpdateStatus('ready');
|
||||
});
|
||||
const cleanupError = onUpdateError((payload) => {
|
||||
setUpdateError(payload.error);
|
||||
setUpdateStatus('error');
|
||||
});
|
||||
return () => {
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdate = useCallback(async () => {
|
||||
setUpdateStatus('checking');
|
||||
setUpdateError('');
|
||||
const result = await checkForUpdate();
|
||||
if (result.error) {
|
||||
setUpdateError(result.error);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('error');
|
||||
} else if (result.available && result.version) {
|
||||
setUpdateVersion(result.version);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('available');
|
||||
} else {
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('up-to-date');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownloadUpdate = useCallback(async () => {
|
||||
setUpdateStatus('downloading');
|
||||
setUpdatePercent(0);
|
||||
const result = await downloadUpdate();
|
||||
if (!result.success) {
|
||||
setUpdateError(result.error ?? t('settings.update.downloadError'));
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
// Success is handled by onDownloaded event
|
||||
}, [t]);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
installUpdate();
|
||||
}, []);
|
||||
|
||||
const handleOpenReleases = useCallback(() => {
|
||||
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
|
||||
netcattyBridge.get()?.openExternal?.(url);
|
||||
}, [updateVersion]);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
@@ -315,92 +276,122 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('settings.update.currentVersion')}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{appVersion || '...'}</span>
|
||||
<span className="text-sm font-mono">
|
||||
{updateState.currentVersion || appVersion || '...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{updateStatus === 'up-to-date' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'available' && (
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('settings.update.available').replace('{version}', updateVersion)}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
|
||||
{updateState.autoDownloadStatus === 'downloading' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
|
||||
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
|
||||
</p>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${updatePercent}%` }}
|
||||
style={{ width: `${updateState.downloadPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
{updateState.autoDownloadStatus === 'ready' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.readyToInstall')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
{updateState.autoDownloadStatus === 'error' && (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateError || t('settings.update.error')}
|
||||
{updateState.downloadError || t('settings.update.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Manual fallback hint when auto-update not supported */}
|
||||
{!updateSupported && updateStatus !== 'idle' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.manualDownloadHint')}
|
||||
</p>
|
||||
{updateState.autoDownloadStatus === 'idle' && (
|
||||
<>
|
||||
{updateState.manualCheckStatus === 'up-to-date' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('settings.update.available').replace(
|
||||
'{version}',
|
||||
updateState.latestRelease?.version ?? ''
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{updateState.manualCheckStatus === 'error' && (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateState.error || t('settings.update.error')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckForUpdate}
|
||||
disabled={updateStatus === 'checking'}
|
||||
>
|
||||
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
|
||||
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
|
||||
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
|
||||
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<RefreshCw size={14} className="mr-1.5 animate-spin" />
|
||||
{t('settings.update.checking')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'available' && updateSupported && (
|
||||
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('settings.update.download')}
|
||||
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
|
||||
/* Check button — shown in idle states and in error state (allows retry) */
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void checkNow()}
|
||||
>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('settings.update.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
|
||||
) : null}
|
||||
|
||||
{/* Install button — shown when download is complete */}
|
||||
{updateState.autoDownloadStatus === 'ready' && (
|
||||
<Button variant="default" size="sm" onClick={installUpdate}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('settings.update.restartNow')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
|
||||
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
|
||||
|
||||
{/* Open releases — shown on download error */}
|
||||
{updateState.autoDownloadStatus === 'error' && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('settings.update.manualDownload')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — shown when update found on unsupported platform, or on check error */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('settings.update.manualDownload')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.update.autoUpdateEnabled')}
|
||||
description={t('settings.update.autoUpdateEnabledDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoUpdateEnabled}
|
||||
onChange={setAutoUpdateEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
{t('settings.update.lastCheckedPrefix')}
|
||||
{formatLastChecked(updateState.lastCheckedAt, t)}
|
||||
{' '}
|
||||
</span>
|
||||
)}
|
||||
{t('settings.update.hint')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -606,7 +597,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
value={sessionLogsFormat}
|
||||
options={formatOptions}
|
||||
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
|
||||
className="w-32"
|
||||
className="w-44"
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -625,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
{/* Enable/Disable Global Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
label={t('settings.globalHotkey.enabled')}
|
||||
description={t('settings.globalHotkey.enabledDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={globalHotkeyEnabled}
|
||||
onChange={setGlobalHotkeyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
|
||||
@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.osc52Clipboard ?? 'write-only'}
|
||||
options={[
|
||||
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
|
||||
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
|
||||
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
|
||||
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.scrollOnInput")}
|
||||
description={t("settings.terminal.behavior.scrollOnInput.desc")}
|
||||
@@ -616,7 +633,7 @@ export default function SettingsTerminalTab(props: {
|
||||
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
|
||||
className="w-40"
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
55
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
55
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const AddProviderDropdown: React.FC<{
|
||||
onAdd: (providerId: AIProviderId) => void;
|
||||
}> = ({ onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('ai.providers.add')}
|
||||
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
|
||||
{providerIds.map((pid) => (
|
||||
<button
|
||||
key={pid}
|
||||
onClick={() => {
|
||||
onAdd(pid);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<ProviderIconBadge providerId={pid} size="sm" />
|
||||
{PROVIDER_PRESETS[pid].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
88
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const ClaudeCodeCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.claude.detecting')
|
||||
: found
|
||||
? t('ai.claude.detected')
|
||||
: t('ai.claude.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="claude" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.claude.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.claude.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path detection info */}
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.claude.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.claude.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
181
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
181
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from "react";
|
||||
import { ExternalLink, LogIn, LogOut, RefreshCw, X } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo, CodexIntegrationStatus, CodexLoginSession } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CodexConnectionCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onLogout: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onLogout,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
? t('ai.codex.notFound')
|
||||
: loginSession?.state === "running"
|
||||
? t('ai.codex.awaitingLogin')
|
||||
: integration?.state === "connected_chatgpt"
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: !found
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
: loginSession?.output?.trim()
|
||||
? loginSession.output.trim()
|
||||
: integration?.rawOutput?.trim()
|
||||
? integration.rawOutput.trim()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="openai" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.codex.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path detection info */}
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.codex.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Connection & login UI -- only when codex is detected */}
|
||||
{found && (
|
||||
<>
|
||||
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
|
||||
{loginSession?.state === "running" ? (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('ai.codex.openLogin')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
<X size={14} className="mr-1.5" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
{t('ai.codex.logout')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={onConnect}>
|
||||
<LogIn size={14} className="mr-1.5" />
|
||||
{t('ai.codex.connectChatGPT')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
|
||||
{t('ai.codex.refreshStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{found && outputText && (
|
||||
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
components/settings/tabs/ai/ModelSelector.tsx
Normal file
180
components/settings/tabs/ai/ModelSelector.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
|
||||
export const ModelSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
baseURL: string;
|
||||
modelsEndpoint?: string;
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
skipTLSVerify?: boolean;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!modelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!modelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Temporarily allow the provider's host in the backend fetch allowlist
|
||||
// so model listing works for URLs not yet synced from the main window.
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
if (providerId === "anthropic") {
|
||||
headers["x-api-key"] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(result.data);
|
||||
const list: FetchedModel[] = (parsed.data || parsed.models || []).map((m: { id: string; name?: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
}));
|
||||
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
setModels(list);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to parse response");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
|
||||
// Filter models by current input value (inline autocomplete)
|
||||
const suggestions = useMemo(() => {
|
||||
if (!hasFetched || models.length === 0) return [];
|
||||
if (!value.trim()) return models;
|
||||
const q = value.toLowerCase();
|
||||
return models.filter((m) =>
|
||||
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [models, value, hasFetched]);
|
||||
|
||||
const showSuggestions = isOpen && canFetch;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (canFetch && hasFetched && !isOpen) setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (canFetch) setIsOpen(true); }}
|
||||
onBlur={() => { setIsOpen(false); }}
|
||||
placeholder={placeholder ?? (canFetch ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
canFetch && "pr-8",
|
||||
)}
|
||||
/>
|
||||
{canFetch && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canFetch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
title={t('ai.providers.refreshModels')}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-destructive">{error}</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
{hasFetched ? t('ai.providers.noMatchingModels') : t('ai.providers.clickToLoadModels')}
|
||||
</div>
|
||||
) : (
|
||||
suggestions.slice(0, 100).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(m.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors flex items-center justify-between gap-2",
|
||||
m.id === value && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono truncate">{m.id}</span>
|
||||
{m.id === value && <Check size={12} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{suggestions.length > 100 && (
|
||||
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
|
||||
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
components/settings/tabs/ai/ProviderCard.tsx
Normal file
95
components/settings/tabs/ai/ProviderCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Toggle } from "../../settings-ui";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
|
||||
export const ProviderCard: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
isActive: boolean;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onUpdate: (updates: Partial<ProviderConfig>) => void;
|
||||
isEditing: boolean;
|
||||
onCancelEdit: () => void;
|
||||
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
|
||||
const { t } = useI18n();
|
||||
const hasApiKey = !!provider.apiKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4 transition-colors",
|
||||
isActive ? "border-primary/50 bg-primary/5" : "border-border/60 bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider icon */}
|
||||
<ProviderIconBadge providerId={provider.providerId} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
{isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
|
||||
{t('ai.providers.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
|
||||
</span>
|
||||
{provider.defaultModel && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">|</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t('ai.providers.configure')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title={t('ai.providers.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable config form */}
|
||||
{isEditing && (
|
||||
<ProviderConfigForm
|
||||
provider={provider}
|
||||
onSave={(updates) => {
|
||||
onUpdate(updates);
|
||||
onCancelEdit();
|
||||
}}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
152
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
152
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
onSave: (updates: Partial<ProviderConfig>) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ provider, onSave, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<ProviderFormState>({
|
||||
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
|
||||
apiKey: "",
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
|
||||
// Decrypt and load existing API key on mount
|
||||
useEffect(() => {
|
||||
if (provider.apiKey) {
|
||||
setIsDecrypting(true);
|
||||
decryptField(provider.apiKey)
|
||||
.then((decrypted) => {
|
||||
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
|
||||
})
|
||||
.catch(() => {
|
||||
// If decryption fails, show raw value
|
||||
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
|
||||
})
|
||||
.finally(() => setIsDecrypting(false));
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
if (form.apiKey) {
|
||||
updates.apiKey = await encryptField(form.apiKey);
|
||||
} else {
|
||||
updates.apiKey = undefined;
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, isCustom]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
{/* Name (custom providers only) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('ai.providers.name.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.baseURL}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
|
||||
<ModelSelector
|
||||
value={form.defaultModel}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
|
||||
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skip TLS Verification */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.skipTLSVerify}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
28
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { SettingsIconId } from "./types";
|
||||
import { SETTINGS_ICON_PATHS, SETTINGS_ICON_COLORS } from "./types";
|
||||
|
||||
export const ProviderIconBadge: React.FC<{
|
||||
providerId: SettingsIconId;
|
||||
size?: "sm" | "md";
|
||||
}> = ({ providerId, size = "md" }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden",
|
||||
size === "sm" ? "w-5 h-5" : "w-8 h-8",
|
||||
SETTINGS_ICON_COLORS[providerId],
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
204
components/settings/tabs/ai/SafetySettings.tsx
Normal file
204
components/settings/tabs/ai/SafetySettings.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Plus, Shield, X } from "lucide-react";
|
||||
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Select, SettingRow } from "../../settings-ui";
|
||||
|
||||
export const SafetySettings: React.FC<{
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist: string[];
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
}> = ({
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
|
||||
if (!pattern) {
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
setRegexErrors((prev) => ({
|
||||
...prev,
|
||||
[idx]: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePatternChange = useCallback((value: string, idx: number) => {
|
||||
const next = [...commandBlocklist];
|
||||
next[idx] = value;
|
||||
validatePattern(value, idx);
|
||||
setCommandBlocklist(next);
|
||||
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
|
||||
|
||||
const permissionModeOptions = [
|
||||
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
|
||||
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
|
||||
{ value: "autonomous", label: t('ai.safety.permissionMode.autonomous') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.safety.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
|
||||
<SettingRow
|
||||
label={t('ai.safety.permissionMode')}
|
||||
description={t('ai.safety.permissionMode.description')}
|
||||
>
|
||||
<Select
|
||||
value={globalPermissionMode}
|
||||
options={permissionModeOptions}
|
||||
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('ai.safety.commandTimeout')}
|
||||
description={t('ai.safety.commandTimeout.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={commandTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) setCommandTimeout(val);
|
||||
}}
|
||||
min={1}
|
||||
max={3600}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('ai.safety.maxIterations')}
|
||||
description={t('ai.safety.maxIterations.description')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={maxIterations}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) setMaxIterations(val);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Command Blocklist */}
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.blocklist.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
|
||||
>
|
||||
{t('ai.safety.blocklist.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{commandBlocklist.map((pattern, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value, idx)}
|
||||
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
|
||||
regexErrors[idx]
|
||||
? 'border-destructive focus-visible:ring-destructive'
|
||||
: 'border-input focus-visible:ring-ring'
|
||||
}`}
|
||||
placeholder={t('ai.safety.blocklist.placeholder')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = commandBlocklist.filter((_, i) => i !== idx);
|
||||
setCommandBlocklist(next);
|
||||
setRegexErrors((prev) => {
|
||||
const updated: Record<number, string> = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
const ki = Number(k);
|
||||
if (ki < idx) updated[ki] = v as string;
|
||||
else if (ki > idx) updated[ki - 1] = v as string;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{regexErrors[idx] && (
|
||||
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t('ai.safety.blocklist.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.note')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
220
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Globe, Eye, EyeOff } from "lucide-react";
|
||||
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Select, SettingRow } from "../../settings-ui";
|
||||
|
||||
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
|
||||
tavily: "/ai/search/tavily.svg",
|
||||
exa: "/ai/search/exa.png",
|
||||
bocha: "/ai/search/bocha.webp",
|
||||
zhipu: "/ai/search/zhipu.png",
|
||||
searxng: "/ai/search/searxng.svg",
|
||||
};
|
||||
|
||||
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
|
||||
<img
|
||||
src={SEARCH_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0"
|
||||
/>
|
||||
);
|
||||
|
||||
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
|
||||
WEB_SEARCH_PROVIDER_PRESETS,
|
||||
).map(([id, preset]) => ({
|
||||
value: id as WebSearchProviderId,
|
||||
label: preset.name,
|
||||
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
|
||||
}));
|
||||
|
||||
export const WebSearchSettings: React.FC<{
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}> = ({ webSearchConfig, setWebSearchConfig }) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const config = useMemo(() => webSearchConfig ?? {
|
||||
providerId: "tavily" as WebSearchProviderId,
|
||||
enabled: false,
|
||||
maxResults: 5,
|
||||
}, [webSearchConfig]);
|
||||
|
||||
// Ref to always read the latest config in async callbacks (avoids stale closure)
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
|
||||
|
||||
// Decrypt API key on mount or when provider changes (with cancellation guard)
|
||||
const decryptSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (config.apiKey) {
|
||||
const seq = ++decryptSeqRef.current;
|
||||
setIsDecrypting(true);
|
||||
decryptField(config.apiKey)
|
||||
.then((decrypted) => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
|
||||
})
|
||||
.finally(() => {
|
||||
if (decryptSeqRef.current === seq) setIsDecrypting(false);
|
||||
});
|
||||
} else {
|
||||
decryptSeqRef.current++;
|
||||
setApiKeyInput("");
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [config.apiKey, config.providerId]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<WebSearchConfig>) => {
|
||||
setWebSearchConfig({ ...configRef.current, ...updates });
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(val: string) => {
|
||||
const providerId = val as WebSearchProviderId;
|
||||
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
|
||||
setWebSearchConfig({
|
||||
...configRef.current,
|
||||
providerId,
|
||||
apiKey: undefined,
|
||||
apiHost: newPreset.defaultApiHost || undefined,
|
||||
});
|
||||
setApiKeyInput("");
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
// Sequence counter for blur saves — prevents out-of-order encryption results
|
||||
const blurSeqRef = useRef(0);
|
||||
const handleApiKeyBlur = useCallback(async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
blurSeqRef.current++;
|
||||
updateConfig({ apiKey: undefined });
|
||||
return;
|
||||
}
|
||||
const seq = ++blurSeqRef.current;
|
||||
const providerAtBlur = configRef.current.providerId;
|
||||
const encrypted = await encryptField(apiKeyInput.trim());
|
||||
// Only apply if this is still the latest blur and provider hasn't changed
|
||||
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
|
||||
updateConfig({ apiKey: encrypted });
|
||||
}
|
||||
}, [apiKeyInput, updateConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
|
||||
{/* Enable/Disable */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.enable")}
|
||||
description={t("ai.webSearch.enable.description")}
|
||||
>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => updateConfig({ enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
|
||||
{/* Provider */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.provider")}
|
||||
description={t("ai.webSearch.provider.description")}
|
||||
>
|
||||
<Select
|
||||
value={config.providerId}
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* API Key (hidden for SearXNG) */}
|
||||
{preset.requiresApiKey && (
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiKey")}
|
||||
description={t("ai.webSearch.apiKey.description")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyInput}
|
||||
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
onBlur={() => void handleApiKeyBlur()}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isDecrypting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* API Host */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiHost")}
|
||||
description={
|
||||
config.providerId === "searxng"
|
||||
? t("ai.webSearch.apiHost.searxngDescription")
|
||||
: t("ai.webSearch.apiHost.description")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.apiHost ?? preset.defaultApiHost}
|
||||
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
|
||||
placeholder={preset.defaultApiHost || "https://..."}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Max Results */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.maxResults")}
|
||||
description={t("ai.webSearch.maxResults.description")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxResults ?? 5}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val >= 1 && val <= 20) {
|
||||
updateConfig({ maxResults: val });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
components/settings/tabs/ai/index.ts
Normal file
8
components/settings/tabs/ai/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
export { ModelSelector } from "./ModelSelector";
|
||||
export { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
129
components/settings/tabs/ai/types.ts
Normal file
129
components/settings/tabs/ai/types.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Shared types for AI settings sub-components
|
||||
*/
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
|
||||
export interface CodexLoginSession {
|
||||
sessionId: string;
|
||||
state: CodexLoginState;
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface AgentPathInfo {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderFormState {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// Agent default configs for registration in externalAgents
|
||||
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
|
||||
codex: {
|
||||
name: "Codex CLI",
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
icon: "openai",
|
||||
acpCommand: "codex-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
claude: {
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBridge(): NetcattyAiBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function getFetchBridge(): FetchBridge | undefined {
|
||||
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function normalizeCodexBridgeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
|
||||
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
custom: "/ai/providers/custom.svg",
|
||||
};
|
||||
|
||||
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
custom: "bg-zinc-600",
|
||||
};
|
||||
@@ -17,7 +17,8 @@ interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
files: RemoteFile[];
|
||||
hasFiles: boolean;
|
||||
hasDisplayFiles: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
@@ -60,7 +61,8 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
files,
|
||||
hasFiles,
|
||||
hasDisplayFiles,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
@@ -169,7 +171,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && files.length === 0 && (
|
||||
{loading && !hasFiles && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -200,7 +202,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
{!hasDisplayFiles && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
@@ -51,8 +50,11 @@ interface SftpModalHeaderProps {
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles: () => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onNavigateToBookmark?: (path: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
@@ -91,8 +93,11 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onUpdateHost,
|
||||
onNavigateToBookmark,
|
||||
onClose,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
@@ -122,24 +127,35 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
<div className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
@@ -302,6 +318,22 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip
|
||||
open={openTooltip === 'showHiddenFiles'}
|
||||
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
|
||||
@@ -53,6 +53,7 @@ interface UseSftpModalSessionParams {
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
@@ -88,6 +89,7 @@ export const useSftpModalSession = ({
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const initializingRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
@@ -315,92 +317,109 @@ export const useSftpModalSession = ({
|
||||
if (open) {
|
||||
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
|
||||
initializedRef.current = true;
|
||||
initializingRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
onClearSelection();
|
||||
setLoading(true);
|
||||
|
||||
if (isLocalSession) {
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void loadFiles(currentPath);
|
||||
// Skip redundant loadFiles while async initialization is still in flight.
|
||||
// Without this guard, dependency changes (e.g. loadFiles recreation from
|
||||
// files.length change) can re-trigger this effect and call loadFiles with
|
||||
// the stale currentPath before the initialization IIFE has resolved and
|
||||
// updated currentPathRef — causing uploads to target the wrong directory.
|
||||
if (!initializingRef.current) {
|
||||
void loadFiles(currentPath);
|
||||
}
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
initializedRef.current = false;
|
||||
initializingRef.current = false;
|
||||
}
|
||||
}, [
|
||||
closeSftpSession,
|
||||
@@ -428,6 +447,7 @@ export const useSftpModalSession = ({
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
|
||||
@@ -35,6 +35,7 @@ type UploadTask = TransferTask;
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
currentPath: string;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
@@ -99,6 +100,7 @@ interface UseSftpModalTransfersResult {
|
||||
|
||||
export const useSftpModalTransfers = ({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
@@ -214,8 +216,16 @@ export const useSftpModalTransfers = ({
|
||||
};
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
|
||||
|
||||
const refreshTargetPathIfCurrent = useCallback(
|
||||
async (targetPath: string) => {
|
||||
if (currentPathRef.current !== targetPath) return;
|
||||
await loadFiles(targetPath, { force: true });
|
||||
},
|
||||
[currentPathRef, loadFiles],
|
||||
);
|
||||
|
||||
// Create upload callbacks
|
||||
const createUploadCallbacks = useCallback((): UploadCallbacks => {
|
||||
const createUploadCallbacks = useCallback((targetPath: string): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
const scanningTask: UploadTask = {
|
||||
@@ -247,7 +257,7 @@ export const useSftpModalTransfers = ({
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -345,16 +355,18 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [t, currentPath]);
|
||||
}, [t]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
files: FileList | File[],
|
||||
useCompressed: boolean
|
||||
useCompressed: boolean,
|
||||
targetPathOverride?: string,
|
||||
): Promise<void> => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
const targetPath = targetPathOverride ?? currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -367,13 +379,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -384,7 +396,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -396,7 +408,7 @@ export const useSftpModalTransfers = ({
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
|
||||
}, [createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
@@ -820,6 +832,7 @@ export const useSftpModalTransfers = ({
|
||||
const handleUploadFromDrop = useCallback(
|
||||
async (dataTransfer: DataTransfer) => {
|
||||
setUploading(true);
|
||||
const targetPath = currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -832,13 +845,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -849,7 +862,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -862,7 +875,7 @@ export const useSftpModalTransfers = ({
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
|
||||
@@ -871,6 +884,7 @@ export const useSftpModalTransfers = ({
|
||||
if (entries.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
const targetPath = currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -883,13 +897,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -900,7 +914,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -913,7 +927,7 @@ export const useSftpModalTransfers = ({
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from File array (used by file input after copying files)
|
||||
|
||||
@@ -98,9 +98,6 @@ export interface SftpContextValue {
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
@@ -140,12 +137,6 @@ export const useSftpUpdateHosts = () => {
|
||||
return context.updateHosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
@@ -153,7 +144,6 @@ interface SftpContextProviderProps {
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -164,7 +154,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
@@ -177,9 +166,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
|
||||
@@ -2,10 +2,10 @@ import React from "react";
|
||||
import type { Host, SftpFileEntry } from "../../types";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import { Button } from "../ui/button";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
import { SftpTransferQueue } from "./SftpTransferQueue";
|
||||
|
||||
type SftpState = ReturnType<typeof useSftpState>;
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SftpOverlaysProps {
|
||||
hosts: Host[];
|
||||
sftp: SftpState;
|
||||
visibleTransfers: SftpState["transfers"];
|
||||
showTransferQueue?: boolean;
|
||||
showHostPickerLeft: boolean;
|
||||
showHostPickerRight: boolean;
|
||||
hostSearchLeft: string;
|
||||
@@ -46,6 +47,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
hosts,
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
showTransferQueue = true,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
@@ -98,49 +100,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
onSelectHost={handleHostSelectRight}
|
||||
/>
|
||||
|
||||
{/* Transfer status area - shows folder uploads and file transfers */}
|
||||
{sftp.transfers.length > 0 && (
|
||||
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
|
||||
<span className="font-medium">
|
||||
Transfers
|
||||
{sftp.activeTransfersCount > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({sftp.activeTransfersCount} active)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{sftp.transfers.some(
|
||||
(t) => t.status === "completed" || t.status === "cancelled",
|
||||
) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={sftp.clearCompletedTransfers}
|
||||
>
|
||||
Clear completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-auto">
|
||||
{visibleTransfers.map((task) => (
|
||||
<SftpTransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => {
|
||||
// External uploads use a different cancel mechanism
|
||||
if (task.sourceConnectionId === "external") {
|
||||
sftp.cancelExternalUpload();
|
||||
}
|
||||
sftp.cancelTransfer(task.id);
|
||||
}}
|
||||
onRetry={() => sftp.retryTransfer(task.id)}
|
||||
onDismiss={() => sftp.dismissTransfer(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showTransferQueue && (
|
||||
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
|
||||
)}
|
||||
|
||||
<SftpConflictDialog
|
||||
|
||||
@@ -411,7 +411,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import { Bookmark, Check, ChevronLeft, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
import { SftpBreadcrumb } from "./index";
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
@@ -12,7 +14,6 @@ import type { SftpBookmark } from "../../domain/models";
|
||||
interface SftpPaneToolbarProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
onNavigateUp: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onSetFilter: (value: string) => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
@@ -47,12 +48,19 @@ interface SftpPaneToolbarProps {
|
||||
onToggleBookmark: () => void;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
}
|
||||
|
||||
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
|
||||
// bookmark ~20px, padding ~16px. Collapse early so the breadcrumb
|
||||
// always gets at least ~200px of space.
|
||||
const COLLAPSE_WIDTH = 400;
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
t,
|
||||
pane,
|
||||
onNavigateUp,
|
||||
onNavigateTo,
|
||||
onSetFilter,
|
||||
onSetFilenameEncoding,
|
||||
@@ -86,299 +94,488 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
onToggleBookmark,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
}) => (
|
||||
<>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onNavigateUp}
|
||||
title={t("sftp.goUp")}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</Button>
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
{/* Editable Breadcrumb with autocomplete */}
|
||||
{isEditingPath ? (
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => {
|
||||
setEditingPathValue(e.target.value);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
}}
|
||||
onBlur={handlePathBlur}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
onFocus={() => setShowPathSuggestions(true)}
|
||||
className="h-5 w-full text-[10px] bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{showPathSuggestions && pathSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={pathDropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
|
||||
>
|
||||
{pathSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion.path}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
|
||||
idx === pathSuggestionIndex && "bg-secondary/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePathSubmit(suggestion.path);
|
||||
}}
|
||||
>
|
||||
{suggestion.type === "folder" ? (
|
||||
<Folder size={12} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Home
|
||||
size={12}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate font-mono">
|
||||
{suggestion.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={pane.connection.currentPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
// Observe the overall toolbar width to decide whether to collapse action buttons
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setCollapsed(entry.contentRect.width < COLLAPSE_WIDTH);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
const handleNewFolder = useCallback(() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
}, [setNewFolderName, setShowNewFolderDialog]);
|
||||
|
||||
const handleNewFile = useCallback(() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}, [getNextUntitledName, pane.files, setNewFileName, setFileNameError, setShowNewFileDialog]);
|
||||
|
||||
const handleToggleFilter = useCallback(() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
if (!showFilterBar) {
|
||||
setTimeout(() => filterInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [showFilterBar, setShowFilterBar, filterInputRef]);
|
||||
|
||||
const isRemote = !pane.connection?.isLocal;
|
||||
|
||||
// Buttons that always remain visible (not collapsed)
|
||||
const pinnedButtons = (
|
||||
<>
|
||||
{onGoToTerminalCwd && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
|
||||
title={isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
onClick={(e) => {
|
||||
// If not bookmarked, toggle directly instead of opening popover
|
||||
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
|
||||
e.preventDefault();
|
||||
onToggleBookmark();
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6"
|
||||
onClick={onGoToTerminalCwd}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
|
||||
<TerminalSquare size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", pane.filter && "text-primary")}
|
||||
onClick={handleToggleFilter}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.filter")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
// Collapsible action buttons (shown inline when space allows)
|
||||
const collapsibleButtons = (
|
||||
<>
|
||||
{isRemote && (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="end">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
pane.filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onSetFilenameEncoding(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleNewFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleNewFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={
|
||||
pane.loading || pane.reconnecting ? "animate-spin" : ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
// Overflow dropdown menu items (same collapsible actions as menu items)
|
||||
const overflowMenuItems = (
|
||||
<div className="flex flex-col min-w-[140px]">
|
||||
{isRemote && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left">
|
||||
<Languages size={14} className="shrink-0" />
|
||||
{t("sftp.encoding.label")}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start" side="right">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
pane.filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onSetFilenameEncoding(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
|
||||
onClick={handleNewFolder}
|
||||
>
|
||||
<FolderPlus size={14} className="shrink-0" />
|
||||
{t("sftp.newFolder")}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
|
||||
onClick={handleNewFile}
|
||||
>
|
||||
<FilePlus size={14} className="shrink-0" />
|
||||
{t("sftp.newFile")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
|
||||
showHiddenFiles && "text-primary",
|
||||
)}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} className="shrink-0" /> : <Eye size={14} className="shrink-0" />}
|
||||
{t("settings.sftp.showHiddenFiles")}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("shrink-0", (pane.loading || pane.reconnecting) && "animate-spin")}
|
||||
/>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
<div ref={outerRef} className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
|
||||
{/* Editable Breadcrumb with autocomplete */}
|
||||
{isEditingPath ? (
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => {
|
||||
setEditingPathValue(e.target.value);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
}}
|
||||
onBlur={handlePathBlur}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
onFocus={() => setShowPathSuggestions(true)}
|
||||
className="h-5 w-full text-[10px] bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{showPathSuggestions && pathSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={pathDropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
|
||||
>
|
||||
{pathSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion.path}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
|
||||
idx === pathSuggestionIndex && "bg-secondary/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePathSubmit(suggestion.path);
|
||||
}}
|
||||
>
|
||||
{suggestion.type === "folder" ? (
|
||||
<Folder size={12} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Home
|
||||
size={12}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate font-mono">
|
||||
{suggestion.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={pane.connection.currentPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
|
||||
onClick={(e) => {
|
||||
// If not bookmarked, toggle directly instead of opening popover
|
||||
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
|
||||
e.preventDefault();
|
||||
onToggleBookmark();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Action buttons area - observed for overflow */}
|
||||
<div className="ml-auto flex items-center gap-0.5 shrink-0">
|
||||
{collapsed ? (
|
||||
<>
|
||||
{pinnedButtons}
|
||||
<Dropdown>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.more")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownContent align="end">
|
||||
{overflowMenuItems}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pinnedButtons}
|
||||
{collapsibleButtons}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline filter bar - appears below toolbar when search is active */}
|
||||
{showFilterBar && (
|
||||
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
value={pane.filter}
|
||||
onChange={(e) =>
|
||||
startTransition(() => onSetFilter(e.target.value))
|
||||
}
|
||||
placeholder={t("sftp.filter.placeholder")}
|
||||
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (pane.filter) {
|
||||
startTransition(() => onSetFilter(""));
|
||||
} else {
|
||||
setShowFilterBar(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pane.filter && (
|
||||
<button
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title={t("sftp.encoding.label")}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="end">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
pane.filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onSetFilenameEncoding(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFolder")}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFile")}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", pane.filter && "text-primary")}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
if (!showFilterBar) {
|
||||
setTimeout(() => filterInputRef.current?.focus(), 0);
|
||||
}
|
||||
}}
|
||||
title={t("sftp.filter")}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onRefresh}
|
||||
title={t("common.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={
|
||||
pane.loading || pane.reconnecting ? "animate-spin" : ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline filter bar - appears below toolbar when search is active */}
|
||||
{showFilterBar && (
|
||||
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
value={pane.filter}
|
||||
onChange={(e) =>
|
||||
startTransition(() => onSetFilter(e.target.value))
|
||||
}
|
||||
placeholder={t("sftp.filter.placeholder")}
|
||||
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (pane.filter) {
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
startTransition(() => onSetFilter(""));
|
||||
} else {
|
||||
setShowFilterBar(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pane.filter && (
|
||||
<button
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.close")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
startTransition(() => onSetFilter(""));
|
||||
setShowFilterBar(false);
|
||||
}}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpShowHiddenFiles,
|
||||
useSftpUpdateHosts,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
@@ -58,6 +57,8 @@ interface SftpPaneViewProps {
|
||||
pane: SftpPane;
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
@@ -65,13 +66,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const showHiddenFiles = useSftpShowHiddenFiles();
|
||||
|
||||
const { t } = useI18n();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -118,7 +120,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
connection: pane.connection,
|
||||
showHiddenFiles,
|
||||
showHiddenFiles: pane.showHiddenFiles,
|
||||
sortField,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -299,7 +301,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<SftpPaneToolbar
|
||||
t={t}
|
||||
pane={pane}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
onNavigateTo={callbacks.onNavigateTo}
|
||||
onSetFilter={callbacks.onSetFilter}
|
||||
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
|
||||
@@ -333,6 +334,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onToggleBookmark={toggleBookmark}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
/>
|
||||
|
||||
<SftpPaneFileList
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -22,9 +23,18 @@ interface SftpTransferItemProps {
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
canRevealTarget?: boolean;
|
||||
onRevealTarget?: () => void;
|
||||
}
|
||||
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
task,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
canRevealTarget = false,
|
||||
onRevealTarget,
|
||||
}) => {
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
|
||||
// Calculate remaining time from backend-reported sliding-window speed
|
||||
@@ -49,33 +59,43 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
: '';
|
||||
|
||||
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
|
||||
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
|
||||
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
|
||||
const details = (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
|
||||
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
|
||||
{task.status === 'pending' && (task.isDirectory
|
||||
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
|
||||
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
|
||||
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
|
||||
)}
|
||||
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
|
||||
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
|
||||
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
|
||||
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
|
||||
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
|
||||
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm truncate font-medium">{task.fileName}</span>
|
||||
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
|
||||
{task.status === 'transferring' && speedFormatted && (
|
||||
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
|
||||
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
|
||||
)}
|
||||
{task.status === 'transferring' && remainingFormatted && (
|
||||
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
|
||||
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-[9px] mt-0.5 truncate",
|
||||
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
|
||||
)}
|
||||
title={targetDirectoryPath}
|
||||
>
|
||||
{targetDirectoryPath}
|
||||
</div>
|
||||
{(task.status === 'transferring' || task.status === 'pending') && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex-1 h-2 bg-secondary/80 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative overflow-hidden",
|
||||
@@ -100,39 +120,56 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 min-w-[40px] text-right font-mono">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
|
||||
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'transferring' && bytesDisplay && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
|
||||
{bytesDisplay}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'completed' && bytesDisplay && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
<div className="text-[9px] text-green-600 mt-0.5">
|
||||
Completed - {bytesDisplay}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'failed' && task.error && (
|
||||
<span className="text-xs text-destructive">{task.error}</span>
|
||||
<span className="text-[10px] text-destructive">{task.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
|
||||
{canRevealTarget && onRevealTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
onClick={onRevealTarget}
|
||||
title="Open transfer destination"
|
||||
>
|
||||
{details}
|
||||
</button>
|
||||
) : (
|
||||
details
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === 'pending' || task.status === 'transferring') && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDismiss} title="Dismiss">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
@@ -158,6 +195,8 @@ const arePropsEqual = (
|
||||
|
||||
// Always re-render on fileName change
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
if (prev.targetPath !== next.targetPath) return false;
|
||||
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
|
||||
|
||||
// For transferring status, allow frequent re-renders for smooth progress bar
|
||||
if (next.status === 'transferring') {
|
||||
|
||||
79
components/sftp/SftpTransferQueue.tsx
Normal file
79
components/sftp/SftpTransferQueue.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { TransferTask } from "../../types";
|
||||
import { SftpTransferItem } from "./SftpTransferItem";
|
||||
|
||||
type SftpState = ReturnType<typeof useSftpState>;
|
||||
|
||||
interface SftpTransferQueueProps {
|
||||
sftp: SftpState;
|
||||
visibleTransfers: SftpState["transfers"];
|
||||
canRevealTransferTarget?: (task: TransferTask) => boolean;
|
||||
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
canRevealTransferTarget,
|
||||
onRevealTransferTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (sftp.transfers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
|
||||
<span className="font-medium">
|
||||
{t("sftp.transfers")}
|
||||
{sftp.activeTransfersCount > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({t("sftp.transfers.active", { count: sftp.activeTransfersCount })})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{sftp.transfers.some(
|
||||
(tr) => tr.status === "completed" || tr.status === "cancelled",
|
||||
) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[11px]"
|
||||
onClick={sftp.clearCompletedTransfers}
|
||||
>
|
||||
{t("sftp.transfers.clearCompleted")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-auto">
|
||||
{visibleTransfers.map((task) => (
|
||||
<SftpTransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => {
|
||||
if (task.sourceConnectionId === "external") {
|
||||
sftp.cancelExternalUpload();
|
||||
}
|
||||
sftp.cancelTransfer(task.id);
|
||||
}}
|
||||
onRetry={() => sftp.retryTransfer(task.id)}
|
||||
onDismiss={() => sftp.dismissTransfer(task.id)}
|
||||
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
|
||||
onRevealTarget={
|
||||
onRevealTransferTarget
|
||||
? () => {
|
||||
void onRevealTransferTarget(task);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,6 @@ interface UseSftpKeyboardShortcutsParams {
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
isActive: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +57,6 @@ export const useSftpKeyboardShortcuts = ({
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
@@ -238,7 +236,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
@@ -280,7 +278,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,14 +30,12 @@ export const useSftpPaneVirtualList = ({
|
||||
if (!container || !isActive) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf1 = window.requestAnimationFrame(update);
|
||||
const raf2 = window.requestAnimationFrame(update);
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [isActive, sortedDisplayFiles.length]);
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ export const useSftpViewFileOps = ({
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
/** Host ID at the time the file was opened, to prevent saving to wrong host.
|
||||
* Uses hostId (not connectionId) because auto-reconnect after a transient
|
||||
* disconnect generates a fresh connectionId for the same endpoint. */
|
||||
hostId?: string;
|
||||
} | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
@@ -148,7 +152,7 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ file, side, fullPath });
|
||||
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
|
||||
|
||||
const content = await sftpRef.current.readTextFile(side, fullPath);
|
||||
|
||||
@@ -242,6 +246,19 @@ export const useSftpViewFileOps = ({
|
||||
async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
|
||||
// Verify the SFTP connection hasn't switched to a different host.
|
||||
// We check hostId (not connectionId) because auto-reconnect after a
|
||||
// transient disconnect generates a fresh connectionId for the same
|
||||
// endpoint. The auto-connect effect in SftpSidePanel blocks
|
||||
// host-switching while the editor is open, so a hostId mismatch here
|
||||
// reliably indicates a genuinely different endpoint.
|
||||
const currentPane = textEditorTarget.side === "left"
|
||||
? sftpRef.current.leftPane
|
||||
: sftpRef.current.rightPane;
|
||||
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
await sftpRef.current.writeTextFile(
|
||||
textEditorTarget.side,
|
||||
textEditorTarget.fullPath,
|
||||
|
||||
@@ -19,7 +19,6 @@ export {
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpUpdateHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
|
||||
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button disabled={!isValid} onClick={onSubmit}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
onClick={onSubmit}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Connection Dialog
|
||||
* Full connection overlay with host info, progress indicator, and auth/progress content
|
||||
*/
|
||||
import { User } from 'lucide-react';
|
||||
import { Loader2, TerminalSquare, User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -154,7 +154,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{'>_'}
|
||||
{isConnecting ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<TerminalSquare size={14} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,28 +5,19 @@
|
||||
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Snippet, Host } from '../../types';
|
||||
import { Host } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
|
||||
export interface TerminalToolbarProps {
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
snippets: Snippet[];
|
||||
host?: Host;
|
||||
defaultThemeId: string;
|
||||
defaultFontFamilyId: string;
|
||||
defaultFontSize: number;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
isScriptsOpen: boolean;
|
||||
setIsScriptsOpen: (open: boolean) => void;
|
||||
onOpenSFTP: () => void;
|
||||
onSnippetClick: (command: string) => void;
|
||||
onOpenScripts: () => void;
|
||||
onOpenTheme: () => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
showClose?: boolean;
|
||||
onClose?: () => void;
|
||||
@@ -43,18 +34,10 @@ export interface TerminalToolbarProps {
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
status,
|
||||
snippets,
|
||||
host,
|
||||
defaultThemeId,
|
||||
defaultFontFamilyId,
|
||||
defaultFontSize,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
isScriptsOpen,
|
||||
setIsScriptsOpen,
|
||||
onOpenSFTP,
|
||||
onSnippetClick,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
onUpdateHost,
|
||||
showClose,
|
||||
onClose,
|
||||
@@ -66,7 +49,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSetTerminalEncoding,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
@@ -75,69 +57,45 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const currentThemeId = host?.theme || defaultThemeId;
|
||||
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
|
||||
const currentFontSize = host?.fontSize || defaultFontSize;
|
||||
|
||||
const handleThemeChange = (themeId: string) => {
|
||||
if (isLocalTerminal) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
if (host && onUpdateHost) {
|
||||
onUpdateHost({ ...host, theme: themeId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontFamilyChange = (fontFamilyId: string) => {
|
||||
if (isLocalTerminal) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
if (host && onUpdateHost) {
|
||||
onUpdateHost({ ...host, fontFamily: fontFamilyId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (fontSize: number) => {
|
||||
if (isLocalTerminal) {
|
||||
onUpdateTerminalFontSize?.(fontSize);
|
||||
return;
|
||||
}
|
||||
if (host && onUpdateHost) {
|
||||
onUpdateHost({ ...host, fontSize });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
{!hidesSftp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
disabled={status !== 'connected'}
|
||||
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
onClick={onOpenSFTP}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.encoding")}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
disabled={status !== 'connected'}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
onClick={onOpenSFTP}
|
||||
>
|
||||
<Languages size={12} />
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.encoding")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
@@ -163,57 +121,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.scripts")}
|
||||
aria-label={t("terminal.toolbar.scripts")}
|
||||
onClick={onOpenScripts}
|
||||
>
|
||||
<Zap size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="px-3 py-2 text-[10px] uppercase text-muted-foreground font-semibold bg-muted/30 border-b">
|
||||
{t("terminal.toolbar.library")}
|
||||
</div>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="py-1">
|
||||
{snippets.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
{t("terminal.toolbar.noSnippets")}
|
||||
</div>
|
||||
) : (
|
||||
snippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSnippetClick(s.command)}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px]">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.scripts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.terminalSettings")}
|
||||
aria-label={t("terminal.toolbar.terminalSettings")}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
>
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.terminalSettings")}
|
||||
onClick={onOpenTheme}
|
||||
>
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.terminalSettings")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
@@ -223,59 +159,57 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.composeBar")}
|
||||
aria-label={t("terminal.toolbar.composeBar")}
|
||||
aria-pressed={isComposeBarOpen}
|
||||
onClick={onToggleComposeBar}
|
||||
>
|
||||
<TextCursorInput size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.composeBar")}
|
||||
aria-pressed={isComposeBarOpen}
|
||||
onClick={onToggleComposeBar}
|
||||
>
|
||||
<TextCursorInput size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.composeBar")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.searchTerminal")}
|
||||
aria-label={t("terminal.toolbar.searchTerminal")}
|
||||
aria-pressed={isSearchOpen}
|
||||
onClick={onToggleSearch}
|
||||
>
|
||||
<Search size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.searchTerminal")}
|
||||
aria-pressed={isSearchOpen}
|
||||
onClick={onToggleSearch}
|
||||
>
|
||||
<Search size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{showClose && onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
title={t("terminal.toolbar.closeSession")}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.closeSession")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ThemeCustomizeModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
currentThemeId={currentThemeId}
|
||||
currentFontFamilyId={currentFontFamilyId}
|
||||
currentFontSize={currentFontSize}
|
||||
onThemeChange={handleThemeChange}
|
||||
onFontFamilyChange={handleFontFamilyChange}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onSave={() => {
|
||||
// Trigger any necessary updates
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
430
components/terminal/ThemeSidePanel.tsx
Normal file
430
components/terminal/ThemeSidePanel.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* ThemeSidePanel - Theme/Font customization panel for the terminal side panel
|
||||
*
|
||||
* Adapted from ThemeCustomizeModal's left panel content.
|
||||
* No preview - the actual terminal behind serves as a live preview.
|
||||
* Changes apply in real-time.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
import { CustomThemeModal } from './CustomThemeModal';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
type TabType = 'theme' | 'font' | 'custom';
|
||||
|
||||
// Memoized theme item component
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-0.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">
|
||||
{theme.type}
|
||||
{theme.isCustom && ' • custom'}
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !onEdit && (
|
||||
<Check size={12} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
// Memoized font item component
|
||||
const FontItem = memo(({
|
||||
font,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
font: TerminalFont;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(font.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ fontFamily: font.family }}
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={12} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
FontItem.displayName = 'FontItem';
|
||||
|
||||
interface ThemeSidePanelProps {
|
||||
currentThemeId: string;
|
||||
currentFontFamilyId: string;
|
||||
currentFontSize: number;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
onFontFamilyChange: (fontFamilyId: string) => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
currentThemeId,
|
||||
currentFontFamilyId,
|
||||
currentFontSize,
|
||||
onThemeChange,
|
||||
onFontFamilyChange,
|
||||
onFontSizeChange,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
const [isNewTheme, setIsNewTheme] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allThemes = useMemo(
|
||||
() => [...TERMINAL_THEMES, ...customThemes],
|
||||
[customThemes]
|
||||
);
|
||||
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
setEditingTheme(null);
|
||||
onThemeChange(themeId);
|
||||
}, [onThemeChange]);
|
||||
|
||||
const handleFontSelect = useCallback((fontId: string) => {
|
||||
onFontFamilyChange(fontId);
|
||||
}, [onFontFamilyChange]);
|
||||
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta));
|
||||
onFontSizeChange(newSize);
|
||||
}, [currentFontSize, onFontSizeChange]);
|
||||
|
||||
const handleNewTheme = useCallback(() => {
|
||||
const base = allThemes.find(t => t.id === currentThemeId) || TERMINAL_THEMES[0];
|
||||
const newTheme: TerminalTheme = {
|
||||
...base,
|
||||
id: `custom-${Date.now()}`,
|
||||
name: `${base.name} (Custom)`,
|
||||
isCustom: true,
|
||||
colors: { ...base.colors },
|
||||
};
|
||||
setEditingTheme(newTheme);
|
||||
setIsNewTheme(true);
|
||||
}, [currentThemeId, allThemes]);
|
||||
|
||||
const handleImportFile = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
addTheme(parsed);
|
||||
onThemeChange(parsed.id);
|
||||
setActiveTab('theme');
|
||||
} else {
|
||||
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
}, [addTheme, onThemeChange, t]);
|
||||
|
||||
const handleEditTheme = useCallback((themeId: string) => {
|
||||
const theme = customThemes.find(t => t.id === themeId);
|
||||
if (theme) {
|
||||
setEditingTheme({ ...theme, colors: { ...theme.colors } });
|
||||
setIsNewTheme(false);
|
||||
}
|
||||
}, [customThemes]);
|
||||
|
||||
const handleEditorDelete = useCallback((themeId: string) => {
|
||||
deleteTheme(themeId);
|
||||
if (currentThemeId === themeId) {
|
||||
onThemeChange(TERMINAL_THEMES[0].id);
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [deleteTheme, currentThemeId, onThemeChange]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const builtinThemes = TERMINAL_THEMES;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'theme'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Palette size={12} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'font'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Type size={12} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'custom'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-1">
|
||||
{activeTab === 'theme' && (
|
||||
<div>
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div>
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
isSelected={currentFontFamilyId === font.id}
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-primary/10 text-primary shrink-0">
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFile}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<Download size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".itermcolors"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t border-border/50 shrink-0">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={currentFontSize <= MIN_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-foreground tabular-nums">{currentFontSize}</span>
|
||||
<span className="text-[9px] text-muted-foreground">px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={currentFontSize >= MAX_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current selection info */}
|
||||
<div className="px-2.5 py-1.5 border-t border-border/50 shrink-0">
|
||||
<div className="text-[9px] text-muted-foreground truncate">
|
||||
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • {currentFontSize}px
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Theme Editor Modal */}
|
||||
{editingTheme && (
|
||||
<CustomThemeModal
|
||||
open={!!editingTheme}
|
||||
theme={editingTheme}
|
||||
isNew={isNewTheme}
|
||||
onSave={(theme) => {
|
||||
if (isNewTheme) {
|
||||
addTheme(theme);
|
||||
onThemeChange(theme.id);
|
||||
} else {
|
||||
updateTheme(theme.id, theme);
|
||||
if (currentThemeId === theme.id) {
|
||||
onThemeChange(theme.id);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}}
|
||||
onDelete={isNewTheme ? undefined : handleEditorDelete}
|
||||
onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemeSidePanel = memo(ThemeSidePanelInner);
|
||||
ThemeSidePanel.displayName = 'ThemeSidePanel';
|
||||
@@ -43,6 +43,11 @@ export const useTerminalContextActions = ({
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FitAddon } from "@xterm/addon-fit";
|
||||
import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { shouldScrollOnTerminalOutput } from "../../../domain/terminalScroll";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import {
|
||||
@@ -10,6 +11,9 @@ import {
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
/** Timeout of distro detection task */
|
||||
const DISTRO_DETECT_TIMEOUT = 8000; // ms
|
||||
|
||||
type TerminalBackendApi = {
|
||||
backendAvailable: () => boolean;
|
||||
telnetAvailable: () => boolean;
|
||||
@@ -67,9 +71,13 @@ export type TerminalSessionStartersContext = {
|
||||
resolvedChainHosts: Host[];
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
serialConfig?: SerialConfig;
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
sessionRef: RefObject<string | null>;
|
||||
hasConnectedRef: RefObject<boolean>;
|
||||
@@ -117,6 +125,41 @@ const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
|
||||
return env;
|
||||
};
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
if (!shouldScrollOnTerminalOutput(settings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.isVisibleRef?.current === false) {
|
||||
if (ctx.pendingOutputScrollRef) {
|
||||
ctx.pendingOutputScrollRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
term.scrollToBottom();
|
||||
};
|
||||
|
||||
const writeSessionData = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
if (!shouldScrollOnTerminalOutput(settings)) {
|
||||
term.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
term.write(data, () => {
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
});
|
||||
};
|
||||
|
||||
const attachSessionToTerminal = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -139,7 +182,7 @@ const attachSessionToTerminal = (
|
||||
// Replace \n that is not preceded by \r with \r\n
|
||||
data = data.replace(/(?<!\r)\n/g, "\r\n");
|
||||
}
|
||||
term.write(data);
|
||||
writeSessionData(ctx, term, data);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
opts?.onConnected?.();
|
||||
@@ -176,7 +219,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const runDistroDetection = async (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
auth: { username: string; password?: string; key?: SSHKey },
|
||||
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
|
||||
) => {
|
||||
if (!ctx.terminalBackend.execAvailable()) return;
|
||||
try {
|
||||
@@ -186,8 +229,9 @@ const runDistroDetection = async (
|
||||
port: ctx.host.port || 22,
|
||||
password: auth.password,
|
||||
privateKey: auth.key?.privateKey,
|
||||
passphrase: auth.passphrase ?? auth.key?.passphrase,
|
||||
command: "cat /etc/os-release 2>/dev/null || uname -a",
|
||||
timeout: 8000,
|
||||
timeout: DISTRO_DETECT_TIMEOUT,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
@@ -496,8 +540,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// Guard against stale timers: if the session changed (e.g. user
|
||||
// clicked Start Over quickly), skip to avoid double execution
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
const suffix = ctx.noAutoRun ? '' : '\r';
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
@@ -534,6 +579,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
@@ -617,8 +663,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const scheduledSessionId = id;
|
||||
setTimeout(() => {
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
const suffix = ctx.noAutoRun ? '' : '\r';
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
@@ -665,7 +712,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
term.write(chunk);
|
||||
writeSessionData(ctx, term, chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
type XTermPlatform,
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
shouldScrollOnTerminalPaste,
|
||||
} from "../../../domain/terminalScroll";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
@@ -89,6 +94,9 @@ export type CreateXTermRuntimeContext = {
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -148,7 +156,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
const scrollOnUserInput = settings?.scrollOnInput ?? true;
|
||||
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
|
||||
const altIsMeta = settings?.altAsMeta ?? false;
|
||||
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
|
||||
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
|
||||
@@ -202,6 +210,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
wordSeparator,
|
||||
theme: {
|
||||
@@ -335,6 +344,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
const scrollViewportToBottom = () => {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterPaste = () => {
|
||||
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
|
||||
scrollViewportToBottom();
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterInput = (data: string) => {
|
||||
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if (e.type !== "keydown") {
|
||||
@@ -360,12 +387,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Send the snippet command to the terminal
|
||||
const payload = `${normalizeLineEndings(snippet.command)}\r`;
|
||||
const payload = snippet.noAutoRun
|
||||
? normalizeLineEndings(snippet.command)
|
||||
: `${normalizeLineEndings(snippet.command)}\r`;
|
||||
ctx.terminalBackend.writeToSession(id, payload);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
|
||||
}
|
||||
if (ctx.onCommandExecuted) {
|
||||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
ctx.commandBufferRef.current = "";
|
||||
@@ -421,6 +450,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -456,6 +486,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
@@ -536,6 +567,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
}
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
|
||||
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
|
||||
if (data === "\r" || data === "\n") {
|
||||
const cmd = ctx.commandBufferRef.current.trim();
|
||||
@@ -562,7 +595,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
// OSC 7 is the standard way for shells to report the current working directory
|
||||
term.parser.registerOscHandler(7, (data) => {
|
||||
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
|
||||
try {
|
||||
// data is the content after "7;" - typically "file://hostname/path"
|
||||
if (data.startsWith('file://')) {
|
||||
@@ -586,6 +619,78 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true; // Indicate we handled the sequence
|
||||
});
|
||||
|
||||
// OSC 52 — clipboard integration
|
||||
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
|
||||
// <target> is typically "c" (clipboard) or "p" (primary selection)
|
||||
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const mode = settings?.osc52Clipboard ?? 'write-only';
|
||||
if (mode === 'off') return true;
|
||||
|
||||
try {
|
||||
const semi = data.indexOf(';');
|
||||
if (semi < 0) return true;
|
||||
const target = data.substring(0, semi);
|
||||
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
|
||||
if (target !== 'c' && target !== '') return true;
|
||||
const payload = data.substring(semi + 1);
|
||||
|
||||
if (payload === '?') {
|
||||
// Read request — allowed in read-write mode, or prompt user in prompt mode
|
||||
if (mode !== 'read-write' && mode !== 'prompt') {
|
||||
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
|
||||
return true;
|
||||
}
|
||||
const sessionId = ctx.sessionRef.current;
|
||||
if (!sessionId) return true;
|
||||
// Use Electron bridge as primary, fall back to navigator.clipboard
|
||||
const readClipboard = async (): Promise<string> => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readClipboardText) return await bridge.readClipboardText();
|
||||
} catch { /* fall through to navigator.clipboard */ }
|
||||
return navigator.clipboard.readText();
|
||||
};
|
||||
const doRead = async () => {
|
||||
// In prompt mode, ask user first
|
||||
if (mode === 'prompt') {
|
||||
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
|
||||
if (!allowed) {
|
||||
logger.debug('[XTerm] OSC 52 read denied by user');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const text = await readClipboard();
|
||||
// Chunked base64 encoding to avoid stack overflow on large payloads
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
const b64 = btoa(binary);
|
||||
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
|
||||
};
|
||||
doRead().catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Write: payload is base64-encoded UTF-8 text
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
|
||||
});
|
||||
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
|
||||
} catch (err) {
|
||||
logger.warn('[XTerm] Failed to handle OSC 52:', err);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -610,6 +715,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
91
components/ui/input-group.tsx
Normal file
91
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col rounded-[22px] border border-border/65 bg-background/92 shadow-[0_18px_42px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.04)] transition-[border-color,background-color,box-shadow]',
|
||||
'supports-[backdrop-filter]:backdrop-blur-sm',
|
||||
'focus-within:border-primary/45 focus-within:bg-background focus-within:ring-1 focus-within:ring-primary/20',
|
||||
'overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroup.displayName = 'InputGroup';
|
||||
|
||||
export type InputGroupTextareaProps = ComponentProps<'textarea'>;
|
||||
|
||||
export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full resize-none bg-transparent text-[13px] text-foreground/92 selection:bg-primary/25',
|
||||
'placeholder:text-muted-foreground/62 placeholder:font-medium placeholder:text-[13px]',
|
||||
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'px-4 pt-3.5 pb-2 leading-[20px]',
|
||||
'field-sizing-content min-h-[82px] max-h-52',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupTextarea.displayName = 'InputGroupTextarea';
|
||||
|
||||
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
|
||||
align?: 'block-start' | 'block-end';
|
||||
};
|
||||
|
||||
export const InputGroupAddon = forwardRef<HTMLDivElement, InputGroupAddonProps>(
|
||||
({ className, align = 'block-end', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-2.5 py-1.5',
|
||||
align === 'block-start' && 'border-b border-border/35 bg-muted/8',
|
||||
align === 'block-end' && 'border-t border-border/60 bg-muted/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupAddon.displayName = 'InputGroupAddon';
|
||||
|
||||
export type InputGroupButtonProps = ComponentProps<'button'> & {
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
size?: 'sm' | 'icon-sm' | 'default';
|
||||
};
|
||||
|
||||
export const InputGroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(
|
||||
({ className, variant = 'ghost', size = 'icon-sm', disabled, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors cursor-pointer',
|
||||
'disabled:opacity-30 disabled:cursor-default',
|
||||
size === 'icon-sm' && 'h-7 w-7',
|
||||
size === 'sm' && 'h-7 px-2 text-[12px] gap-1',
|
||||
size === 'default' && 'h-8 px-3 text-[13px] gap-1.5',
|
||||
variant === 'ghost' && 'text-muted-foreground/78 hover:text-foreground hover:bg-muted/45',
|
||||
variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary',
|
||||
variant === 'outline' && 'border border-border/40 text-muted-foreground/85 hover:text-foreground hover:bg-muted/35',
|
||||
variant === 'destructive' && 'text-destructive/70 hover:text-destructive hover:bg-destructive/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupButton.displayName = 'InputGroupButton';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user