Compare commits

...

174 Commits

Author SHA1 Message Date
bincxz
ce1a00bed9 update Vaults icon from Shield to FolderLock for better visual consistency with SFTP
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:21:02 +08:00
bincxz
7df88f5bf7 fix: keep terminal autocomplete popup off the input line 2026-03-27 03:05:45 +08:00
bincxz
eeb42b1d20 fix: make vault and sftp theme switching instant 2026-03-27 02:51:23 +08:00
bincxz
23475fb1ce improve terminal theme preview synchronization 2026-03-27 02:36:21 +08:00
bincxz
fadd84606a refine terminal connection auth dialog styling 2026-03-27 01:39:02 +08:00
bincxz
d3e1a96702 optimize terminal theme side panel updates 2026-03-27 01:33:33 +08:00
bincxz
91fd44cccf fix terminal autocomplete path and popup behavior 2026-03-27 01:22:35 +08:00
陈大猫
5b6f45c896 perf: reduce workspace and theme switch rerenders (#537)
* fix: replace workspace pane border with text dimming for unfocused panes

Replace the 2px primary-color border and Tailwind ring with a subtler
focus indicator: unfocused panes reduce xterm canvas opacity to 70%,
making text slightly dimmer without adding visual clutter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use visibility:hidden for terminal caching and restore focus on tab switch

- Replace display:none with visibility:hidden for TerminalLayer and
  workspace panes to preserve xterm canvas state across tab switches
- Restore focus to the correct pane when terminal layer becomes visible
  again, preventing opacity flash from :focus-within CSS
- Reduce autocomplete popup box-shadow intensity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:03:12 +08:00
陈大猫
c924259fc0 fix: add local autocomplete specs and isolate command history per host (#536)
Add local spec files for commands missing from @withfig/autocomplete
(journalctl, yum, awk) and load them with priority over the upstream
package. Also enforce strict per-host isolation for command history —
previously cross-host matching by OS leaked host-specific commands
(e.g. cd /cq/) into unrelated sessions.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:04:42 +08:00
bincxz
f896f2a071 fix: polish autocomplete popup and bridge 2026-03-26 23:34:10 +08:00
bincxz
1851a8de71 Merge remote-tracking branch 'origin/main' 2026-03-26 23:22:15 +08:00
bincxz
53dd266f42 Merge branch 'feat/path-completion' 2026-03-26 23:21:51 +08:00
bincxz
5e05d25c2b fix: tighten autocomplete directory listing 2026-03-26 23:21:31 +08:00
bincxz
2d57015ac5 fix: harden path completion edge cases 2026-03-26 23:13:52 +08:00
bincxz
579dab56c2 fix: tighten path completion popup updates 2026-03-26 22:50:14 +08:00
bincxz
f1fea53af6 fix: avoid preload API collision with sftp 2026-03-26 22:38:44 +08:00
bincxz
aabae00970 fix: refine path completion popup behavior 2026-03-26 22:35:48 +08:00
Eric Chan
9136569809 feat: Add session activity indicator and store (#528)
* Add session activity indicator and store

Introduce a SessionActivityStore (useSyncExternalStore) to track which tabs/workspaces have unread terminal activity. TerminalLayer now strips terminal control sequences, listens for session data, and marks tabs as active when not focused; it also clears activity on focus change and prunes stale IDs. TopTabs consumes the activity map to render a breathing activity dot on session/workspace tabs and adjusts the workspace tab layout to show the dot next to the pane count. Add CSS animation for the activity indicator.

* fix: buffer incomplete escape sequences across data chunks

Add ChunkedEscapeFilter to carry partial ANSI/OSC escape-sequence
tails between successive data chunks, preventing false-positive
activity badges from split control sequences on busy sessions.

Also fix missing trailing newline in sessionActivity.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove 256-byte cap on pending escape sequence tails

Long OSC sequences (e.g. clipboard/title payloads) can exceed 256
bytes. Removing the cap ensures they are fully buffered across
chunks instead of being misclassified as printable output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: buffer OSC tails that end on bare ESC awaiting backslash

OSC sequences terminated with ESC\ can split at the ESC boundary.
Extend the incomplete tail regex to also match an in-progress OSC
sequence ending with ESC (awaiting the closing backslash).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:52:10 +08:00
bincxz
f2bcbe5123 fix: popup 用 Portal + position:fixed 渲染,不被分屏裁剪
之前 popup 在终端面板内部渲染,分屏时被 overflow:hidden 裁剪,
子面板展开会挤压相邻面板空间。

改为 React Portal 渲染到 document.body:
- containerRef 获取终端容器的 getBoundingClientRect
- 从相对坐标转换为 viewport 固定坐标
- position: fixed + zIndex: 10000 浮在所有内容之上
- effectiveMaxHeight 根据 viewport 底部剩余空间动态计算
- 移除 overlay div,popup 完全独立于终端 DOM 层级

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:49:34 +08:00
bincxz
3dcb792a55 fix: 深目录 prompt 检测 + 打字卡顿性能优化
1. prompt 扫描限制只对 > 和 › 生效(容易与重定向混淆),
   $ 和 # 扫描完整行——修复长 CWD 路径下 prompt 检测失败
2. 路径补全只在明确路径触发(/ ./ ../ ~/)或建议不足时才发 IPC,
   避免每次按键都做远程 ls
3. 快速打字时 debounce 延迟从 2x 增到 3x(300ms),减少 IPC 频率

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:45:12 +08:00
bincxz
5ca996d2d2 fix: 子面板选择时构建完整路径而非只写 entry 名
之前 handleSubDirSelect 只写最后一级名称(如 ca-certificates/),
导致 cd /usr/local/share/ca-certificates/ 变成 cd /ca-certificates/。

修复:从面板的 dirPath 构建完整路径,用 Ctrl+U 清除当前输入,
重写完整命令(如 cd /usr/local/share/ca-certificates/)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:37:35 +08:00
bincxz
9ea1c3a92e fix: 子面板聚焦时 → 键不再被顶层 ghost text handler 拦截
顶层 → handler 条件加 subDirFocusLevel < 0 守卫:
当焦点在子面板中时(focusLevel >= 0),整个顶层 → 处理器被跳过,
让后续的子面板导航块处理 → 键实现深层展开。

之前的 bug:顶层 → handler 的 "enter sub-dir from main" 条件不匹配,
但随后的 ghost text accept 条件匹配并消费了事件,
子面板的 → handler 永远执行不到。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:33:43 +08:00
bincxz
af85401a69 fix: → 键正确移焦点到新面板 + 面板不超出底部边界
1. expandSubDir 添加 moveFocus 参数:
   - ↑↓ 自动预加载时 moveFocus=false(焦点不动,只预加载)
   - → 键主动进入时 moveFocus=true(焦点移到新面板,selectedIndex=0)
2. effectiveMaxHeight 根据 position.y 动态计算,确保面板不超出底部

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:32:02 +08:00
bincxz
5d3af6d107 fix: 子面板自动滚动 + ↑↓导航自动展开下一级目录
1. 选中项使用 callback ref 自动 scrollIntoView,
   解决滚动条不跟随选中项的问题
2. 在子面板中 ↑↓ 导航到目录项时自动调用 expandSubDir
   预加载下一级内容,实现连续级联浏览体验

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:23:55 +08:00
bincxz
68ab65764e feat: 多级级联目录面板 — 支持无限深层展开
重构子目录面板从单个 subDirEntries 改为 subDirPanels 面板栈:
- subDirPanels: SubDirPanel[] — 级联面板数组
- subDirFocusLevel: number — 当前焦点层级(-1=主面板)
- → 键在任意层级选中目录后展开下一级面板
- ← 键返回上一级(收起当前面板)
- ↑↓ 在当前层级导航(同时收起右侧已展开的更深面板)
- 已展开但未聚焦的层级用 hover 色标记选中项
- 去掉子面板白色边框

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:21:10 +08:00
bincxz
514bea824a fix: fetchSuggestions 初始化顺序错误 — 用 ref 间接调用
handleSubDirSelect 定义在 fetchSuggestions 之前,直接引用会触发
ReferenceError: Cannot access before initialization。
改用 fetchSuggestionsRef 间接引用,在 fetchSuggestions 定义后同步更新。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:12 +08:00
陈大猫
de874fc8c5 fix: 修复双击检查更新崩溃 & 优化更新 UX (#522) (#531)
* fix: prevent double-click update crash and improve update UX (#522)

- Add state guards to prevent checkForUpdates during active download
- Disable "Check for Updates" button during checking/downloading/ready
- Make version badge trigger in-app download instead of opening GitHub
- Change error toast action from "Open Releases" to "View in Settings"
- Add "Download Now" button in system settings as primary action
- Keep GitHub release link as secondary fallback in settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reset download state when downloadUpdate() rejects

Clears _isDownloading and broadcasts error status on catch so the
update UI does not get stuck after a failed download attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only show Download Now after a completed update check

Prevents downloadUpdate() from being called with stale cached state
before electron-updater has run checkForUpdates(), avoiding a
"Please check update first" error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use correct broadcast function and prime updater before download

- Replace undefined broadcastUpdateStatus with broadcastToAllWindows
- Call checkForUpdate before downloadUpdate to ensure electron-updater
  has populated update metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use correct error payload field and guard unsupported platforms

- Use { error: ... } instead of { message: ... } in download error
  broadcast to match renderer expectations
- Bail out of startDownload when checkForUpdate returns unsupported
  or throws, instead of entering a failing download path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard startDownload against in-flight and no-update check results

Bail out when checkForUpdate returns checking, not-available, or
unsupported states to prevent calling downloadUpdate prematurely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove duplicate error broadcast and fallback to releases on unsupported

- Remove redundant broadcastToAllWindows in download catch (global
  error listener already handles it)
- Open release page instead of silently returning when platform
  does not support auto-update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: check supported before available to ensure release page fallback

Unsupported platforms return { available: false, supported: false },
so the supported check must come first to open the release page
instead of silently returning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip download when update is already ready or downloading

Guard against re-downloading when checkForUpdate returns ready or
downloading sentinel, preventing overwrite of valid install state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to release page when electron-updater reports no update

When GitHub API found an update but electron-updater does not,
open the release page instead of silently doing nothing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:59:11 +08:00
bincxz
14ba1e779c fix: 二级菜单白边、深层展开、底部溢出
1. 去掉子面板多余的 borderLeft — sharedBoxStyle 已有完整边框
2. 选择子目录后 50ms 延迟 re-trigger fetchSuggestions,
   实现无限深层展开(cd /usr/ → lib/ → → python3/ → ...)
3. overlay 容器和内部 div 设 overflow: visible,
   防止子面板在终端底部时被父容器裁剪

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:58:19 +08:00
bincxz
0c1e269718 fix: 二级目录面板 — → 键优先进入子面板 + 对齐选中项位置
1. → 键优先级修复:当 popup 有选中的目录且子目录已加载时,
   → 进入子面板而非接受 ghost text
2. 子面板用 marginTop 对齐选中项的行位置,不再固定在顶/底部
3. 未聚焦时也显示 border-left 边框区分主/子面板

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:46:38 +08:00
bincxz
a96f5c332c feat: 目录级联展开 — 选中目录时右侧显示子目录面板
选中一个目录补全项时,自动获取其子目录内容并在右侧展开面板:
- ↑↓ 在主面板导航时自动 fetch 目录内容
- → 进入子目录面板(焦点转移到右侧)
- ← 返回主面板
- 在子目录面板中 ↑↓ 导航,Enter/Tab/→ 选择并插入
- 选中项带 › 展开指示符
- 子面板带 cursor 颜色左边框标识焦点
- 最多显示 50 个子目录条目

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:39:50 +08:00
bincxz
a0b8d74582 fix: 路径补全图标从 emoji 改为 lucide-react 图标
Folder/File/Link 替代 📁📄🔗,与项目已有图标风格一致。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:29:26 +08:00
陈大猫
e6166a1de3 feat: AI Provider 高级参数配置 (#532) (#533)
* feat: expose advanced AI model parameters in provider settings (#532)

Add collapsible "Advanced Parameters" section to provider config with
optional max_tokens, temperature, top_p, frequency_penalty, and
presence_penalty fields. Parameters are merged into streamText() calls
only when explicitly set, otherwise provider defaults apply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use maxOutputTokens instead of maxTokens for ai@6 SDK

The streamText CallSettings in ai@6 expects maxOutputTokens, not
maxTokens. Without this fix the user's max_tokens setting is silently
ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: allow negative penalty input and clamp params on save

- Use raw string state for penalty fields so typing "-" is not
  discarded before the digit is entered
- Clamp all parameters to valid ranges on save (temperature 0-2,
  topP 0-1, penalties -2 to 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use raw string state for all numeric advanced param inputs

Prevents intermediate text like "0." from being normalized to "0"
during keyboard entry of decimal values for temperature, topP, and
maxTokens fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clamp max_tokens to minimum of 1 after rounding

Prevents Math.round(0.4) = 0 from being persisted and causing
streamText to reject with "maxOutputTokens must be >= 1".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject non-finite max_tokens before persisting

Guard with Number.isFinite to prevent Infinity from being stored
and forwarded to streamText.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:26:13 +08:00
bincxz
ae797e5fb1 feat: 远程路径补全 — cd/ls/cat 等命令自动列出文件和目录
通过 SSH exec channel 在远程机器上执行 ls 命令获取目录内容,
在补全菜单中显示文件/目录列表。

实现:
- sshBridge.cjs: 新增 netcatty:ssh:listdir IPC handler,
  使用 session.conn.exec() 在独立 channel 执行 ls -1Fap,
  不影响交互式终端
- main.cjs: 新增 netcatty:local:listdir,本地终端用 fs.readdir
- preload.cjs: 暴露 listRemoteDir/listLocalDir API
- remotePathCompleter.ts: 路径补全核心模块
  - shouldDoPathCompletion: 检测 fig spec template/generators、
    PATH_COMMANDS 白名单、或输入以 /  ./  ../  ~/ 开头
  - resolvePathComponents: 解析目录路径和过滤前缀
  - getPathSuggestions: 编排检测→解析→IPC→格式化
  - 5 秒 TTL 缓存 + in-flight 请求去重
- completionEngine.ts: SuggestionSource 新增 "path" 类型,
  CompletionSuggestion 新增 fileType 字段,
  getCompletions 接受 sessionId/protocol/cwd 参数
- AutocompletePopup.tsx: 路径建议显示 📁/📄/🔗 图标
- Terminal.tsx: 传入 protocol 和 getCwd

支持:SSH 远程目录、本地终端、cd 仅显示目录、
  空格文件名转义、head -100 限制输出

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:23:45 +08:00
陈大猫
9a7d4decff feat: 终端命令自动补全系统 (#527)
* feat: 终端命令自动补全系统

实现类似 WindTerm/Fish 的终端命令自动补全功能,不依赖机器学习:

- 历史命令持久化存储:按主机分组,频率+时间衰减排序,跨会话共享
- 前缀匹配引擎:支持精确前缀匹配和模糊匹配(首字符+连续字符+词边界加权)
- Prompt 检测器:识别 bash/$、zsh/%、fish/> 等常见 prompt 模式,排除 vim/less 等程序
- Ghost Text 插件:xterm.js 自定义 addon,光标后灰色行内建议,→ 接受全部,Ctrl+→ 接受一词
- 弹出补全菜单:浮动列表 UI,↑↓ 导航,Tab/Enter 选中,Esc 关闭,来源标记(h/c/s/o/a)
- @withfig/autocomplete 集成:600+ 命令规范的子命令、选项、参数补全
- 上下文感知:解析命令行 token,根据当前位置提供对应类型的补全
- 用户配置:启用/禁用、Ghost Text、弹出菜单、防抖延迟、最小字符数等
- 快速打字防误触:检测打字速度,快速输入时抑制建议
- 输入防抖 100ms,异步匹配不阻塞 UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 补全菜单混合展示历史命令和 spec 子命令

- 输入已知命令名(如 docker)时即使没有空格也预览子命令
- 历史命令条数从 8 降为 5,留空间给 spec 建议
- 修复 wordIndex === 0 时 spec 补全被跳过的问题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 补全菜单在终端底部时向上展开

当光标在终端下方、空间不足时,弹出菜单向上展开(底边对齐光标行),
避免溢出终端区域。列表顺序和选中逻辑不变——最可能的选项始终在顶部,
用户初始向下选择。参考 Termius 的做法。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 补全菜单跟随终端主题 + Enter 直接执行命令

1. 补全菜单颜色从终端主题动态派生(color-mix),不再硬编码色值,
   确保与任何主题视觉一致
2. 在弹出菜单中按 Enter 选择命令时,直接插入并发送 \r 执行,
   无需用户再按一次回车
3. Tab/鼠标点击仍然只插入不执行(保留选择后编辑的能力)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 PR review 发现的全部 20 个问题

功能修复:
- #1 修复 selectAndExecute 导致命令双重录入历史:用 suppressNextEnterRecordRef
  标志位让 handleInput 的 Enter 分支跳过已经录入过的命令
- #2 修复 Prompt 末尾 $ 误判:重写 findPromptBoundary 为从左到右逐字符扫描,
  排除 $HOME/$PATH 等变量引用(检查 $ 前是否有空格、是否在 token 内部)
- #6 快速打字检测实际生效:快速打字时 debounce 延迟翻倍(200ms),等用户停顿
- #8 resolveSpecContext 处理带参数的 option(如 --name value):
  识别 option 的 args 字段,自动跳过下一个 token
- #9 Ghost text 位置随终端滚动/渲染更新:注册 term.onRender 回调
- #13 Escape 键不再拦截 vi-mode:仅在 popup 可见时消费 Escape,
  ghost text 显示时不拦截(ghost text 是被动的,不应阻止 shell 交互)
- #14 所有 setState 统一使用 EMPTY_STATE 常量,不再遗漏 expandUpward 字段

架构修复:
- #3 消除 CustomEvent 通信:改为 onAcceptText 回调注入,
  Terminal.tsx 直接传 writeToSession 回调给 hook,
  删除 createXTermRuntime 中约 20 行 listener 代码和 cleanupAutocompleteListener 字段
- #7 xterm 私有 API 访问集中到 xtermUtils.ts:getCellDimensions 统一入口,
  带缓存机制,仅在首次访问或 terminal 切换时触发 DOM 测量
- #16 删除 getCommandNameSuggestions 中多余的动态自导入 await import("./figSpecLoader")

性能修复:
- #5 合并 ghost text 和 popup 的查询路径:删除独立的 getInlineSuggestion,
  fetchSuggestions 只调一次 getCompletions,ghost text 取 completions[0]
- #10 preloadCommonSpecs 分批加载(每批 8 个,requestIdleCallback 间隔),
  延迟 200ms 启动,且检查 enabled 才执行
- #11 scoreEntry 改为 scoreEntryAt(entry, now),now 在查询开始时缓存一次
- #15 scrollIntoView 从 smooth 改为 instant,消除快速导航动画排队
- #19 loadSpec 添加 in-flight 去重(inFlightLoads Map),同一 spec 并发加载只触发一次 import
- #20 存储满时淘汰改为按 score 排序后保留前半,而非按插入顺序

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复二审发现的全部 10 个问题

功能修复:
- #1(高) insertSuggestion 改用实时 detectPrompt 而非过时的 lastPromptRef,
  修复用户继续打字后 Tab 选择建议导致字符重复插入的 bug
- #2(中) handleInput Enter 录入历史优先用实时 detectPrompt,
  修复快速打字场景下 recordCommand 记录不完整命令
- #9 suppressNextEnterRecordRef 添加 100ms 安全超时清除,防止 flag 残留
- #10 getNextWord 从 index 1 开始搜索分隔符,修复 ghost text 以 / 开头时
  一次接受全部而非逐段的问题

性能修复:
- #3(中) GhostTextAddon 注册 term.onResize 调用 invalidateCellDimensionCache,
  确保 resize/字体变化后 cell 尺寸缓存正确失效
- #4 updatePosition 缓存 lastLeft/lastTop,位置无变化时跳过 DOM 写入;
  字体属性移到 show() 中只设置一次,不再每帧写 6 个 style
- #5 统一 clearState() 函数替代所有 setState({...EMPTY_STATE}),
  带 popupVisible 守卫避免无效 re-render
- #6 hasSpec 中 specs.includes() 改为 Set.has(),O(1) 查找

架构修复:
- #7 Terminal.tsx 中 autocompleteAcceptTextRef 去掉多余的 useCallback 包装
- #8 删除 AutocompletePopup 的 onClose 死代码 prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: popup 默认不选中任何项,用户按 ↑/↓ 后才选中

修复输入 ls 等简单命令时回车误执行联想结果的问题:
- selectedIndex 初始为 -1(无选中),Enter 直接执行用户输入的命令
- 用户按 ↑/↓ 导航后 selectedIndex >= 0,此时 Enter 才执行选中的建议
- Tab 仍然可以直接接受第一条建议(主动接受行为)
- Enter 无选中时关闭 popup 并让按键透传到终端

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fig spec 改为从静态资源 fetch 加载,修复生产构建中补全不工作

根因:@vite-ignore 动态 import 在 Electron 生产构建中无法解析
node_modules 路径(app:// 协议只能访问 dist/ 目录)。

修复方案(与 Monaco 编辑器相同的模式):
- 新增 scripts/copy-fig-specs.cjs,prebuild 时将全部 739 个 fig spec
  从 node_modules/@withfig/autocomplete/build/ 复制到 public/fig-specs/
- Vite 自动将 public/ 内容复制到 dist/,app:// 协议可以正常访问
- figSpecLoader.ts 改用 fetch + Blob URL + dynamic import 加载 spec,
  同时保留 @vite-ignore import 作为 fallback(兼容 dev 模式)
- public/fig-specs 加入 .gitignore(构建时生成,不进版本控制)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ESLint 忽略 public/fig-specs 目录(第三方生成代码)

与 public/monaco 相同的处理方式——这些是从 node_modules 复制的
第三方构建产物,不应被项目 ESLint 规则检查。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 输入完整子命令名时展示其选项(如 git commit 显示 --message 等)

当 currentToken 完全匹配一个子命令时(如 "git commit" 中的 "commit"),
导航进入该子命令并展示其 options 和 sub-subcommands 作为预览。

之前的逻辑因为 name !== currentToken 过滤掉了完全匹配的项,
且 resolveSpecContext 的 consumedTokens 不包含当前 token,
导致停留在父级而看不到子级的选项。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 fig spec index.js 解析失败导致补全不工作

根因:index.js 格式为 var e=[...],diffVersionedCompletions=[...];
正则 /var\s+\w+\s*=\s*(\[[\s\S]*?\]);/ 要求 ] 后紧跟 ;,
但第一个数组后面是 , 不是 ;,导致非贪婪匹配跳到第二个 ];,
捕获了两个数组拼在一起,JSON.parse 失败,spec 列表为空。

修复:改用 indexOf 找第一个 [ 和对应的 ],直接截取子串解析。
fig spec 的 index 是简单的字符串平坦数组,无嵌套括号。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fig spec 改用 URL 直接 dynamic import,移除 fetch+Blob 方案

fetch + Blob URL + import() 方案可能被 Electron CSP 策略阻止。
改为直接用完整 URL 做 dynamic import:
- dev: import("http://localhost:5173/fig-specs/git.js")
- prod: import("app://./fig-specs/git.js")

两种环境下动态 import 都能正常解析模块,无需 fetch 中间步骤。
同时简化 getAvailableSpecs 也用同样方式,移除 fetch+正则解析。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fig spec 改为通过 Electron IPC 加载,彻底解决 dev/prod 加载问题

之前的方案(静态文件 + dynamic import / fetch + Blob URL)都因为
Vite dev server 对 .js 文件的模块转换和 Electron CSP 限制而失败。

新方案:通过 main process 的 Node.js require() 加载 fig spec,
通过 IPC 传给 renderer:
- main.cjs: 添加 netcatty:figspec:list 和 netcatty:figspec:load handler
- preload.cjs: 暴露 listFigSpecs() 和 loadFigSpec() API
- figSpecLoader.ts: 通过 window.netcatty bridge 调用 IPC

优势:
- main process 直接访问 node_modules,dev 和 production 都可靠
- 无需复制文件到 public/、无需 @vite-ignore hack
- spec 数据通过 IPC 序列化传输,无 CSP 限制
- 删除了 scripts/copy-fig-specs.cjs 和 public/fig-specs/ 相关代码

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: main process fig spec 加载改用 import() 替代 require()

@withfig/autocomplete 是 ESM 包("type": "module"),
CommonJS 的 require() 无法加载 ESM 模块会抛 ERR_REQUIRE_ESM。
改用 dynamic import() 在 async handler 中加载。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fig spec 加载用 pathToFileURL 绕过 package.json exports 限制

@withfig/autocomplete 的 exports 字段只允许 import "." 和 "./dynamic",
Node.js 严格遵守 exports map 拒绝解析 build/git.js 等子路径。

改为手动拼接文件绝对路径 + pathToFileURL 转换为 file:// URL 后 import,
完全绕过 Node.js 的 package exports 限制。

同时修复 promptDetector 不再 trim 尾部空格(用 cursorX 确定实际输入长度),
确保 "git commit " 的尾部空格被保留,触发空 token 显示选项列表。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: 补全菜单添加详情面板 + 清理调试日志

- 选中或悬停补全项时,右侧显示详情面板(类似 VS Code IntelliSense)
  - 显示完整命令名、来源类型标签(Option/Subcommand/History 等)
  - 显示完整的描述文本(不再截断)
- source 标记移到左侧,与描述分离,更易读
- 悬停和键盘选中都能触发详情面板
- 向上展开时详情面板也正确对齐
- 清理所有临时调试 console.log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 清理全部调试日志

移除 autocomplete 模块中所有临时 console.log 调试语句,
仅保留 figSpecLoader 中的 console.warn 用于真实错误报告。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 三审问题修复 — 移除多余 prop、过滤子路径 spec、防路径遍历

1. 移除 Terminal.tsx 传给 AutocompletePopup 的多余 onClose prop
2. getCommandNameSuggestions 过滤含 / 的 spec 名(aws/s3 等不是直接命令)
3. figspec:load IPC handler 添加 .. 路径遍历检查

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 5 个问题全部修复

1. [P1] fuzzy 匹配建议不以 userInput 开头时,用 Ctrl+U 清行再写入完整命令,
   避免 substring 截断产生损坏的命令行
2. [P2] Ghost addon 初始化改用 polling 等待 termRef,解决首次挂载时
   termRef.current 为 null 导致 ghost text 永远不激活的问题
3. [P2] popup overlay 改为 pointer-events-none 透传,仅 popup 自身设
   pointer-events: auto,不再阻止终端区域的鼠标交互
4. [P2] getCompletions 异步返回后重新 detectPrompt 校验输入是否已变,
   丢弃过时的补全结果避免覆盖新状态
5. [P2] prompt 检测支持折行:当 line.isWrapped 时向上回溯查找 prompt 行,
   拼接多行内容作为完整 userInput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第二轮 3 个问题修复

1. [P2] broadcast 模式下 autocomplete 插入也触发广播 —
   onAcceptText 回调中调用 onBroadcastInputRef 通知其他 session
2. [P2] 支持无尾随空格的 prompt(如 cmd.exe C:\path>)—
   prompt 字符后允许直接是行尾,boundary 为 i+1
3. [P2] 光标移动 escape 序列(Left/Home/End)清除过时建议 —
   不再静默忽略,改为 clearState() 清除 popup 和 ghost text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第三轮 3 个问题修复

1. [P2] commandBufferRef 处理 Ctrl+U 清行 — fuzzy 匹配发送 \x15 时
   重置 buffer,避免 onCommandExecuted 记录错误的拼接命令
2. [P2] fetchVersionRef 递增计数器废弃过时异步结果 — clearState/Escape
   关闭 popup 时 bump version,getCompletions 返回后检查 version 匹配,
   防止已关闭的 popup 被旧请求重新打开
3. [P2] prompt scanLimit 从 80 提高到 200 — 支持包含 git branch、
   kube context、长路径的 prompt,超过 80 列不再失效

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第四轮 3 个问题修复

1. [P1] 拒绝绝对路径 — figspec:load IPC handler 检查 commandName
   不以 / 或 \ 开头,防止 path.join 丢弃前缀导致任意 JS 执行
2. [P1] cmd.exe prompt > 后不要求空格 — 对 > ❯ ➜ › 等 prompt 字符
   不强制要求后跟空格,支持 C:\src>dir 格式
3. [P2] serial line mode 下 autocomplete 走 serialLineBufferRef —
   在串口 lineMode 时不直接 writeToSession,而是缓冲到 line buffer
   并处理 local echo,与正常按键输入行为一致

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第五轮 — translateToString(false) 保留尾部空格

translateToString(true) 会 trim 行尾空格,导致 cursorX 截取的
userInput 与实际行内容不一致。改为 translateToString(false) 保留
原始空格,确保 "git commit " 的尾部空格被正确保留用于触发选项补全。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: 设置页添加自动补全开关(启用/Ghost Text/弹出菜单)

在终端设置页末尾新增「自动补全」区域,包含三个开关:
- 启用自动补全:总开关
- 行内建议(Ghost Text):光标后灰色建议文本
- 弹出菜单:浮动补全列表

子开关在总开关关闭时 disabled。中英文 i18n 翻译齐全。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第六轮 3 个问题修复

1. [P1] 光标不在行尾时禁止补全 — 检测 cursorX 后方是否有字符,
   有则 clearState 不显示建议,避免 mid-line 插入导致文本重复
2. [P2] Enter 录入历史改为先尝试实时 detectPrompt,失败则 fallback
   到 lastPromptRef 缓存,应对高延迟 SSH 下 buffer 未回显的情况
3. [P2] fuzzy 替换在 Windows host 上用退格清行而非 Ctrl+U —
   cmd.exe/PowerShell 不支持 Ctrl+U,改为发送 \b 退格序列

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第七轮 — commandBuffer 退格处理 + 接受后历史记录

1. [P2] commandBufferRef 处理 \b 退格 — Windows fuzzy 替换用退格
   清行时正确移除 buffer 末尾字符,避免记录拼接错误的命令
2. [P3] lastAcceptedCommandRef 追踪接受的补全文本 — Tab/→ 接受后
   立即 Enter 时用追踪值录入历史,不依赖可能未回显的 buffer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第八轮 — 历史记录准确性 + 设置同步

1. [P2] 用户继续编辑后清除 lastAcceptedCommandRef — Tab 接受
   "git status" 后追加 " --short" 再 Enter 时记录完整编辑后的命令
2. [P2] Ghost text →/Tab 接受路径也设置 lastAcceptedCommandRef —
   确保所有接受路径在快速 Enter 时都能准确记录命令
3. [P2] autocomplete 设置加入 SYNCABLE_TERMINAL_KEYS —
   跨设备同步时保留自动补全偏好

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第九轮 — REPL 误识别 + 本地终端 OS 检测

1. [P1] local terminal 的 hostOs 改用 navigator.platform 检测实际 OS,
   避免 Windows 上 fallback 到 "linux" 导致 Ctrl+U 清行失败
2. [P2] 回退 > 无条件接受改动,恢复要求 > 后跟空格或行尾 —
   避免 python >>>、mysql>、sqlite> 等 REPL 被误识别为 shell prompt
3. 新增 REPL NON_PROMPT_PATTERNS:>>>(python)和 word>(mysql/redis)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第十轮 4 个问题修复

1. [P1] cmd.exe prompt C:\path> — 对 > 特判:前面是 \ 或 / 时允许无空格,
   避免误匹配 REPL(python>>>、mysql>)的同时支持 Windows cmd prompt
2. [P2] serial lineMode autocomplete 不再 early return — fall through 到
   共享的 commandBuffer/broadcast 更新逻辑
3. [P2] serial 字符模式 + localEcho 时 autocomplete 插入文本也本地回显
4. [P3] 运行时关闭 autocomplete 时调用 clearState() 清除已显示的 popup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第十一轮 — option args、PS2 误识别、bridge 缓存

1. [P2] resolveSpecContext 返回 option 的 args — 当光标在 option 参数
   位置时(如 git archive --format |),返回该 option 的 args 而非
   subcommand 的 args,使 tar/zip 等枚举值能正确补全
2. [P2] 排除 bare > 作为 shell prompt — bash PS2 续行提示 > 加入
   NON_PROMPT_PATTERNS,避免在多行命令续行和 REPL 中误触发补全
3. [P3] bridge 不存在时不缓存 null — preload 时 bridge 可能未就绪,
   缓存 null 会永久禁用该命令的 spec 补全

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第十二轮 — prompt 检测取最后一个分隔符

Starship/Powerlevel10k 等 prompt 包含多个 prompt 字符
(如 ➜  repo git:(main) $),之前在第一个 ➜ 就停了,
把后续 prompt 文本当成用户输入。

改为收集所有候选 prompt 边界,返回最后一个。确保
"➜  repo git:(main) $ ls" 中 userInput 正确为 "ls"。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 第十三轮 — prompt 搜索范围限制 + cmd.exe 路径

1. [P2] prompt 扫描限制在行前 60% — 避免 "echo foo > bar" 中的
   重定向符 > 被当作 prompt 结束(prompt 不会出现在行尾部分)
2. [P3] cmd.exe 路径检测扩展 — 除了 \ / 前缀,也检测行首是否有
   驱动器号 (X:) 模式,支持 C:\Users\me> 等标准 Windows prompt

P1 (高延迟 SSH buffer 滞后) 和 P2 (Enter 时 stale prompt) 属于
prompt 检测方案的固有局限,根本解决需要 OSC 133 Shell Integration,
不在本 PR 范围内。已有 lastAcceptedCommandRef fallback 缓解。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:45:34 +08:00
陈大猫
fa29515095 feat: SFTP 全局书签支持 (#529) (#530)
* feat: add global SFTP bookmarks shared across all hosts (#529)

- Add global bookmark support with separate localStorage storage
- Global bookmarks appear on all hosts with a globe icon indicator
- "+Global" button in bookmark popover to save path as global
- Global bookmarks sorted before host-specific bookmarks
- Improve SFTP error display: use Unplug icon, refined styling,
  auto-expand connection logs on error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: toggle bookmark correctly removes global-only bookmarks

When a path is only globally bookmarked, the toggle button now
removes the global bookmark instead of creating a duplicate host one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:19:33 +08:00
陈大猫
34f9d2a663 chore: 死代码清理与架构分层修复 (#524)
* chore: 移除死代码并修复架构分层违规

- 删除未使用的 ACP 模块 (infrastructure/ai/acp/)
- 删除未使用的 AI 组件 (ExecutionPlan, PermissionDialog)
- 将 syncPayload.ts 从 domain 移至 application 层,修复分层违规
- 移除未使用的导出 (useSecurityState, useProviderStatus, GitHubAuthState,
  getAgentCommandLabel, ImageAttachment, HotkeyActions)
- 收窄 Electron bridge module.exports,移除未使用的导出函数
- 将仅内部使用的函数/类型取消导出 (isSupportedLocale, SyncDashboard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 二次审查清理 — 移除更多死代码和架构违规

- 移除未使用的 ConversationEmptyState 组件和类型
- 移除未使用的 PromptInputSelect 系列组件 (5 个导出)
- 移除 global.d.ts 中残留的 SMBConfig 类型和 cloudSyncSmb* 方法声明
- 移除 useAutoSync.ts 中未使用的 toast 导入 (同时修复 application→components 反向依赖)
- 清理因删除而产生的多余 import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 消除直接 localStorage 访问,提取 safeSend 共享工具

localStorage 集中化:
- 新增 storageKeys 常量: SIDE_PANEL_WIDTH, PF_RECONNECT_CANCEL, DEBUG_HOTKEYS, DEBUG_UPDATE_DEMO
- TerminalLayer/SettingsApplicationTab/App.tsx/useUpdateCheck 改用 localStorageAdapter
- CloudSyncManager 内部方法改用 localStorageAdapter
- portForwardingService 改用 localStorageAdapter + 集中 key

safeSend 去重:
- 新增 electron/bridges/ipcUtils.cjs 共享模块
- sshBridge/sftpBridge/portForwardingBridge/sshAuthHelper/aiBridge 统一引用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 终审清理 — 移除未使用的 require 和废弃类型别名

- 移除 sftpBridge.cjs 中未使用的 require("node:net")
- 移除 aiBridge.cjs 中未使用的 require("node:path")
- 移除 types.ts 中已废弃的 ChatMessageImage 类型别名

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 ESLint 错误 — 组件不再直接导入 infrastructure

- 新增 useStoredNumber hook,TerminalLayer 通过 hook 访问侧边栏宽度
- SettingsApplicationTab 的 isUpdateDemoMode 改为从 useUpdateCheck hook 传入
- 移除 useCloudSync.ts 中未使用的 CloudSyncManager 导入和 GitHubAuthState 接口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 提取 notification port,消除 application 层对 components 的依赖

将 toast 通知抽象为 application/notification.ts 端口,
UI 层通过 setNotify 注入实现,useAutoSync 改用 notify 接口。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:14:37 +08:00
陈大猫
90d161c1b5 refactor: 精简 MCP server 工具集,移除 SFTP/multiExec/terminalWrite
精简 ACP agent 工具集,与 Catty Agent 保持一致,只保留核心工具:
- get_environment
- terminal_execute
- terminal_send_input

移除内容:
- 7 个 sftp_* 工具 (sftp_list_directory, sftp_read_file, sftp_write_file,
  sftp_mkdir, sftp_remove, sftp_rename, sftp_stat)
- multi_host_execute 工具
- ENABLE_SFTP_TOOLS 环境变量和 sftpAvailable 字段
- WRITE_METHODS 中的 sftp/multiExec 条目
- dispatch 中的 sftp/multiExec 路由和 multiExec scope 验证
- mcpServerBridge 中的 sessionSupportsSftp/scopeHasSftpSessions 函数
- getContext description 中的 SFTP 说明

bridge 层的 SFTP/multiExec handler 函数保留(UI SFTP 面板仍在使用)。

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:51:43 +08:00
陈大猫
7a5b6f506e feat: Catty Agent 支持串口会话命令执行 (#520)
* feat: Catty Agent 支持串口会话命令执行 (#520)

串口连接的网络设备(华为交换机、Cisco 路由器等)使用厂商自有 CLI,
无法识别 Agent 原有的 shell 包裹语法(__NCMCP_ markers、eval、trap)。

新增 execViaRawPty 函数,直接发送原始命令到串口,通过 idle timeout
检测命令完成,无 shell 语法包裹。

- 新增 execViaRawPty:原始命令执行,2s idle timeout 检测完成
- terminalBridge: 串口 session 添加 protocol/shellKind 字段
- mcpServerBridge: handleGetContext 发现串口会话,handleExec/handleTerminalWrite 支持串口
- aiBridge: ai:exec 和 ai:terminal:write 增加 serialPort 分支
- systemPrompt: Agent 提示词增加串口会话使用指南

Closes #520

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review 问题全量修复

P1:
- handleExec 移除死代码(内层 if 条件永远为 true)
- 串口会话跳过 shell 安全黑名单(shutdown 在 Cisco 是正常接口命令)
- MCP tool 描述更新:terminal_execute/get_environment/multi_host_execute 不再只说 "shell command"
- 串口检查增加 protocol === "serial" guard,不再纯靠 duck typing

P2:
- execViaRawPty 编码改为 latin1,与 terminalBridge 终端解码一致
- exitCode 改为 null(而非 -1),MCP 响应中 null 时不输出 exit code 行
- idle timer 改为收到第一个数据后才启动,避免慢设备超时返回空输出
- idle timeout 默认从 2s 调为 3s,适配低速串口
- serialPort.write 统一用 safeWrite 包裹 try-catch
- echo 剥离仅在 lines.length > 1 时执行,避免误删唯一输出行

P3:
- cancelKey 用简单自增序列替代 crypto.randomBytes
- serialPort.on 前增加 typeof 检查
- finish 函数签名差异增加注释说明

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 第二轮 review 问题修复

P2:
- MCP server terminal 类工具 (terminal_execute/terminal_send_input/multi_host_execute)
  跳过 blocklist,由 bridge 层做 session-aware 检查,解决串口 shutdown 等命令
  在 MCP 层就被拦截的问题
- handleTerminalWrite (mcpServerBridge + aiBridge) 串口会话跳过 blocklist,
  与 handleExec 保持一致
- handleMultiExec 移除外层 blocklist,每个 session 由 handleExec 独立检查
- 移除 execViaRawPty 中的死代码 receivedFirstChunk 变量
- handleGetContext 返回的 description 补充 serial 会话说明

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex review 问题修复

- [P2] toolExecutors.ts executeTerminalExecute 也需要跳过串口 blocklist,
  否则 Catty Agent renderer 侧的 checkCommandSafety 会在命令到达 bridge
  之前拦截 shutdown 等合法设备命令
- [P2] execViaRawPty 增加 noResponseTimer,无输出命令(enable、
  configure terminal 等)不再等满 60s 整体超时,而是 2×idleMs 后正常返回
- [P1] 串口 blocklist skip 设计决策加注释:serial 协议由用户主动选择,
  如果串口连的是 Linux shell 应使用 local 协议

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex 第二轮 review 修复

- [P2] noResponseTimer 从 2×idleMs 调整为 min(idleMs*4, timeoutMs/4),
  默认 12s,避免截断慢速网络设备操作
- [P1] 串口 blocklist skip 设计说明扩充:serial 协议由用户主动选择,
  且 execViaRawPty 不做 shell 解释,blocklist 中的 shell 元字符
  即使发到串口连接的 Linux shell 也不会被解释执行

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: execViaRawPty echo 阶段使用更长的 idle timeout

ping/traceroute/copy 等命令在回显后可能沉默数秒才产出真正输出。
引入 chunkCount 区分 echo 阶段(前 2 个 chunk)和正式输出阶段:
echo 阶段使用 2×idleMs(默认 6s),正式输出阶段使用 idleMs(3s)。
避免在回显后就误判命令已完成导致输出截断。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: noResponseTimer 增加无输出提示

设备无响应时返回提示信息 "(no output received — command may have
completed silently or may still be running)",让 AI 知道命令可能
仍在执行,避免误认为命令已成功完成后立即发送下一条命令。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Codex 第 5 轮 review 修复

- [P2] terminal_send_input 串口写入时将 \n 转换为 \r,
  网络设备期望 CR 作为回车而非 LF
- [P2] execViaRawPty 增加 512KB 输出上限,达到上限后停止
  重置 idle timer,避免 noisy session(持续发日志的设备)
  导致命令永远无法完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:04:17 +08:00
陈大猫
c49346f6cc fix: 编辑器查找/替换输入框无法粘贴内容 (#512) (#515)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
自定义粘贴处理器拦截了所有 Ctrl+V 事件,包括查找/替换控件内的输入框。
当焦点在 .find-widget 内时,改为读取剪贴板并直接插入到输入框中,
而非将内容粘贴到编辑器主体。

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:59:52 +08:00
陈大猫
39a398aa2b SFTP 右键菜单添加「复制文件路径」功能 (#514)
* feat(sftp): add "Copy file path" to right-click context menu (#507)

Add a context menu item that copies the full remote file/directory path
to clipboard using navigator.clipboard.writeText(). Works for both
files and directories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 使用 joinPath 构建复制路径,修复 Windows 路径分隔符问题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: joinPath 去除 Unix 路径尾部多余斜杠

避免 currentPath 带 trailing slash 时产生双斜杠路径(如 /var/log//syslog)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:57:59 +08:00
陈大猫
0b7c52523e feat: 终端沉浸模式 (#517)
* feat: add terminal immersive mode

When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its
colors to match the active terminal's theme, creating a visually
cohesive experience. Colors are derived from the terminal theme's
hex values and converted to HSL for CSS custom property overrides.

- Add useImmersiveMode hook with hex-to-HSL conversion and token derivation
- Add reapplyCurrentTheme to useSettingsState for restoring original theme
- Integrate with App.tsx to resolve active terminal's effective theme
- Add immersive mode toggle in Appearance settings with i18n (en/zh-CN)
- Add CSS transition class for smooth 300ms color changes
- Support cross-window sync via IPC for Settings window toggle
- Handle per-host theme overrides and workspace focused sessions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式多项改进与 bug 修复

- 修复 primaryForeground 硬编码白色导致浅色 cursor 对比度不足
- 修复 SettingsPage 直接导入 infrastructure 层违反架构约束
- 修复 TerminalSession 类型未导入导致 TS 编译错误
- 修复 TopTabs memo 缺少 logViews 导致 logView 变化不触发重渲染
- 重构 useImmersiveMode 为纯 effect hook,状态由 useSettingsState 统一管理
- Workspace 多终端主题不一致时禁用沉浸模式
- 排除 logView tab 误触发沉浸模式
- 沉浸模式下禁用 dark/light 切换按钮
- Agent 图标使用 CSS mask 跟随文字颜色
- Agent 下拉菜单 overflow-hidden 修复 hover 溢出
- 退出沉浸模式使用 overlay 淡出避免闪烁
- immersive-transition class 仅在沉浸实际生效时添加

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: 沉浸模式默认开启

新用户默认启用沉浸模式,已有设置的用户不受影响。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: 沉浸模式主题切换性能优化

- 启动时预计算所有内置主题的 CSS 字符串,切换时 O(1) 查表
- 自定义主题懒计算并缓存,后续切换同样 O(1)
- useLayoutEffect 替代 useEffect,paint 前完成避免闪烁
- 跳过无效的 dark/light class 切换
- apply 和 restore 逻辑拆分为独立 effect
- 去掉主题列表 hover 渐变动画

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 Codex review 提出的三个问题

- [P1] base UI theme 变化时不再覆盖沉浸模式的 dark/light class
- [P2] fingerprint 加入 theme.type,检测自定义主题 dark↔light 编辑
- [P2] 沉浸模式设置接入 sync pipeline (collect/apply/rehydrate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: focus 模式 workspace 支持沉浸 + settingsVersion 加入 immersiveMode

- focus 模式 workspace 使用 focusedSessionId 的主题,不再要求所有 session 一致
- settingsVersion 加入 immersiveMode 依赖,确保 auto-sync 能检测到变化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式 sync 一致性修复

- 初始化时将默认值写入 localStorage,确保 collectSyncableSettings 能收集到
- rehydrateAllFromStorage 后通过 IPC 广播给其他窗口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: focus 模式关闭 focused session 后 fallback 到剩余 session 的主题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式加入 storage event 跨窗口同步

将 immersiveMode 加入 settingsSnapshotRef 和 handleStorageChange,
确保 web/preview 场景下多窗口间沉浸模式状态同步。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式同步 Electron 原生窗口背景色

切换沉浸主题时同步调用 setTheme/setBackgroundColor,
使 Windows 上的窗口边框颜色与沉浸主题一致。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:47:50 +08:00
陈大猫
cb63f105aa Merge pull request #513 from crawt/feat/remove-root-paint-polling-use-renderer-ready
Feat/remove root paint polling use renderer ready
2026-03-26 00:20:21 +08:00
panwk
316e46de4b Mod:Removed waitForRootPaint polling helper from electron/bridges/windowManager.cjs.
Removed did-finish-load polling trigger that called markRendererReady via DOM child count checks.
Kept deferred show behavior based on:
ready-to-show
renderer-ready IPC from renderer
timeout fallback (dev and prod values unchanged)
2026-03-25 23:48:56 +08:00
panwk
1af5182b59 Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 23:42:06 +08:00
陈大猫
35194036cb Merge pull request #502 from crawt/perf/settings-window-prewarm-hide-on-close
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
perf(settings): prewarm settings window and hide on close
2026-03-25 01:24:48 +08:00
陈大猫
6a077a3855 Merge pull request #501 from binaricat/codex/optimize-ai-panel-tab-switch
Optimize AI panel tab switching
2026-03-25 01:19:30 +08:00
bincxz
43f4687bb9 Keep AI panel UI inside side panel layout 2026-03-25 01:13:49 +08:00
bincxz
bbb888ae1e Keep AI state mounted when side panels close 2026-03-25 01:09:36 +08:00
bincxz
c74b78a49d Reconcile AI session state with live sessions 2026-03-25 01:03:34 +08:00
panwk
7b2590e54e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 01:03:00 +08:00
bincxz
a7f42ec93e Avoid dropping unflushed AI sessions during cleanup 2026-03-25 00:57:48 +08:00
panwk
a886d509f8 perf(settings): prewarm settings window and hide on close
Instead of creating a new BrowserWindow on each user click, the settings window is now:
1. Pre-warmed silently 3 s after app startup (showOnLoad: false)
2. Hidden instead of destroyed when the user closes it
3. Instantly shown/focused on subsequent opens
2026-03-25 00:54:32 +08:00
bincxz
d6fea6c328 Preserve AI session state and cleanup across panel unmounts 2026-03-25 00:52:33 +08:00
bincxz
b6169f1735 Optimize AI panel tab switching 2026-03-25 00:46:59 +08:00
陈大猫
c97470a085 Merge pull request #500 from binaricat/codex/preserve-vault-hosts-state
Preserve vault hosts state across section switches
2026-03-25 00:38:10 +08:00
bincxz
98cb9d09df Preserve vault hosts state across vault section switches 2026-03-25 00:37:56 +08:00
陈大猫
9deb39dec2 Merge pull request #499 from binaricat/codex/jump-host-proxy-support
Support proxy config on jump hosts
2026-03-25 00:32:09 +08:00
bincxz
bb45279d4e Track jump-host proxy socket during chain setup 2026-03-25 00:23:55 +08:00
bincxz
6b1d9ee409 Gate jump-proxy checks on usable endpoints 2026-03-25 00:16:16 +08:00
bincxz
c0c0378df0 Ignore incomplete jump-host proxy configs 2026-03-25 00:09:26 +08:00
bincxz
093951150c Only validate first-hop jump proxies 2026-03-25 00:06:00 +08:00
bincxz
a0418039c4 Prefer jump-host proxy over target proxy guards 2026-03-25 00:04:35 +08:00
bincxz
559e71cfcc Block jump-host proxy auth placeholders 2026-03-25 00:02:59 +08:00
bincxz
a0a2567fa5 Validate jump-host proxy credentials early 2026-03-25 00:01:24 +08:00
陈大猫
d080a43ae6 Merge pull request #497 from crawt/feat/electron-v8-cache-lazy-bridges
feat(electron): enable V8 code cache and lazy-load non-critical bridges
2026-03-25 00:00:21 +08:00
bincxz
2c551cf5e8 Sanitize proxy credentials for jump hosts 2026-03-24 23:58:35 +08:00
bincxz
c54aa52191 Support proxy config on jump hosts 2026-03-24 23:56:28 +08:00
陈大猫
b8c838059a Merge pull request #496 from binaricat/codex/port-forward-jump-hosts
Support jump hosts for port forwarding
2026-03-24 23:55:00 +08:00
bincxz
007b4bd389 Treat cancelled port-forward setup as non-error 2026-03-24 23:50:00 +08:00
bincxz
13fd198243 Allow cancelling proxy setup for port forwarding 2026-03-24 23:48:29 +08:00
bincxz
2c562463c4 Respect cancellation during port-forward startup 2026-03-24 23:47:45 +08:00
bincxz
859d4b8156 Fix auto-start auth readiness checks 2026-03-24 23:45:54 +08:00
bincxz
c6e07cf149 Clean up port forwarding auto-start lint 2026-03-24 23:45:26 +08:00
bincxz
0ab18ce186 Fix port forwarding startup and cleanup races 2026-03-24 23:45:02 +08:00
bincxz
f814719b32 Fix jump-host port forwarding edge cases 2026-03-24 23:43:03 +08:00
bincxz
ee6b05892d Support jump hosts for port forwarding 2026-03-24 23:36:13 +08:00
陈大猫
0f98ffd4f7 Merge pull request #494 from binaricat/codex/ai-command-exec-fixes
Fix AI terminal execution completion and tool UI
2026-03-24 23:22:44 +08:00
bincxz
7ca5d0c832 Track pending ACP cancels during startup 2026-03-24 23:04:08 +08:00
bincxz
1a76d34696 Handle ACP startup cancellation and cmd echo 2026-03-24 23:01:41 +08:00
bincxz
0b2d1b613b Tighten prompt fallback matching 2026-03-24 22:59:35 +08:00
bincxz
ded989b374 Harden cmd tool-call echo handling 2026-03-24 22:57:18 +08:00
bincxz
04c6348bc0 Fix cmd wrapper variable expansion 2026-03-24 22:55:42 +08:00
bincxz
54297859e3 Fix AI cancellation and shell wrapper edge cases 2026-03-24 22:54:17 +08:00
panwk
d236adcd48 1.Enable V8 code caching for BrowserWindow instances by setting webPreferences.v8CacheOptions to bypassHeatCheck
2.Reduce eager main-process module loading by replacing several top-level bridge require() calls in main.cjs with lazy module getters
2026-03-24 22:48:15 +08:00
bincxz
4971f18bbe Fix AI terminal execution completion and tool UI 2026-03-24 22:41:40 +08:00
panwk
15687bd56e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-24 22:00:14 +08:00
陈大猫
76675ec515 Merge pull request #492 from binaricat/fix/smooth-scroll-default-off-490
fix: default smooth scrolling to off to prevent scroll freeze
2026-03-24 19:51:50 +08:00
bincxz
7c6304c355 fix: default smooth scrolling to off to prevent scroll freeze (#490)
When smooth scrolling is enabled (smoothScrollDuration: 120ms) and
an AI agent produces high-throughput output, the scroll animation
can't keep up with incoming data, causing the viewport to get stuck
mid-buffer. Users can't scroll to the bottom or Ctrl+C to interrupt.

Default to false. Users who prefer smooth scrolling can still enable
it in Settings > Terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:50:28 +08:00
陈大猫
8fdcbf87c2 Merge pull request #487 from binaricat/fix/empty-password-crash-482
fix: prevent crash on ECONNRESET from embedded SSH devices
2026-03-24 19:45:57 +08:00
bincxz
0326ba7556 fix: prevent duplicate exit events when conn.close fires before stream.close
ssh2 emits conn.once("close") before stream.on("close") during
transport drops. The conn.close handler was sending exit + deleting
the session, then stream.close would send a second misleading exit.

Now stream.close checks sessions.has() before sending exit, while
still flushing the data buffer unconditionally. This ensures:
- Buffer flush always happens (no data loss)
- Exit event is sent exactly once
- Transport errors are correctly reported

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:19:02 +08:00
bincxz
964230a737 fix: always use dynamic authHandler, detect encrypted PPK keys
P1: Change authMethods.length condition from > 1 to >= 1 so the
dynamic authHandler (which includes 'none' probing) is always used,
even when only keyboard-interactive is available. Fixes the
passwordless embedded device case when no keys/agent are discovered.

P1: Add PPK encryption detection to isKeyEncrypted() — check for
"Encryption:" header in PuTTY PPK format. Without this, encrypted
.ppk files were treated as unencrypted and attempted without a
passphrase, failing silently instead of triggering the passphrase
retry flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:09:37 +08:00
bincxz
5d551ee8e9 fix: address codex P1/P2 — agent none auth, PPK support, FIFO safety
P1: Add "none" to the agent-mode simple array auth path so passwordless
devices work even when agent forwarding is configured.

P1: Extend looksLikePrivateKey() to recognize PuTTY PPK format
("PuTTY-User-Key-File" prefix) so PPK keys in ~/.ssh/ are not
incorrectly filtered out.

P2: Add stat().isFile() check before readFile() in all key discovery
paths to skip FIFOs, sockets, directories, and other non-regular files
that would block readFile() indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:40:27 +08:00
bincxz
ec4e209972 fix: address codex P1s — transport error in stream close, key content validation
P1: Transport errors on established sessions now surface correctly.
The stream.on("close") handler (which fires before conn close and
after buffer flush) checks session._transportError and sends exit
with exitCode:1 and the error message instead of a misleading
exitCode:0 "closed".

P1: Add looksLikePrivateKey() content validation to all key discovery
functions. Files matching id_* that don't start with "-----BEGIN" or
"openssh-key-v1" are skipped, preventing non-key files from being
passed to ssh2 as privateKey (which would abort connect before
password/agent fallback could run).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:00:47 +08:00
bincxz
c141fbc11e fix: defer post-settle exit event to preserve buffered stream data
Codex P2: when a transport error (ECONNRESET) arrives after the session
is established, the error handler was immediately sending netcatty:exit,
causing preload to remove data listeners before the stream close handler
could flush the 8ms data buffer. Users would lose the last chunk of
terminal output.

Now the error handler stores the error message on the session object
(_transportError) instead of sending exit immediately. The close handler
(which fires after stream close + buffer flush) checks for this flag
and sends the exit event with the transport error info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:07 +08:00
bincxz
8e61ccac91 fix: address agent review — double exit event, array none auth, label consistency
Medium: Close handler now checks sessions.has(sessionId) before sending
netcatty:exit, preventing a misleading exitCode:0 "closed" event after
the error handler already reported the real transport failure.

Medium: Array-based auth path in buildAuthHandler now includes "none"
as the first method, matching the dynamic handler behavior.

Low: Set lastAttemptedLabel to "none (no credentials)" so the rejection
message is consistent with the initial onAuthAttempt callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:32:00 +08:00
bincxz
7c5047f22e feat: scan ~/.ssh/ for all id_* keys instead of hardcoded list
Replace the fixed DEFAULT_KEY_NAMES array ("id_ed25519", "id_ecdsa",
"id_rsa") with a directory scan using /^id_[\w-]+$/ regex, matching
Tabby's PrivateKeyLocator behavior. This discovers keys like
id_ed25519_work, id_dsa, or any custom-named key automatically.

Preferred keys (ed25519, ecdsa, rsa) are still tried first, followed
by any additional keys found in alphabetical order.

Applied to both sshBridge.cjs and sshAuthHelper.cjs (all four
key discovery functions + the get-default-keys IPC handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:25:18 +08:00
bincxz
c10100a314 feat: always try SSH 'none' auth first (matches OpenSSH and Tabby)
Restore unconditional 'none' auth as the first method tried. Per
RFC 4252, the 'none' request is the standard way for clients to
discover which auth methods the server supports. It also enables
passwordless login on embedded devices (#482).

This matches the behavior of OpenSSH (which always sends 'none'
first) and Tabby (which unconditionally adds { type: 'none' } as
the first element of allAuthMethods). Most SSH servers do not count
'none' toward MaxAuthTries per the RFC.

Applied to both the main SSH authHandler and the shared
buildAuthHandler used by SFTP/chain/exec connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:18 +08:00
bincxz
5a294aa306 revert: remove automatic 'none' auth probing (needs separate feature)
Codex review identified P1 issues: automatic 'none' auth before any
other method can exhaust MaxAuthTries on hardened servers, breaking
connections that previously worked. The 'none' auth support for
embedded devices should be a user-facing option, not automatic.

This commit reverts the 'none' auth additions while keeping the
crash prevention fixes (settled guard, conn.destroy, error wrapping).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:09:50 +08:00
bincxz
54b3ba2c01 fix: address Codex review — conditional none auth and post-ready error handling
P2: Only try 'none' auth when no explicit credentials (password/key/agent)
are configured. Avoids wasting an auth attempt on servers with low
MaxAuthTries.

P2: Post-settle errors on active sessions now send netcatty:exit to the
renderer instead of being silently swallowed, so transport failures
(keepalive timeout, ECONNRESET) are correctly reported as errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:55:38 +08:00
bincxz
f25822fdae feat: support SSH 'none' auth for embedded devices with no password
The SSH protocol's 'none' auth method allows login without any
credentials — common on embedded devices (routers, switches) where
root has no password. ssh2 tries this by default, but Netcatty's
custom authHandler and buildAuthHandler overrode the default behavior
and never attempted 'none', making it impossible to connect to these
devices.

Now both authHandlers try 'none' as the first method (before any
other auth) on the initial call (methodsLeft === null). If the server
accepts it, the connection succeeds immediately. If rejected, the
normal auth flow continues with publickey/password/keyboard-interactive.

This is the root cause of #482: the user's embedded device needed
'none' auth, but Netcatty never tried it, then the auth failure +
ECONNRESET combination crashed the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:44:22 +08:00
bincxz
69f433c161 fix: prevent crash on ECONNRESET from embedded devices with empty password (#482)
When connecting to embedded devices with legacy algorithms and no password,
the SSH connection could crash the app with an uncaught ECONNRESET exception.

Three fixes:
1. Guard against duplicate error handling in conn.on("error") — once the
   promise is settled, late errors (e.g. ECONNRESET after auth failure)
   are logged but no longer re-reject or re-notify the renderer.
2. Destroy the SSH connection on error/timeout to prevent the underlying
   TCP socket from emitting further uncaught errors.
3. Wrap non-auth errors in startSSHSessionWrapper with clean Error objects
   so Electron's ipcMain.handle can serialize them back to the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:29 +08:00
陈大猫
6087343203 Merge pull request #489 from binaricat/fix/restore-npm-rebuild-macos-474
fix: restore npmRebuild for macOS/Windows to fix local terminal crash
2026-03-24 16:37:54 +08:00
bincxz
bb63de2658 fix: restore npmRebuild for macOS/Windows to fix posix_spawnp crash (#474)
PR #449 set npmRebuild: false in electron-builder.config.cjs to fix a
Linux architecture mismatch. But this also disabled native module
recompilation for macOS and Windows builds, causing node-pty to ship
with the wrong ABI (Node.js instead of Electron). On macOS, this
manifests as "posix_spawnp failed" when opening a local terminal.

Restore npmRebuild: true. Linux builds are unaffected because they
already run ensure-node-pty-linux.sh before packaging with explicit
npm_config_arch, and the redundant rebuild uses the same arch setting.

User confirmed: 1.0.62 works, 1.0.63 (first release after #449) fails.

Closes #474

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:32:54 +08:00
陈大猫
fd938a84e4 Merge pull request #485 from yaotiancheng-ola/feature/macos_stats
feat(terminal): support server stats on macOS
2026-03-24 16:29:15 +08:00
陈大猫
c2e629ad61 Merge pull request #488 from binaricat/fix/sftp-permissions-not-displayed-480
fix: SFTP permissions dialog shows empty (000) instead of actual file permissions
2026-03-24 16:21:08 +08:00
bincxz
4bf61c02a0 fix: pass permissions field from SFTP listing to frontend (#480)
The remote file listing mapper in useSftpDirectoryListing.ts was
dropping the `permissions` field returned by the backend. This caused
the permissions dialog to show all checkboxes unchecked (000) and the
file list to show "--" in the permissions column.

One-line fix: add `permissions: f.permissions` to the mapped object.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:17:22 +08:00
陈大猫
4747217929 Merge pull request #486 from binaricat/fix/sftp-filename-tooltip-480
fix(sftp): show full filename tooltip on hover
2026-03-24 16:15:42 +08:00
bincxz
fb3cdd0661 fix(sftp): show full filename tooltip on hover in file list (#480)
Add title attribute to the file name span so truncated names reveal
their full text via native browser tooltip on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:02:41 +08:00
陈大猫
11ca8fba87 Merge pull request #484 from binaricat/feat/unified-auth-logs-and-sftp-progress
feat: unified auth logging for SSH and SFTP connections
2026-03-24 15:55:52 +08:00
bincxz
7ffc4b4c7f fix: address Codex round 4 — keyboard-interactive progress for all paths
P2: Wrap keyboard-interactive handlers in SSH chain, SFTP chain, and
SFTP main connections to emit "waiting for user input..." and "user
responded" progress events, matching the SSH main connection behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:47:14 +08:00
bincxz
fe27dd8a9d fix: address Codex round 3 — accurate auth logs and clean state
P2: Remove premature onAuthAttempt calls from buildAuthHandler's array
branch — methods are listed before connect(), making logs inaccurate.

P2: Handle "waiting for user input..." and "user responded" as literal
log messages, not as "Trying X..." format, in both SSH and SFTP.

P3: Clear connectionLogs after successful SFTP connect so directory
navigation doesn't replay stale auth transcript in the loading overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:40:05 +08:00
bincxz
eca11e9d2a fix: address Codex round 2 — array auth logging, cached overlay, stale listener
P2: Emit onAuthAttempt notifications from buildAuthHandler's array
branch so single-method SFTP connections (e.g. password-only) show
auth method logs in the connection panel.

P3: Show connectionLogs in the cached-files loading overlay so repeat
connections still display auth progress during reconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:26:52 +08:00
徐三
779aa31ef8 chore(terminal): clarify server stats scope comment
- update Terminal server-stats comment to reflect Linux/macOS support
- no runtime behavior changes
2026-03-24 15:21:47 +08:00
徐三
2c8670a6c6 fix(terminal): stop server-stats polling on unsupported OS
- add explicit Linux/macOS guard in server-stats hook
- return UNSUPPORTED_OS from ssh bridge when uname is not Linux/Darwin
- fail fast when stats payload cannot be parsed to avoid futile polling
- wire Terminal to pass supported-OS hint to useServerStats
2026-03-24 15:18:12 +08:00
bincxz
a94293d31e fix: address Codex review — scoped progress, local reset, connected event
P2: Guard SFTP progress callback with navSeqRef check to prevent stale
auth logs from leaking into a reused tab after retry/disconnect.

P3: Reset connectionLogs when connecting to local filesystem, avoiding
stale remote auth logs showing in the local pane.

P3: Emit 'connected' progress event when the final SFTP SSH session
is ready, so the log confirms the connection completed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:11:42 +08:00
徐三
04b62f7ba3 feat(terminal): support server stats on macOS via remote OS auto-detection
- auto-detect remote OS in sshBridge using uname -s
- add macOS stats collection path (CPU, memory, swap, processes, disk, network)
- keep existing Linux stats pipeline and parsing logic
- remove Linux-only gating in useServerStats and Terminal display logic
- show server stats whenever connected (not restricted by host.os)
- add CPU hover fallback UI when per-core data is unavailable (e.g. macOS)
- update bridge type docs in global.d.ts to reflect cross-OS stats support
2026-03-24 15:00:33 +08:00
bincxz
45794b7f6f feat: unified auth logging for SSH and SFTP connections
Add detailed authentication method logs to both SSH terminal and SFTP
connection flows, giving users visibility into which methods are tried,
rejected, or require input.

Backend (shared):
- sshAuthHelper buildAuthHandler: track lastAttemptedLabel, log method
  rejections and "all methods exhausted" via onAuthAttempt callback
- sftpBridge: add sendSftpProgress helper, wire onAuthAttempt to both
  chain and main buildAuthHandler calls, emit connecting/authenticating/
  connected/error progress events via new IPC channel

Backend (SSH-specific):
- sshBridge: log method rejections in custom authHandler, log
  keyboard-interactive prompt/response and all-methods-exhausted

IPC/Bridge:
- preload: register netcatty:sftp:connection-progress listener, expose
  onSftpConnectionProgress in bridge API
- global.d.ts: add onSftpConnectionProgress type

Frontend (SFTP):
- types.ts: add connectionLogs to SftpPane
- useSftpConnections: subscribe to progress events during connect,
  convert to human-readable log lines, accumulate in pane state
- SftpPaneFileList: show logs below spinner during connecting, show
  expandable "Show logs" in error view with collapsible log panel

Frontend (SSH):
- createTerminalSessionStarters: format rejected methods with ✗ prefix
  and "all methods exhausted" message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:34:25 +08:00
陈大猫
314072a631 Merge pull request #479 from binaricat/feat/ssh-config-identity-file
feat: support IdentityFile from SSH config import
2026-03-24 14:07:08 +08:00
bincxz
c9f1951e28 fix: address Codex review — quoted paths, stale keys, managed source round-trip
P1: serializeHostsToSshConfig now emits IdentityFile directives so
managed ssh_config sources preserve key paths on sync. Paths with
spaces are automatically quoted.

P2: Unquote IdentityFile paths during import — ssh_config allows
quoted paths for filenames with spaces, but the quotes were stored
literally and caused fs.readFile to fail.

P2: Clear identityFilePaths when applying an identity profile, and
only forward them at connection time when no vault key is selected.
Prevents stale local key paths from triggering unrelated passphrase
prompts after switching to a different credential source.

P1 (SFTP): Forward identityFilePaths for jump hosts in SFTP credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:59:36 +08:00
bincxz
7f83b22c95 fix: address Codex review — SFTP jump host identity files and skip handling
P1: Pass identityFilePaths for jump hosts in SFTP credentials so chain
connections can load IdentityFile keys for bastion hosts.

P2: When the passphrase dialog is skipped or times out (not just
cancelled), clear the encrypted key and continue to the next identity
file. Previously skip/timeout fell through and left the encrypted key
in connOpts, causing the same stall this feature is meant to fix.

Applies to all 4 identity file loading paths (SSH chain, SSH main,
SFTP chain, SFTP main).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:05 +08:00
bincxz
b7082ab198 feat: add native file picker for local key file selection
Replace the manual-only text input with a file picker button that opens
the system file dialog (showOpenDialog with showHiddenFiles enabled so
~/.ssh/ keys are visible). Users can still type a path manually or use
the browse button.

Changes:
- electron/main.cjs: add netcatty:selectFile IPC handler
- electron/preload.cjs: expose selectFile on bridge
- global.d.ts: add selectFile type
- HostDetailsPanel.tsx: add FolderOpen browse button next to path input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
9369495e22 feat: add local key file path UI in host editor
Add "Local Key File" option in the host credential type selector.
Users can specify local SSH key file paths (e.g. ~/.ssh/id_ed25519)
as an alternative to selecting a key from the vault. This is the
primary UI for keys imported via SSH config's IdentityFile directive.

UI behavior:
- Credential selector now shows three options: Key, Certificate,
  Local Key File
- Local key file paths are displayed as a list with delete buttons
- Text input with Enter/Add support for adding new paths
- Selecting a vault key clears local key paths (and vice versa)
- Paths are stored as host.identityFilePaths and resolved at
  connection time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
e3fdb1f7ff feat: support IdentityFile from SSH config import (#463)
SSH config import now parses the `IdentityFile` directive and stores
the file paths on the host as `identityFilePaths`. At connection time,
the SSH and SFTP bridges resolve these paths, read the key file content,
and use it for authentication — matching the behavior of OpenSSH and
Tabby.

If the key file is encrypted, a passphrase dialog is shown before
connecting. If the user cancels, the key is skipped and auth falls back
to other methods. If the file doesn't exist, a warning is logged and
the next key path is tried.

Changes:
- domain/models.ts: add `identityFilePaths` to Host interface
- domain/vaultImport.ts: parse `IdentityFile`, expand `~`, store paths
- global.d.ts: add `identityFilePaths` to NetcattySSHOptions and
  NetcattyJumpHost types
- createTerminalSessionStarters.ts: pass identityFilePaths for both
  main connection and jump hosts
- useSftpHostCredentials.ts: pass identityFilePaths for SFTP
- sshBridge.cjs: read identity files at connection time for both main
  and chain connections, with encrypted key passphrase prompting
- sftpBridge.cjs: same for SFTP main and chain connections

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
陈大猫
b9bc6b95e5 Merge pull request #477 from binaricat/fix/chain-encrypted-key-passphrase-463
fix: prompt passphrase for encrypted keys on jump hosts and SFTP
2026-03-24 13:48:40 +08:00
bincxz
5cbaae8d2f fix: throw auth-level error on SFTP passphrase cancel for password fallback
Address Codex P2: when the passphrase dialog is cancelled, the thrown
error now includes 'authentication' in the message and sets
level='client-authentication'. This allows the SFTP frontend's
isAuthError() check to recognize it and fall back to the password
retry path, preserving the key-first-then-password behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:43:05 +08:00
bincxz
915e571c63 fix: use readable host/key label in passphrase dialog
Address Codex P3: the passphrase modal was showing UUIDs or generic
placeholders like "private-key" / "hop-1-key" instead of the host
label or hostname. Now pass the human-readable label/hostname as
keyName so users can identify which key needs the passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:37:06 +08:00
bincxz
86a43655e1 fix: destroy proxy socket when SFTP passphrase is cancelled
Address Codex P2: when using a proxy and an encrypted key, cancelling
the passphrase dialog cleaned up chain connections but leaked the
proxy socket in connectionSocket. Now explicitly destroy it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:29:07 +08:00
bincxz
e47d86874f fix: clean up chain connections when SFTP passphrase is cancelled
Address Codex P2: when the passphrase dialog is cancelled for the
final SFTP host, any already-open proxy/jump-host connections were
leaked because the throw bypassed the cleanup path. Now explicitly
end all chain connections before throwing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:22:39 +08:00
bincxz
369de6fff2 fix: clear encrypted key when passphrase is skipped or times out
Address Codex P1 review: when the passphrase dialog is skipped or
times out, the encrypted key was left in connOpts.privateKey without
a passphrase. buildAuthHandler would still attempt it as publickey-user,
causing the same stall this PR fixes. Now delete connOpts.privateKey
in all non-success paths so auth falls back to password/keyboard-interactive.

Applies to SSH chain, SFTP chain, and SFTP main connection paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:16:05 +08:00
陈大猫
3aa414ad05 Merge pull request #478 from binaricat/codex/fix-sidebar-snippet-execution-order
fix: restore proper snippet paste semantics for sidebar clicks
2026-03-24 13:13:36 +08:00
bincxz
356c27d0fb fix: send auto-run Enter outside bracketed paste markers
Codex review caught a P1 regression: when a multi-line snippet had
noAutoRun=false, the \r was appended before wrapping in bracketed
paste, causing shells to treat the Enter as pasted text instead of a
submit action. Now the bracketed paste wraps only the command text,
and \r is appended afterward so it is sent as a real keypress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:51:22 +08:00
bincxz
ae94e7e529 fix: register snippet executor only after terminal is connected
Address Codex review feedback: the snippet executor was registered on
mount before the session was ready, causing sidebar snippet clicks to
be silently dropped during the connecting/reconnecting window instead
of falling through to TerminalLayer's raw writeToSession fallback.

Now the executor is only published when status === "connected" and is
cleared back to null on disconnect so the fallback path is used for
sessions that aren't ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:45:23 +08:00
bincxz
5828503ffc fix: restore proper snippet paste semantics for sidebar clicks 2026-03-24 11:48:02 +08:00
bincxz
1c0f45e410 fix: prompt passphrase for encrypted keys on jump hosts and SFTP (#463)
When an SSH config specifies an encrypted IdentityFile for a jump host
(e.g. `IdentityFile ~/.ssh/id_ed25519` with passphrase protection),
the chain connection passed the encrypted key to ssh2 without a
passphrase. ssh2 failed to parse it and the auth hung until timeout,
with no user-visible prompt.

The same issue existed for SFTP connections using encrypted keys.

Now detect encrypted keys via `isKeyEncrypted()` before connecting and
prompt the user for the passphrase via the existing passphrase dialog.
If the user cancels, a clear error is shown. If skipped, auth falls
back to other methods (password, keyboard-interactive, default keys).

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:07 +08:00
陈大猫
5c791cebe5 Merge pull request #476 from binaricat/fix/ssh-error-crash-452
fix: prevent SSH connection errors from crashing the entire app
2026-03-24 10:42:23 +08:00
bincxz
0ce6b0f777 fix: expand non-fatal network error coverage in safety net
Add EHOSTDOWN, ENETDOWN, EPROTO, EPERM to the isNonFatalNetworkError
check. Also refactor to switch/case for readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:40:33 +08:00
bincxz
6fca38a209 fix: prevent SSH connection errors from crashing the entire app (#452)
ssh2 emits multiple error events per failed connection (e.g. ECONNRESET
followed by "Connection lost before handshake"). Several code paths used
`.once("error")` which removed the listener after the first event,
leaving the second error unhandled and crashing the process via the
uncaughtException handler's re-throw.

Root cause: `runDistroDetection` ran unconditionally after connection
attempts (including failures), creating a new SSHClient to the same
unreachable host. Its `execCommand` used `.once("error")`, so the
second ssh2 error event had no listener and became an uncaught exception.

Fixes:
- execCommand: `.once("error")` → `.on("error")` with settled guard and
  explicit `conn.end()` cleanup
- runDistroDetection: move into try block so it only runs after
  successful connections
- portForwardingBridge: same `.once` → `.on` fix
- sftpBridge: add catch-all error listener after cleanup() removes the
  pre-ready listeners
- main.cjs: suppress non-fatal SSH/network errors in uncaughtException
  and unhandledRejection handlers as defense-in-depth (log to crash
  bridge, do not re-throw)

Closes #452

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:31:51 +08:00
Leo Pan
52541a6066 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh (#473)
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力

Co-authored-by: panwukan <panwukan@yco.pet>
2026-03-24 09:35:48 +08:00
panwukan
6d35301436 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力
2026-03-24 06:40:12 +08:00
陈大猫
5d29c8d91a fix: support IPv6 addresses in quick connect and fix display formatting (#472)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +08:00
陈大猫
196b1f8dbb feat: add terminal smooth scrolling setting (#471)
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)

Inspired by #467 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:39:03 +08:00
陈大猫
f1065745bc perf(keyword-highlight): skip cellMap for ASCII lines and share empty result array (#470)
- Use a regex ASCII test to detect lines where string indices equal cell
  columns, skipping the buildStringToCellMap buffer walk entirely. Most
  terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
  a new array per scanLine call, reducing GC pressure during scrollback.

Inspired by #466 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:24:39 +08:00
陈大猫
c67befa0e9 perf(keyword-highlight): reduce latency with throttled rAF and line cache (#469)
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache

Based on #464 by @crawt with fixes for review feedback:

- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
  so onWriteParsed highlights land with fresh content instead of trailing
  by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
  like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
  unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
  translateToString() renders as spaces — keeps the map aligned with
  the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change

Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard rAF callback against stale state and add debounce fallback

- Re-check enabled/alternate-buffer inside the rAF callback so a
  pending frame doesn't resurrect decorations after the user disables
  highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
  (where Chromium suspends rAF) still get highlight updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent fallback timer from being cleared on rAF-pending path

- Don't clear debounceTimer at the start of immediate mode — in hidden
  tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
  were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
  tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear stale rAF in fallback timer and add alternate buffer guard

- Cancel the pending rAF and clear animationFrameId in the fallback
  timer callback so hidden-tab refreshes don't leave animationFrameId
  stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
  matching the guard already present in the rAF callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: extract executeRefresh to ensure all timer paths clear stale rAF

A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.

Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:01 +08:00
陈大猫
cea83d6cb1 Revert "Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)" (#468)
This reverts commit 293ee46b26.
2026-03-23 21:46:04 +08:00
Leo Pan
293ee46b26 Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning

- Split triggerRefresh into two modes: "immediate" (rAF, for new output
  and rule changes) and "debounced" (setTimeout, for scroll/resize),
  eliminating the fixed 200ms delay after each write that caused visible
  highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
  actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
  prevent leaks and stale results.

* fix: include cell layout in highlight cache key to prevent misplaced decorations

Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.

Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:43:29 +08:00
陈大猫
a6af1dffed fix: resolve SSH chain connection hang and improve connection progress (#465)
* fix: resolve SSH chain connection hang and improve connection progress

- Fix Promise never settling when conn 'close' fires before 'ready'
  during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
  chain), including: connecting, key exchange, auth attempts, forwarding,
  shell opening
- Surface auth method attempts (SSH agent, key names, password) in
  progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH

- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
  emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
  don't render as 'Chain 1/1' (P3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: mark shell-open failure as settled before closing connection

The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:28:44 +08:00
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:44:49 +08:00
bincxz
a62353bb41 fix: respect bracketedPasteMode and disableBracketedPaste for snippets
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
  disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
  to term.modes, but respects user opt-out)

Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:39:48 +08:00
bincxz
d2ab27ab92 fix: normalize line endings and bracket-paste multi-line snippets
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.

- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
  so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data

Fixes #455

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:36 +08:00
陈大猫
65f62983b6 Merge pull request #461 from binaricat/fix/sftp-home-dir
fix: detect actual home directory for SFTP auto-open
2026-03-23 17:21:16 +08:00
bincxz
56d3109d23 fix: abort timed-out exec channel, treat realpath '/' as ambiguous
- Close/destroy the SSH exec stream when the 5s timeout fires to
  avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
  non-root users fall through to the candidate probe chain instead
  of incorrectly opening at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:13 +08:00
bincxz
34ab6c0e98 fix: add 5s timeout to SSH echo ~ home dir probe
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:32 +08:00
bincxz
3db9b0aa26 fix: restore listSftp fallback when statSftp is unavailable
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:03:06 +08:00
陈大猫
fe49ea74e2 Merge pull request #460 from binaricat/fix/update-metadata-verify
ci: verify and recover update metadata after artifact merge
2026-03-23 16:59:38 +08:00
bincxz
be91740582 fix: add actions:read permission for artifact recovery in release job
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:56:27 +08:00
bincxz
ad15d8ceb5 fix: detect actual home directory for SFTP instead of hardcoding /home
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir

Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).

Fixes #458

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:54:36 +08:00
bincxz
c37fe8f9e0 ci: verify and recover update metadata after artifact merge
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.

Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.

Fixes #456

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:44:52 +08:00
陈大猫
b0924c14b1 Merge pull request #454 from binaricat/feat/crash-logs
feat: crash log capture and viewer in Settings
2026-03-23 15:56:12 +08:00
bincxz
774c25086e fix: truncate crash log env info with tooltip on overflow
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:45:45 +08:00
bincxz
05c0d43bc4 feat: enrich crash logs with error metadata and process details
- Extract error properties (code, errno, syscall, hostname, port,
  signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
  reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
  attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:41:45 +08:00
bincxz
baac8670d3 feat: enrich crash log entries with environment diagnostics
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.

These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:34:02 +08:00
bincxz
c84bf497f2 fix: address codex review round 6 — stream line counting, tail-read logs
- listLogs: stream-count newlines instead of reading entire file content
  just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
  avoiding O(file_size) memory/CPU for crash-loop scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:23:14 +08:00
bincxz
ac5f708eba fix: address codex review round 5 — filter benign rejections and clean exits
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
  avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
  those are normal shutdowns, not crashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:12:46 +08:00
bincxz
ecba2560c9 fix: address codex review round 4 — skip benign errors, check openPath result
- Move EPIPE/ERR_STREAM_DESTROYED check before captureError so benign
  stream teardown errors don't pollute crash logs.
- Check shell.openPath return value (error string) instead of always
  returning success: true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:03:27 +08:00
bincxz
ff638c64cd fix: address codex review round 3 — dedupe logs, reload after clear
- Mark re-thrown unhandledRejection errors so uncaughtException handler
  skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
  so partial delete failures still show remaining files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:54:23 +08:00
bincxz
3db6465340 fix: address codex review round 2 — early require, stale request guard
- Move crashLogBridge require before process error handlers so it is
  available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
  results when the user clicks different log files in quick succession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:50 +08:00
bincxz
2b4f8d33c9 fix: address codex review — re-throw unhandled rejections, early crash capture
- P1: Re-throw in unhandledRejection handler to preserve default fatal
  semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
  ensureLogDir() so crash logs work even before init() is called,
  catching early startup failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:14:04 +08:00
bincxz
bc6c0a2ef6 feat: add crash log capture and viewer in Settings > System
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:05:56 +08:00
陈大猫
9cccc943ff Merge pull request #451 from tces1/patch-1 2026-03-23 12:31:30 +08:00
Eric Chan
cecda50ce2 Add 'meslolgs nf' to local fonts list
Fixes an issue on macOS where MesloLGS NF was incorrectly filtered out of the terminal font list
2026-03-23 12:28:30 +08:00
bincxz
c136006108 fix: prevent x64 build from producing arm64 packages with wrong native modules
Some checks failed
build-packages / release (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
The linux target config specified arch: ['x64', 'arm64'] for each format,
causing the x64 build job to also produce arm64 packages. These packages
contained x86-64 native modules (node-pty, serialport) since the x64 job
only rebuilds for x64. When artifacts were merged in the release job,
the incorrect arm64 deb from the x64 build could overwrite the correct
one from the arm64 build.

Remove arch from linux target config so the CLI flags (--x64/--arm64)
control which architecture is built per job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:25:12 +08:00
陈大猫
ba073219e5 Merge pull request #450 from binaricat/fix/linux-native-module-arch-verification
ci(linux): enhance native module arch verification
2026-03-23 09:43:41 +08:00
li88iioo
034e5ea3bc ci(linux): enhance artifact verification and architecture handling
- Added environment variables for npm configuration to specify architecture in CI jobs for both x64 and arm64 builds.
- Implemented verification steps for downloaded Linux deb artifacts, ensuring both amd64 and arm64 versions are checked for integrity.
- Updated the `ensure-node-pty-linux.sh` script to resolve and verify serialport prebuilds, ensuring compatibility with the specified architecture.
- Enhanced the `verify-linux-deb-artifact.sh` script to allow optional deb file input and improved error handling for missing artifacts.

These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
2026-03-23 09:40:56 +08:00
136 changed files with 11464 additions and 3569 deletions

View File

@@ -93,6 +93,8 @@ jobs:
name: build-linux-x64
runs-on: ubuntu-22.04
env:
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -159,6 +161,8 @@ jobs:
container:
image: debian:bullseye
env:
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -226,6 +230,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -239,6 +244,54 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Verify update metadata files
run: |
missing=0
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "Re-downloading individual artifacts to recover missing files..."
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
tmpdir="/tmp/artifact-${name}"
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
if [ -d "${tmpdir}" ]; then
for yml in "${tmpdir}"/latest*.yml; do
[ -f "$yml" ] && cp -v "$yml" artifacts/
done
fi
done
echo "After recovery:"
ls -la artifacts/*.yml
fi
# Final check — fail if any update yml is still missing
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::error::$f is still missing after recovery attempt"
exit 1
fi
done
echo "All update metadata files present."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify downloaded Linux amd64 deb artifact
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
- name: Verify downloaded Linux arm64 deb artifact metadata
env:
VERIFY_LOAD: "0"
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:

110
App.tsx
View File

@@ -1,6 +1,7 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { usePortForwardingState } from './application/state/usePortForwardingState';
@@ -14,10 +15,15 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './application/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -29,7 +35,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -98,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
IS_DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
const LazySftpView = lazy(() =>
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
@@ -172,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
const {
setTheme,
resolvedTheme,
terminalThemeId,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -192,6 +198,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
} = settings;
const {
@@ -271,6 +279,56 @@ function App({ settings }: { settings: SettingsState }) {
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// ---------------------------------------------------------------------------
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
const host = hosts.find(h => h.id === s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return TERMINAL_THEMES.find(t => t.id === themeId)
|| customThemes.find(t => t.id === themeId)
|| currentTerminalTheme;
};
// Workspace
const workspace = workspaces.find(w => w.id === activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = sessions.find(s => s.id === workspace.focusedSessionId)
?? sessions.find(s => wsSessionIds.includes(s.id));
return focused ? resolveTheme(focused) : null;
}
// Split mode: require all sessions to share the same theme
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
// Single session tab
const session = sessions.find(s => s.id === activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
useImmersiveMode({
isImmersive: immersiveMode,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
@@ -316,7 +374,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
@@ -351,7 +409,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [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.
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
@@ -374,23 +432,18 @@ function App({ settings }: { settings: SettingsState }) {
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [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, passphrase: k.passphrase, })),
[keys]
);
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: portForwardingKeys,
keys,
identities,
});
// Sync tray menu data + handle tray actions
@@ -452,9 +505,8 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
@@ -466,7 +518,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
// Tray panel actions (from main process)
useEffect(() => {
@@ -1210,7 +1262,7 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -1231,6 +1283,7 @@ function App({ settings }: { settings: SettingsState }) {
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
@@ -1252,6 +1305,8 @@ function App({ settings }: { settings: SettingsState }) {
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -1280,7 +1335,20 @@ function App({ settings }: { settings: SettingsState }) {
/>
</VaultViewContainer>
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
<SftpViewMount
hosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
/>
<TerminalLayerMount
hosts={hosts}

View File

@@ -99,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -216,6 +231,9 @@ const en: Messages = {
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.immersiveMode': 'Immersive Mode',
'settings.appearance.immersiveMode.desc':
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
@@ -296,6 +314,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
@@ -334,6 +355,15 @@ const en: Messages = {
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
'settings.terminal.autocomplete.ghostText': 'Ghost text',
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
@@ -583,6 +613,8 @@ const en: Messages = {
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
@@ -694,6 +726,7 @@ const en: Messages = {
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
@@ -876,9 +909,12 @@ const en: Messages = {
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
'hostDetails.credential.key': 'Key',
'hostDetails.credential.certificate': 'Certificate',
'hostDetails.credential.localKeyFile': 'Local Key File',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',
@@ -1563,6 +1599,10 @@ const en: Messages = {
'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.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',

View File

@@ -83,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -200,6 +215,9 @@ const zhCN: Messages = {
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.immersiveMode': '沉浸模式',
'settings.appearance.immersiveMode.desc':
'启用后UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.placeholder':
@@ -410,6 +428,8 @@ const zhCN: Messages = {
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
@@ -568,9 +588,12 @@ const zhCN: Messages = {
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
'hostDetails.credential.key': '密钥',
'hostDetails.credential.certificate': '证书',
'hostDetails.credential.localKeyFile': '本地密钥文件',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '浏览…',
'hostDetails.credential.missing': '凭据不存在',
'hostDetails.keys.search': '搜索密钥…',
'hostDetails.keys.empty': '暂无密钥',
@@ -1035,6 +1058,7 @@ const zhCN: Messages = {
'sftp.upload.phase.compressed': '压缩传输',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
@@ -1204,6 +1228,8 @@ const zhCN: Messages = {
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
@@ -1242,6 +1268,15 @@ const zhCN: Messages = {
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
'settings.terminal.autocomplete.ghostText': '行内建议',
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell。',
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
'settings.shortcuts.scheme.label': '键盘快捷键',
@@ -1578,6 +1613,10 @@ const zhCN: Messages = {
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
'ai.providers.advancedParams': '高级参数',
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
// AI Codex
'ai.codex': 'Codex',

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ export interface SftpPane {
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
@@ -33,6 +34,7 @@ export const createEmptyPane = (
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",

View File

@@ -159,6 +159,7 @@ export const useSftpConnections = ({
loading: true,
reconnecting: false,
error: null,
connectionLogs: [],
filenameEncoding, // Reset encoding for new connection
}));
@@ -213,13 +214,57 @@ export const useSftpConnections = ({
loading: true,
reconnecting: prev.reconnecting,
error: null,
connectionLogs: [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
if (sid !== sftpSessionId) return;
let logLine: string;
switch (status) {
case 'connecting':
logLine = `Connecting to ${label}...`;
break;
case 'authenticating':
logLine = `${label} - Key exchange complete`;
break;
case 'auth-attempt':
if (detail?.endsWith('rejected')) {
logLine = `${label} - ✗ ${detail}`;
} else if (detail === 'all methods exhausted') {
logLine = `${label} - ✗ All authentication methods exhausted`;
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
logLine = `${label} - ${detail}`;
} else {
logLine = `${label} - Trying ${detail}...`;
}
break;
case 'connected':
logLine = `${label} - Connected`;
break;
case 'error':
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
break;
default:
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
});
}
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
@@ -278,8 +323,24 @@ export const useSftpConnections = ({
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
@@ -289,63 +350,33 @@ export const useSftpConnections = ({
} 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
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
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 === "/") {
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Fallback path not available
// Ignore missing/permission errors
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Fallback path not available
}
}
}
@@ -421,6 +452,7 @@ export const useSftpConnections = ({
files,
loading: false,
reconnecting: false,
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
@@ -438,6 +470,8 @@ export const useSftpConnections = ({
loading: false,
reconnecting: false,
}));
} finally {
unsubSftpProgress?.();
}
}
},

View File

@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});

View File

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

View File

@@ -48,7 +48,7 @@ export const joinPath = (base: string, name: string): string => {
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
return `${base.replace(/\/+$/, "")}/${name}`;
};
export const getParentPath = (path: string): string => {

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
@@ -32,6 +33,12 @@ function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const removedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (removedSessionIds.length === 0) return;
cleanupAcpSessions(removedSessionIds);
const removedSessionIdSet = new Set(removedSessionIds);
const nextSessions = currentSessions.filter((session) => {
if (!session.scope.targetId) return true;
return activeTargetIds.has(session.scope.targetId);
});
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (sessionId && removedSessionIdSet.has(sessionId)) {
nextActiveSessionIdMap[scopeKey] = null;
activeSessionMapChanged = true;
}
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
});
}
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -117,7 +177,9 @@ export function useAIState() {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -129,8 +191,43 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
changed = true;
}
}
if (!changed) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
setActiveSessionIdMapRaw(prev => {
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -303,9 +400,22 @@ export function useAIState() {
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
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_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -315,7 +425,33 @@ export function useAIState() {
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (!key) return;
switch (key) {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return;
default:
handleStorage({ key } as StorageEvent);
}
};
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
@@ -375,6 +511,7 @@ export function useAIState() {
};
setSessionsRaw(prev => {
const next = [session, ...prev];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -391,12 +528,19 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] === sessionId) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}
@@ -415,12 +559,19 @@ export function useAIState() {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] != null) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}, [persistSessions]);
@@ -428,6 +579,7 @@ export function useAIState() {
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -440,6 +592,7 @@ export function useAIState() {
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -463,6 +616,7 @@ export function useAIState() {
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -476,6 +630,7 @@ export function useAIState() {
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -491,6 +646,7 @@ export function useAIState() {
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -503,29 +659,21 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
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]);
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
}, []);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {

View File

@@ -16,11 +16,11 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { collectSyncableSettings } from '../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';
import { notify } from '../notification';
interface AutoSyncConfig {
// Data to sync
@@ -189,7 +189,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
toast.error(
notify.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
@@ -231,7 +231,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);

View File

@@ -24,7 +24,6 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
} from '../../infrastructure/services/CloudSyncManager';
@@ -103,12 +102,6 @@ export interface CloudSyncHook {
refresh: () => void;
}
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -472,60 +465,4 @@ export const useCloudSync = (): CloudSyncHook => {
};
};
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};
export default useCloudSync;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
export interface HotkeyActions {
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;

View File

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

View File

@@ -3,8 +3,8 @@
* This should be used at the App level to ensure auto-start happens
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useEffect, useRef } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { useCallback, useEffect, useRef } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string; passphrase: string }[];
keys: SSHKey[];
identities: Identity[];
}
/**
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
if (host.identityId) {
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
if (!identity) return false;
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
return false;
}
}
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
return false;
}
const chainIds = host.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isHostAuthReady(chainHost, seen)) return false;
}
return true;
}, []);
// Keep refs in sync
useEffect(() => {
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
keysRef.current = keys;
}, [keys]);
useEffect(() => {
identitiesRef.current = identities;
}, [identities]);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Host not found" };
}
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, keys]);
}, [hosts, identities, isHostAuthReady, keys]);
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,
) => void,
enableReconnect = false,
) => {
return startPortForward(rule, host, keys, (status, error) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);

View File

@@ -30,6 +30,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -38,7 +39,6 @@ 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';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -121,8 +121,11 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -155,7 +158,6 @@ const applyThemeTokens = (
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
@@ -287,6 +289,10 @@ export const useSettingsState = () => {
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -322,6 +328,21 @@ export const useSettingsState = () => {
}
}, []);
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (stored === null || stored === '') {
// Persist default so collectSyncableSettings() can include it
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
return true;
}
return stored === 'true';
});
const setImmersiveMode = useCallback((enabled: boolean) => {
setImmersiveModeState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -334,6 +355,17 @@ export const useSettingsState = () => {
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
// Fix 2: Skip expensive DOM operations if nothing actually changed
if (
nextTheme === theme &&
nextLightId === lightUiThemeId &&
nextDarkId === darkUiThemeId &&
nextAccentMode === accentMode &&
nextAccent === customAccent
) {
return;
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
@@ -402,9 +434,17 @@ export const useSettingsState = () => {
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
if (storedImmersive === 'true' || storedImmersive === 'false') {
const val = storedImmersive === 'true';
setImmersiveModeState(val);
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -414,12 +454,11 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Notify other windows
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
// which re-reads ALL appearance values from localStorage.
notifySettingsChanged(STORAGE_KEY_THEME, theme);
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
@@ -437,7 +476,10 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
netcattyBridge.get()?.setLanguage?.(uiLanguage);
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
@@ -446,7 +488,10 @@ export const useSettingsState = () => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
@@ -540,6 +585,9 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -567,53 +615,76 @@ export const useSettingsState = () => {
};
}, []);
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== theme) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== customCSS) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== uiLanguage) {
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
@@ -636,64 +707,64 @@ export const useSettingsState = () => {
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
if (e.newValue !== s.terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== terminalFontFamilyId) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== terminalFontSize) {
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
if (newValue !== s.sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
if (newValue !== s.sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
if (newValue !== s.editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
if (newValue !== s.sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
if (e.newValue !== s.sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== sessionLogsFormat
e.newValue !== s.sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
@@ -701,54 +772,65 @@ export const useSettingsState = () => {
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
if (newValue !== sftpUseCompressedUpload) {
if (newValue !== s.sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoOpenSidebar) {
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(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) {
if (newValue !== s.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) {
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
// Sync immersive mode from other windows
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.immersiveMode) {
setImmersiveModeState(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, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
if (!persistMountedRef.current) return;
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
@@ -763,11 +845,13 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
}, [customKeyBindings, notifySettingsChanged]);
@@ -778,10 +862,7 @@ export const useSettingsState = () => {
// Apply and persist custom CSS
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
@@ -789,59 +870,69 @@ export const useSettingsState = () => {
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Register/unregister the global hotkey in main process
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
@@ -865,25 +956,32 @@ export const useSettingsState = () => {
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
// Update main process tray behavior
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
@@ -904,16 +1002,11 @@ export const useSettingsState = () => {
}, []);
// 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);
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
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) => {
@@ -921,6 +1014,13 @@ export const useSettingsState = () => {
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
// during the initial mount cycle (effects fire in declaration order).
useEffect(() => {
persistMountedRef.current = true;
}, []);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -983,11 +1083,6 @@ export const useSettingsState = () => {
[terminalThemeId, customThemes]
);
const currentTerminalFont = useMemo(
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
@@ -995,6 +1090,12 @@ export const useSettingsState = () => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
const reapplyCurrentTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
return {
theme,
setTheme,
@@ -1018,7 +1119,6 @@ export const useSettingsState = () => {
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
@@ -1052,7 +1152,6 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
@@ -1071,6 +1170,9 @@ export const useSettingsState = () => {
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1079,7 +1181,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
customThemes, immersiveMode,
]),
};
};

View File

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

View File

@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } 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, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// 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';
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
@@ -44,6 +43,8 @@ export interface UseUpdateCheckResult {
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
startDownload: () => void;
isUpdateDemoMode: boolean;
}
/**
@@ -514,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
netcattyBridge.get()?.installUpdate?.();
}, []);
const startDownload = useCallback(async () => {
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
const bridge = netcattyBridge.get();
try {
const checkResult = await bridge?.checkForUpdate?.();
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
if (checkResult.supported === false) {
openReleasePage();
return;
}
if (checkResult.available === false) {
openReleasePage();
return;
}
} catch {
return;
}
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: 0,
downloadError: null,
}));
void bridge?.downloadUpdate?.().then((res) => {
if (res && !res.success) {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error || 'Download failed',
}));
}
}).catch(() => {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: 'Download failed',
}));
});
}, [openReleasePage]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -653,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
dismissUpdate,
openReleasePage,
installUpdate,
startDownload,
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
};
}

View File

@@ -14,8 +14,8 @@ import type {
PortForwardingRule,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_THEME,
@@ -38,6 +38,7 @@ import {
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -74,9 +75,12 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
/**
@@ -157,6 +161,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -215,6 +223,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
}
// ---------------------------------------------------------------------------

View File

@@ -420,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
return activeSessionId;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
@@ -543,6 +545,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}));
// Clear pending approvals for this session (so tool execute functions don't hang)
clearAllPendingApprovals(activeSessionId);
// Cancel in-flight command executions (Catty Agent + ACP Agent)
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiAcpCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(

View File

@@ -611,7 +611,7 @@ interface SyncDashboardProps {
onClearLocalData?: () => void;
}
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onClearLocalData,

View File

@@ -20,6 +20,9 @@ import {
Tag,
TerminalSquare,
User,
FileKey,
FolderOpen,
Trash2,
Variable,
Wifi,
X,
@@ -27,7 +30,6 @@ 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 { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
@@ -69,7 +71,7 @@ import {
ProxyPanel,
} from "./host-details";
type CredentialType = "sshid" | "key" | "certificate" | null;
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
| "none"
| "create-group"
@@ -90,6 +92,8 @@ interface HostDetailsPanelProps {
allTags?: string[]; // All available tags for autocomplete
allHosts?: Host[]; // All hosts for chain selection
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
terminalThemeId: string;
terminalFontSize: number;
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
@@ -105,6 +109,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
allTags = [],
allHosts = [],
defaultGroup,
terminalThemeId,
terminalFontSize,
onSave,
onCancel,
onCreateGroup,
@@ -112,7 +118,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const { terminalThemeId, terminalFontSize } = useSettingsState();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -147,6 +152,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// Local key file path input state
const [newKeyFilePath, setNewKeyFilePath] = useState("");
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -469,6 +477,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
authMethod: identity.authMethod,
password: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
}));
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
@@ -969,6 +978,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
)}
{/* Local key file paths display */}
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
}}
>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
@@ -1046,6 +1080,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.credential.certificate")}
</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("localKeyFile");
setCredentialPopoverOpen(false);
}}
>
<FileKey size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.localKeyFile")}
</span>
</button>
</div>
</PopoverContent>
</Popover>
@@ -1067,6 +1115,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
@@ -1102,6 +1151,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
@@ -1121,6 +1171,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Button>
</div>
)}
{/* Local key file path input - appears after selecting "Local Key File" type */}
{!selectedIdentity &&
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
setNewKeyFilePath("");
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setSelectedCredentialType(null);
setNewKeyFilePath("");
}}
>
<X size={14} />
</Button>
</div>
</div>
)}
</div>
</Card>

View File

@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
hosts,
keys,
identities,
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, keys, setRuleStatus, startTunnel, t],
[hosts, identities, keys, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel

View File

@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
case "auth":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
}
};

View File

@@ -2,7 +2,7 @@ import {
Folder,
LayoutGrid,
Search,
Shield,
FolderLock,
Terminal,
TerminalSquare,
} from "lucide-react";
@@ -287,7 +287,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
<FolderLock size={16} />
) : (
<Folder size={16} />
);

View File

@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
@@ -94,10 +96,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -150,7 +148,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
updateState.autoDownloadStatus === 'ready'
@@ -177,7 +175,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
variant="secondary"
className="gap-2"
onClick={() => void handleCheckForUpdates()}
disabled={updateState.isChecking}
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
>
{updateState.isChecking ? (
<Loader2 size={16} className="animate-spin" />

View File

@@ -5,6 +5,7 @@
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 { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
@@ -19,7 +20,6 @@ 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);
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
/>
);
};
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<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>
);
};
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
@@ -98,10 +149,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
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 { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -206,6 +260,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
startDownload={startDownload}
isUpdateDemoMode={isUpdateDemoMode}
/>
)}
@@ -227,21 +283,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
/>
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
<SettingsTerminalTabContainer settings={settings} />
)}
{mountedTabs.has("shortcuts") && (
@@ -261,34 +309,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{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>
<SettingsAITabContainer />
)}
{mountedTabs.has("sync") && (
@@ -318,6 +339,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
startDownload={startDownload}
/>
)}
</div>

View File

@@ -11,6 +11,7 @@
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
/>
<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}`}
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}

View File

@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -49,21 +50,35 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
updateHosts,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const rootRef = useRef<HTMLDivElement>(null);
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -246,6 +261,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
rightCallbacks={rightCallbacks}
>
<div
ref={rootRef}
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
isActive ? "z-20" : "",
@@ -408,7 +424,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
};
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

@@ -4,11 +4,11 @@ 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, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
import {
Host,
Identity,
@@ -26,8 +26,6 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { resolveHostAuth } from "../domain/sshAuth";
@@ -54,6 +52,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
/**
* Extract unique root paths from drop entries for local terminal path insertion.
@@ -110,7 +109,8 @@ interface TerminalProps {
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
allHosts?: Host[];
chainHosts?: Host[];
themePreviewId?: string;
knownHosts?: KnownHost[];
isVisible: boolean;
inWorkspace?: boolean;
@@ -157,6 +157,10 @@ interface TerminalProps {
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
onSnippetExecutorChange?: (
sessionId: string,
executor: ((command: string, noAutoRun?: boolean) => void) | null,
) => void;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
@@ -179,7 +183,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keys,
identities,
snippets,
allHosts = [],
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
isVisible,
inWorkspace,
@@ -216,6 +221,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
onSnippetExecutorChange,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
@@ -228,6 +234,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const knownCwdRef = useRef<string | undefined>(undefined);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
@@ -291,6 +298,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -342,16 +354,145 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
autocompleteAcceptTextRef.current = (text: string) => {
const id = sessionRef.current;
if (id && text) {
// Serial line mode: buffer text and handle local echo instead of direct send
if (host.protocol === "serial" && serialConfig?.lineMode) {
for (const ch of text) {
if (ch === "\r") {
const line = serialLineBufferRef.current + "\r";
terminalBackend.writeToSession(id, line);
serialLineBufferRef.current = "";
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
} else if (ch === "\x15") {
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
}
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
if (serialConfig?.localEcho) termRef.current?.write(ch);
}
}
// Still update commandBuffer and broadcast for serial line mode
// (fall through to shared bookkeeping below — don't return early)
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
// Serial character mode with local echo: echo accepted text locally
terminalBackend.writeToSession(id, text);
for (const ch of text) {
if (ch === "\r") {
termRef.current?.write("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
termRef.current?.write(ch);
}
}
} else {
terminalBackend.writeToSession(id, text);
}
// Broadcast to other sessions if broadcast mode is enabled
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(text, sessionId);
}
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (ch === "\r" || ch === "\n") {
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
// Backspace: remove last character (Windows fuzzy replacement uses \b)
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
} else if (ch.charCodeAt(0) >= 32) {
commandBufferRef.current += ch;
}
}
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
});
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
useEffect(() => {
knownCwdRef.current = undefined;
}, [sessionId, host.id]);
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
return;
}
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (!cancelled && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
} catch {
// Best effort only.
}
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend]);
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
}
}, [isVisible, autocompleteClosePopup]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) for Linux servers
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isConnected: status === 'connected',
});
@@ -406,21 +547,35 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
const effectiveFontSize = useMemo(
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
[fontSize, hasFontSizeOverride, host.fontSize],
);
const resolvedFontFamily = useMemo(() => {
const hostFontId = hasFontFamilyOverride && host.fontFamily
? host.fontFamily
: fontFamilyId;
const resolvedFontId = hostFontId || "menlo";
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const effectiveTheme = useMemo(() => {
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host, terminalTheme, customThemes]);
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
(host.hostChain?.hostIds
?.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[]) || [];
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
setStatus(next);
@@ -539,7 +694,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onCwdChange: (cwd: string) => {
knownCwdRef.current = cwd;
},
onOsc52ReadRequest: handleOsc52ReadRequest,
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
});
xtermRuntimeRef.current = runtime;
@@ -635,28 +796,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
@@ -679,7 +818,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -714,6 +852,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
autocompleteRepositionRef.current?.();
return;
}
}
@@ -722,6 +861,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
autocompleteRepositionRef.current?.();
});
} else {
autocompleteRepositionRef.current?.();
}
} catch (err) {
logger.warn("Fit failed", err);
}
@@ -737,15 +883,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
useEffect(() => {
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
useLayoutEffect(() => {
if (termRef.current) {
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
}
}, [effectiveTheme]);
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.fontFamily = resolvedFontFamily;
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
@@ -787,6 +938,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
@@ -794,27 +949,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit({ force: true }), 50);
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
}
}, [fontSize, effectiveTheme, terminalSettings, host]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -862,7 +1003,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
@@ -898,7 +1038,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
}, [effectiveFontSize, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1061,11 +1201,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const scrollToBottomAfterProgrammaticInput = (data: string) => {
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
};
}, []);
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
let data = normalizeLineEndings(command);
const isMultiLine = data.includes('\n');
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
// correct path for sessions that are still connecting.
useEffect(() => {
if (status !== "connected") {
onSnippetExecutorChange?.(sessionId, null);
return;
}
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
return () => onSnippetExecutorChange?.(sessionId, null);
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
const terminalContextActions = useTerminalContextActions({
termRef,
@@ -1304,6 +1476,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const terminalPreviewVars = useMemo(() => ({
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu
@@ -1326,6 +1506,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
style={terminalPreviewVars}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1356,14 +1537,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
borderColor: 'var(--terminal-ui-border)',
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
}}
>
<div className="flex items-center gap-1 text-[11px] font-semibold">
@@ -1375,8 +1556,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
@@ -1423,6 +1604,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
@@ -1720,7 +1919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
>
<div
ref={containerRef}
@@ -1728,10 +1927,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
backgroundColor: 'var(--terminal-ui-bg)',
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
/>,
document.body,
)
}
{needsHostKeyVerification && pendingHostKeyInfo && (
<div className="absolute inset-0 z-30 bg-background">
<KnownHostConfirmDialog

File diff suppressed because it is too large Load Diff

View File

@@ -151,6 +151,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
}, [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
});
}, []);

View File

@@ -1,6 +1,8 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, 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 { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -36,6 +38,7 @@ interface TopTabsProps {
onToggleTheme: () => void;
onOpenSettings: () => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
@@ -54,7 +57,7 @@ const localOsId = (() => {
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");
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
@@ -81,7 +84,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<TerminalSquare className={iconSize} />
</div>
);
@@ -108,22 +111,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
const tone = status === 'connected'
? "bg-emerald-400"
: status === 'connecting'
? "bg-amber-400"
: "bg-rose-500";
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
return (
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
<span
className={cn(
"relative inline-block h-2 w-2 rounded-full ring-2",
tone,
hasActivity && "session-activity-dot",
)}
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
/>
</span>
);
};
// Custom window controls for Windows/Linux (frameless window)
@@ -167,14 +181,16 @@ const WindowControls: React.FC = memo(() => {
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
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"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
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"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -217,6 +233,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onToggleTheme,
onOpenSettings,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
@@ -225,6 +242,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Subscribe to activeTabId from external store
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const activeTabId = useActiveTabId();
const sessionActivityMap = useSessionActivityMap();
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const onSelectTab = activeTabStore.setActiveTabId;
@@ -328,6 +346,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [hosts]);
const workspaceActivityMap = useMemo(() => {
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
}, [sessionActivityMap, sessions]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -451,6 +473,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'session') {
const session = item.session;
const hasActivity = !!sessionActivityMap[session.id];
const isBeingDragged = draggingSessionId === session.id;
const shiftStyle = tabShiftStyles[session.id] || {};
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
@@ -470,30 +493,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"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",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: activeTabId === session.id
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: activeTabId === session.id
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<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" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<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="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<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 className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
onClick={(e) => onCloseSession(session.id, e)}
@@ -522,6 +571,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'workspace') {
const workspace = item.workspace;
const paneCount = item.paneCount;
const hasActivity = !!workspaceActivityMap.get(workspace.id);
const isActive = activeTabId === workspace.id;
const isBeingDragged = draggingSessionId === workspace.id;
const shiftStyle = tabShiftStyles[workspace.id] || {};
@@ -542,32 +592,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"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",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<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" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<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="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">{workspace.title}</span>
</div>
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
{paneCount}
<div className="flex items-center gap-1.5 shrink-0">
{hasActivity && sessionStatusDot('connected', true)}
<div
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
style={{
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
}}
>
{paneCount}
</div>
</div>
</div>
</ContextMenuTrigger>
@@ -595,18 +684,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab(logView.id)}
className={cn(
"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={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
<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")} />
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
@@ -640,8 +752,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
style={{
...dragRegionNoSelect,
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
}}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
@@ -656,25 +773,62 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab('vault')}
className={cn(
"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={{
backgroundColor: isVaultActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isVaultActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<Shield size={14} /> Vaults
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"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={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
</div>
@@ -696,7 +850,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
@@ -713,6 +867,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -727,7 +882,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
</div>
@@ -738,6 +893,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -750,21 +906,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
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">
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
@@ -788,10 +947,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&
prev.orderedTabs === next.orderedTabs &&
prev.logViews === next.logViews &&
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow
prev.onSyncNow === next.onSyncNow &&
prev.isImmersiveActive === next.isImmersiveActive
);
};

View File

@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys } = useVaultState();
const { hosts, keys, identities } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
if (isActive) {
void stopTunnel(rule.id);
} else {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
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();

View File

@@ -8,8 +8,6 @@
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
@@ -17,13 +15,6 @@ import type {
} 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,
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
);
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;

View File

@@ -75,17 +75,18 @@ export const ToolCall = ({
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIconClass = 'shrink-0';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
) : isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
return (
@@ -105,7 +106,13 @@ export const ToolCall = ({
? <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>
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (

View File

@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
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';
@@ -187,18 +180,27 @@ export const AgentIconBadge: React.FC<{
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
<div
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
className={cn('shrink-0', imageSize, className)}
style={{
maskImage: `url(${visual.src})`,
WebkitMaskImage: `url(${visual.src})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
backgroundColor: 'currentColor',
}}
/>
);
}
return (
<div
data-agent-badge=""
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,

View File

@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<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"
className="w-[288px] overflow-hidden 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

View File

@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build a map from toolCallId → toolName for display
// Build maps from toolCallId → toolName / toolArgs for display
const toolCallNames = new Map<string, string>();
const toolCallArgs = new Map<string, Record<string, unknown>>();
for (const m of visibleMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
}
}
}
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>

View File

@@ -1,169 +0,0 @@
/**
* 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 };

View File

@@ -1,200 +0,0 @@
/**
* 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 };

View File

@@ -18,6 +18,7 @@ import type {
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderAdvancedParams,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
@@ -186,6 +187,7 @@ export interface UseAIChatStreamingReturn {
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
@@ -320,6 +322,7 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
): Promise<void> => {
const result = streamText({
model,
@@ -328,6 +331,11 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
});
// Track the current assistant message ID so updates target the correct message
@@ -804,7 +812,7 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
} = props;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.immersiveMode")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.immersiveMode")}
description={t("settings.appearance.immersiveMode.desc")}
>
<Toggle
checked={!!isImmersive}
onChange={() => onToggleImmersive?.()}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";

View File

@@ -1,7 +1,7 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface CrashLogFile {
fileName: string;
date: string;
size: number;
entryCount: number;
}
interface CrashLogEntry {
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}
interface TempDirInfo {
path: string;
fileCount: number;
@@ -64,6 +89,7 @@ interface SettingsSystemTabProps {
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
startDownload: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -86,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
checkNow,
installUpdate,
openReleasePage,
startDownload,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -98,6 +125,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -144,6 +177,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const loadCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getCrashLogs) return;
setIsLoadingCrashLogs(true);
try {
const logs = await bridge.getCrashLogs();
setCrashLogs(logs);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
} finally {
setIsLoadingCrashLogs(false);
}
}, []);
useEffect(() => {
void loadCrashLogs();
}, [loadCrashLogs]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
setExpandedLog(null);
setLogEntries([]);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.readCrashLog) return;
const requestId = ++expandRequestRef.current;
// Optimistically show expanded state while loading
setExpandedLog(fileName);
setLogEntries([]);
try {
const entries = await bridge.readCrashLog(fileName);
// Discard if user clicked a different file while awaiting
if (expandRequestRef.current !== requestId) return;
setLogEntries(entries);
} catch (err) {
if (expandRequestRef.current !== requestId) return;
console.error("[SettingsSystemTab] Failed to read crash log:", err);
}
}, [expandedLog]);
const handleClearCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearCrashLogs) return;
setIsClearingCrashLogs(true);
setCrashLogClearResult(null);
try {
const result = await bridge.clearCrashLogs();
setCrashLogClearResult(result);
setExpandedLog(null);
setLogEntries([]);
// Reload the list so partial failures still show remaining files
await loadCrashLogs();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
} finally {
setIsClearingCrashLogs(false);
}
}, [loadCrashLogs]);
const handleOpenCrashLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openCrashLogsDir) return;
await bridge.openCrashLogsDir();
}, []);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -365,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{/* Download button — shown when update found and no download in progress */}
{updateState.autoDownloadStatus === 'idle' &&
updateState.manualCheckStatus === 'available' && (
<Button variant="outline" size="sm" onClick={startDownload}>
<Download size={14} className="mr-1.5" />
{t('update.downloadNow')}
</Button>
)}
{/* Open releases — fallback for unsupported platforms or check errors */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
@@ -449,6 +558,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -114,6 +114,20 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
updateTerminalSetting("autocompletePopupMenu", false);
}
}, [updateTerminalSetting]);
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompletePopupMenu", enabled);
if (enabled) {
updateTerminalSetting("autocompleteGhostText", false);
}
}, [updateTerminalSetting]);
// Import .itermcolors file
const importFileRef = useRef<HTMLInputElement>(null);
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -616,6 +630,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}
@@ -844,6 +865,39 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.autocomplete.enabled")}
description={t("settings.terminal.autocomplete.enabled.desc")}
>
<Toggle
checked={terminalSettings.autocompleteEnabled}
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.ghostText")}
description={t("settings.terminal.autocomplete.ghostText.desc")}
>
<Toggle
checked={terminalSettings.autocompleteGhostText}
onChange={handleAutocompleteGhostTextChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.popupMenu")}
description={t("settings.terminal.autocomplete.popupMenu.desc")}
>
<Toggle
checked={terminalSettings.autocompletePopupMenu}
onChange={handleAutocompletePopupMenuChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { Check, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const preset = PROVIDER_PRESETS[provider.providerId];
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
}
}, [provider.apiKey]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw.trim() === "" || raw.trim() === "-") {
delete next[key];
} else {
const num = Number(raw);
if (!Number.isNaN(num)) {
next[key] = num;
}
}
return { ...prev, advancedParams: next };
});
}, []);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Advanced Parameters */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('ai.providers.advancedParams')}
</button>
{showAdvanced && (
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
{/* max_tokens */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">max_tokens</label>
<input
type="number"
min={1}
step={1}
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
placeholder={t('ai.providers.advancedParams.maxTokens.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>
{/* temperature */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(02)</span></label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
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>
{/* top_p */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(01)</span></label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
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>
{/* frequency_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
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>
{/* presence_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
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>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>

View File

@@ -4,6 +4,7 @@
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
@@ -42,6 +43,7 @@ export interface ProviderFormState {
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
}
export interface FetchedModel {

View File

@@ -88,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -9,6 +9,7 @@ import {
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import { joinPath } from "../../application/state/sftp/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
@@ -58,6 +59,46 @@ interface SftpPaneFileListProps {
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
const SftpErrorWithLogs: React.FC<{
error: string;
connectionLogs: string[];
onRetry: () => void;
t: (key: string) => string;
}> = ({ error, connectionLogs, onRetry, t }) => {
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<Unplug size={28} className="text-destructive/70" />
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
{t("sftp.retry")}
</Button>
{connectionLogs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => setShowLogs(!showLogs)}
>
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
{showLogs ? "Hide logs" : "Show logs"}
</Button>
)}
</div>
{showLogs && connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
{connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
{log}
</div>
))}
</div>
)}
</div>
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
t,
pane,
@@ -178,6 +219,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
}}
>
<ClipboardCopy size={14} className="mr-2" />{" "}
{t("sftp.context.copyPath")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
@@ -340,17 +389,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center justify-center h-full gap-2">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
<SftpErrorWithLogs
error={pane.error}
connectionLogs={pane.connectionLogs}
onRetry={onRefresh}
t={t}
/>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
@@ -410,10 +467,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
</span>
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
)}

View File

@@ -1,5 +1,5 @@
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 { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, 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";
@@ -46,6 +46,8 @@ interface SftpPaneToolbarProps {
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
onToggleBookmark: () => void;
onAddGlobalBookmark: (path: string) => void;
isCurrentPathGlobalBookmarked: boolean;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
@@ -92,6 +94,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
onAddGlobalBookmark,
isCurrentPathGlobalBookmarked,
onNavigateToBookmark,
onDeleteBookmark,
showHiddenFiles,
@@ -440,16 +444,31 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
<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">
<div className="p-2 border-b border-border/40 flex gap-1">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
className="flex-1 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>
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 shrink-0"
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
>
{t("sftp.bookmark.addGlobal")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
</Tooltip>
)}
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
@@ -458,6 +477,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
{bm.global && (
<Globe size={10} className="shrink-0 text-primary" />
)}
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"

View File

@@ -25,6 +25,7 @@ import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -109,12 +110,36 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const localBookmarks = useLocalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const globalBookmarks = useGlobalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const mergedBookmarks = useMemo(
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
);
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
const toggleBookmark = useCallback(() => {
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
const currentPath = pane.connection?.currentPath;
if (currentPath) {
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
if (bm) globalBookmarks.deleteBookmark(bm.id);
}
} else {
hostBookmarks.toggleBookmark();
}
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
const deleteBookmark = useCallback(
(id: string) => {
if (id.startsWith("gbm-")) {
globalBookmarks.deleteBookmark(id);
} else {
hostBookmarks.deleteBookmark(id);
}
},
[hostBookmarks, globalBookmarks],
);
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
@@ -329,9 +354,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
bookmarks={bookmarks}
bookmarks={mergedBookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}
onAddGlobalBookmark={globalBookmarks.addBookmark}
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
showHiddenFiles={pane.showHiddenFiles}

View File

@@ -0,0 +1,67 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
}
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useGlobalSftpBookmarks = ({
currentPath,
}: UseGlobalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const addBookmark = useCallback((path: string) => {
if (!path) return;
if (bookmarks.some((b) => b.path === path)) return;
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
const label = isRoot
? path
: path.split(/[\\/]/).filter(Boolean).pop() || path;
const newBookmark: SftpBookmark = {
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label,
global: true,
};
setBookmarks((prev) => [...prev, newBookmark]);
}, [bookmarks]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
addBookmark,
deleteBookmark,
};
};

View File

@@ -2,7 +2,7 @@
* Terminal Authentication Dialog
* Displays auth form with password/key selection for SSH connection
*/
import { AlertCircle, BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock } from 'lucide-react';
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -80,38 +80,42 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
return (
<>
{/* Auth method tabs */}
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'password'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('password')}
>
<Lock size={14} />
<Lock size={13} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'key' || authMethod === 'certificate'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('key')}
>
<Key size={14} />
<Key size={13} />
{t("terminal.auth.sshKey")}
</button>
</div>
{/* Auth retry error message */}
{authRetryMessage && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm flex items-center gap-2">
<AlertCircle size={16} />
{authRetryMessage}
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
<Unplug size={11} />
</div>
<div className="min-w-0 leading-4 text-destructive/95">
{authRetryMessage}
</div>
</div>
)}

View File

@@ -66,13 +66,15 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
return (
<div
className="flex-shrink-0"
style={{
background: `linear-gradient(to top, ${bg}, color-mix(in srgb, ${fg} 4%, ${bg} 96%))`,
borderTop: `1px solid color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
background: `linear-gradient(to top, ${resolvedBg}, color-mix(in srgb, ${resolvedFg} 4%, ${resolvedBg} 96%))`,
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
borderRadius: '0 0 8px 8px',
padding: '6px 10px',
}}
@@ -97,24 +99,24 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
"placeholder:opacity-40",
)}
style={{
backgroundColor: `color-mix(in srgb, ${fg} 6%, ${bg} 94%)`,
color: fg,
border: `1px solid color-mix(in srgb, ${fg} 25%, ${bg} 75%)`,
backgroundColor: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
color: resolvedFg,
border: `1px solid color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`,
minHeight: '28px',
maxHeight: '120px',
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`,
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`,
}}
rows={1}
placeholder={t("terminal.composeBar.placeholder")}
onInput={handleInput}
onKeyDown={handleKeyDown}
onFocus={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 40%, ${bg} 60%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${fg} 8%, transparent)`;
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 40%, ${resolvedBg} 60%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${resolvedFg} 8%, transparent)`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 25%, ${bg} 75%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`;
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`;
}}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
@@ -125,14 +127,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: fg,
background: `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`,
color: resolvedFg,
background: `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 30%, ${bg} 70%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 30%, ${resolvedBg} 70%)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`;
}}
onClick={handleSend}
title={t("terminal.composeBar.send")}
@@ -142,16 +144,16 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`,
background: `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`,
color: `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`,
background: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 22%, ${bg} 78%)`;
e.currentTarget.style.color = fg;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 22%, ${resolvedBg} 78%)`;
e.currentTarget.style.color = resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`;
e.currentTarget.style.color = `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`;
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`;
}}
onClick={onClose}
title={t("terminal.composeBar.close")}

View File

@@ -7,6 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
@@ -83,14 +84,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"absolute inset-0 z-20 flex items-center justify-center",
needsAuth ? "bg-black" : "bg-black/30"
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div
className="w-[480px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
color: 'var(--terminal-ui-fg, var(--foreground))',
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
<div>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8 rounded-md shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold">
<div className="text-xs font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -100,26 +108,32 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-lg font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-base font-semibold truncate">{host.label}</div>
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
className="h-7 px-3 text-[11px]"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
@@ -129,7 +143,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
className="h-7 px-3 text-[11px]"
onClick={progressProps.onCancelConnect}
disabled={progressProps.isCancelling}
>
@@ -140,7 +154,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
className="h-7 w-7"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
title={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
@@ -151,10 +165,10 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</div>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<div className="flex items-center gap-3">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
needsAuth
? "bg-primary text-primary-foreground"
: hasError
@@ -163,7 +177,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
<Plug size={14} />
<Plug size={13} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
@@ -177,13 +191,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
/>
</div>
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
"h-7 w-7 rounded-md 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" />
<Loader2 size={13} className="animate-spin" />
) : (
<TerminalSquare size={14} />
<TerminalSquare size={13} />
)}
</div>
</div>

View File

@@ -35,7 +35,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
return (
<>
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
<div className="flex items-start justify-between gap-3 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-start gap-2">
{status === 'connecting' ? (
<>
@@ -57,8 +57,8 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
{showLogs && (
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-52 p-3">
<div className="space-y-1 text-sm text-foreground/90">
<ScrollArea className="max-h-44 p-2.5">
<div className="space-y-1 text-xs text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
@@ -79,11 +79,11 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
<div className="flex justify-end gap-2">
{status !== 'connecting' && (
<>
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {t('terminal.progress.startOver')}
</Button>
</>
)}

View File

@@ -73,12 +73,19 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
return (
<div
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="relative flex-1">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-white/40" />
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
/>
<input
ref={inputRef}
type="text"
@@ -88,13 +95,20 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
placeholder={t("terminal.search.placeholder")}
className="w-full h-6 pl-7 pr-2 text-[11px] bg-white/5 border-none rounded text-white placeholder:text-white/30 focus:outline-none focus:bg-white/10"
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
/>
</div>
{/* Match count indicator - only show when no results */}
{searchTerm.length > 0 && matchCount?.total === 0 && (
<span className="text-[10px] text-white/50 flex-shrink-0">
<span
className="text-[10px] flex-shrink-0"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
>
{t("terminal.search.noResults")}
</span>
)}
@@ -105,7 +119,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -123,7 +140,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -39,16 +39,20 @@ const ThemeItem = memo(({
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'
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{/* 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 }}
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
>
<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 }} />
@@ -58,7 +62,7 @@ const ThemeItem = memo(({
<div className="text-xs font-medium truncate">
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
{theme.type}
{theme.isCustom && ' • custom'}
</div>
@@ -69,13 +73,14 @@ const ThemeItem = memo(({
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"
className="w-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
style={{ color: 'var(--terminal-panel-muted)' }}
>
<Pencil size={10} />
</div>
)}
{isSelected && !onEdit && (
<Check size={12} className="text-primary flex-shrink-0" />
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</div>
));
@@ -94,11 +99,15 @@ const FontItem = memo(({
<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'
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<div className="flex-1 min-w-0">
<div
@@ -107,10 +116,10 @@ const FontItem = memo(({
>
{font.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
</div>
{isSelected && (
<Check size={12} className="text-primary flex-shrink-0" />
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</button>
));
@@ -132,6 +141,10 @@ interface ThemeSidePanelProps {
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
isVisible?: boolean;
previewColors?: {
background: string;
foreground: string;
};
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
@@ -150,6 +163,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
onFontSizeChange,
onFontSizeReset,
isVisible = true,
previewColors,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
@@ -245,44 +259,57 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
const panelVars = {
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
['--terminal-panel-muted' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 58%, var(--terminal-panel-bg) 42%)',
['--terminal-panel-border' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
['--terminal-panel-hover' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
['--terminal-panel-active' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 16%, var(--terminal-panel-bg) 84%)',
} as React.CSSProperties;
return (
<>
<div className="h-full flex flex-col bg-background overflow-hidden">
<div
className="h-full flex flex-col overflow-hidden"
style={{
...panelVars,
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<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'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-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'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-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'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
@@ -304,7 +331,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
@@ -320,7 +347,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
{canResetTheme && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalTheme')}
</div>
<ThemeItem
@@ -344,7 +371,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
{canResetFontFamily && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalFont')}
</div>
<FontItem
@@ -360,26 +387,36 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
<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"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<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
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
color: 'var(--terminal-panel-fg)',
}}
>
<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 className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{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"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<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 className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
@@ -391,7 +428,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
/>
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
@@ -412,36 +449,47 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontSize')}
</div>
{canResetFontSize && (
<button
onClick={onFontSizeReset}
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<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"
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-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>
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>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"
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<Plus size={12} />
</button>
@@ -450,8 +498,8 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
{/* 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">
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
</div>
</div>

View File

@@ -0,0 +1,439 @@
/**
* Popup autocomplete menu for terminal.
* Renders a floating list of completion suggestions near the terminal cursor.
* Shows a detail tooltip for the selected/hovered item with full description.
* Colors are derived from the active terminal theme for visual consistency.
*/
import React, { useEffect, useRef, useState, memo } from "react";
import { Folder, File, Link } from "lucide-react";
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export interface AutocompleteThemeColors {
background: string;
foreground: string;
selection: string;
cursor: string;
}
export interface SubDirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
export interface SubDirPanel {
entries: SubDirEntry[];
selectedIndex: number;
dirPath: string;
}
interface AutocompletePopupProps {
suggestions: CompletionSuggestion[];
selectedIndex: number;
/** Position relative to the terminal container (not viewport) */
position: { x: number; y: number };
/** Current input line bounds relative to the terminal container */
cursorLineTop: number;
cursorLineBottom: number;
visible: boolean;
expandUpward?: boolean;
themeColors?: AutocompleteThemeColors;
onSelect: (suggestion: CompletionSuggestion) => void;
maxHeight?: number;
subDirPanels?: SubDirPanel[];
subDirFocusLevel?: number;
/** Reference to the terminal container for calculating fixed position */
containerRef?: React.RefObject<HTMLDivElement | null>;
/** Ask the autocomplete controller to recompute cursor-relative popup position */
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
};
/** Lucide icon components for file types in path suggestions */
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
directory: { Icon: Folder, color: "#38BDF8" },
file: { Icon: File, color: "#94A3B8" },
symlink: { Icon: Link, color: "#A78BFA" },
};
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
return (
<span
style={{
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<cfg.Icon size={14} color={cfg.color} />
</span>
);
};
/** Chevron indicator for expandable directory items */
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}></span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
position,
cursorLineTop,
cursorLineBottom,
visible,
expandUpward = false,
themeColors,
onSelect,
maxHeight = 240,
subDirPanels = [],
subDirFocusLevel = -1,
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
}) => {
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "instant" as ScrollBehavior,
});
}
}, [selectedIndex]);
// Reset hover when suggestions change
useEffect(() => {
setHoveredIndex(-1);
}, [suggestions]);
useEffect(() => {
if (!visible || !onRequestReposition) return;
let frameId = 0;
const requestReposition = () => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
frameId = 0;
onRequestReposition();
});
};
const container = containerRef?.current;
const observer = container ? new ResizeObserver(requestReposition) : null;
observer?.observe(container);
window.addEventListener("resize", requestReposition);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer?.disconnect();
window.removeEventListener("resize", requestReposition);
};
}, [containerRef, onRequestReposition, visible]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
const fg = themeColors?.foreground ?? "#cdd6f4";
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
const selectedBg = `color-mix(in srgb, ${bg} 78%, ${fg} 22%)`;
const hoverBg = `color-mix(in srgb, ${bg} 85%, ${fg} 15%)`;
const textColor = fg;
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
// Determine which item to show the detail tooltip for
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
const showDetail = detailItem?.description && detailItem.description.length > 0;
// Calculate fixed viewport position from container rect + relative cursor position.
// containerRef already has top offset for toolbar/search bar, so don't add it again.
const containerRect = containerRef?.current?.getBoundingClientRect();
const fixedLeft = (containerRect?.left ?? 0) + position.x;
const fixedLineTop = (containerRect?.top ?? 0) + cursorLineTop;
const fixedLineBottom = (containerRect?.top ?? 0) + cursorLineBottom;
const viewportPadding = 8;
const anchorGap = 8;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 800;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
const estimatedDetailHeight = showDetail && detailItem && detailItem.source !== "path" ? 96 : 0;
const desiredContentHeight = Math.min(
maxHeight,
Math.max(estimatedPopupHeight, estimatedDetailHeight),
);
const spaceAbove = Math.max(0, fixedLineTop - viewportPadding - anchorGap);
const spaceBelow = Math.max(0, viewportHeight - fixedLineBottom - viewportPadding - anchorGap);
const canFullyRenderAbove = spaceAbove >= desiredContentHeight;
const canFullyRenderBelow = spaceBelow >= desiredContentHeight;
const renderUpward = canFullyRenderBelow
? false
: canFullyRenderAbove
? true
: expandUpward
? spaceAbove >= Math.min(spaceBelow, 80)
: spaceAbove > spaceBelow;
const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow;
const effectiveMaxHeight = Math.max(0, Math.min(maxHeight, availableVerticalSpace));
const contentHeightForPlacement = Math.min(
effectiveMaxHeight,
desiredContentHeight,
);
const anchoredTop = renderUpward
? Math.max(viewportPadding, fixedLineTop - anchorGap - contentHeightForPlacement)
: Math.min(fixedLineBottom + anchorGap, viewportHeight - viewportPadding - contentHeightForPlacement);
const clampedLeft = Math.max(viewportPadding, Math.min(fixedLeft, viewportWidth - viewportPadding - 400));
const sharedBoxStyle = {
backgroundColor: popupBg,
border: `1px solid ${popupBorder}`,
borderRadius: "6px",
boxShadow: renderUpward
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
: "0 2px 6px rgba(0, 0, 0, 0.15)",
fontFamily: "inherit",
fontSize: "13px",
color: textColor,
};
return (
<div
style={{
position: "fixed",
left: `${clampedLeft}px`,
top: `${anchoredTop}px`,
zIndex: 10000,
display: "flex",
alignItems: renderUpward ? "flex-end" : "flex-start",
gap: "4px",
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{/* Main suggestion list */}
<div
ref={listRef}
className="xterm-autocomplete-popup"
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "180px",
maxWidth: "400px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
}}
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const sourceInfo = SOURCE_LABELS[suggestion.source];
return (
<div
key={`${suggestion.text}-${index}`}
ref={isSelected ? selectedRef : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "5px 10px",
cursor: "pointer",
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(suggestion);
}}
>
{/* Source / file type indicator */}
{suggestion.source === "path" && suggestion.fileType ? (
<FileTypeIcon fileType={suggestion.fileType} />
) : (
<span
style={{
width: "18px",
height: "18px",
borderRadius: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: sourceInfo.fallbackColor,
backgroundColor: `${sourceInfo.fallbackColor}15`,
flexShrink: 0,
}}
>
{sourceInfo.label}
</span>
)}
{/* Command text */}
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: textColor,
fontWeight: isSelected ? 500 : 400,
}}
>
{suggestion.displayText}
</span>
{/* Inline description (truncated) */}
{suggestion.description && (
<span
style={{
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
</span>
)}
{/* Frequency badge for history */}
{suggestion.frequency && suggestion.frequency > 1 && (
<span
style={{
fontSize: "10px",
color: dimTextColor,
flexShrink: 0,
}}
>
×{suggestion.frequency}
</span>
)}
{/* Expand indicator for directories */}
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
</div>
);
})}
</div>
{/* Cascading sub-directory panels */}
{subDirPanels.map((panel, level) => (
<div
key={panel.dirPath}
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "150px",
maxWidth: "240px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
alignSelf: "flex-start",
}}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
const isSubSelected = isFocused && idx === panel.selectedIndex;
return (
<div
key={entry.name}
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "4px 10px",
cursor: "pointer",
backgroundColor: isSubSelected ? selectedBg
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
: "transparent",
gap: "8px",
lineHeight: "1.4",
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<FileTypeIcon fileType={entry.type} />
<span style={{
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
whiteSpace: "nowrap", color: textColor,
}}>
{entry.name}{entry.type === "directory" ? "/" : ""}
</span>
{entry.type === "directory" && (
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
)}
</div>
);
})}
</div>
))}
{/* Detail tooltip panel — shows full description for non-path items */}
{showDetail && detailItem && detailItem.source !== "path" && (
<div
style={{
...sharedBoxStyle,
padding: "10px 12px",
maxWidth: "280px",
minWidth: "160px",
alignSelf: renderUpward ? "flex-end" : "flex-start",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.description}
</div>
</div>
)}
</div>
);
};
export default memo(AutocompletePopup);

View File

@@ -0,0 +1,180 @@
/**
* Ghost Text addon for xterm.js.
* Renders inline suggestion text after the cursor in a dimmed style,
* similar to fish shell's autosuggestions.
*
* Uses a CSS overlay positioned relative to the terminal cursor,
* avoiding modification of the terminal buffer.
*/
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
private lastTop = -1;
activate(term: XTerm): void {
this.term = term;
const termElement = term.element;
if (!termElement) return;
this.containerElement = document.createElement("div");
this.containerElement.className = "xterm-ghost-text-container";
Object.assign(this.containerElement.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
zIndex: "1",
});
this.ghostElement = document.createElement("span");
this.ghostElement.className = "xterm-ghost-text";
Object.assign(this.ghostElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.ghostElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
} else {
termElement.appendChild(this.containerElement);
}
// Update position on scroll and render to keep ghost text aligned
this.disposables.push(
term.onRender(() => {
if (this.isVisible()) this.updatePosition();
}),
);
// Invalidate cell dimension cache on resize so measurements stay accurate
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
}),
);
}
/**
* Show ghost text suggestion.
* @param fullSuggestion The complete suggested command
* @param currentInput The text the user has typed so far
*/
show(fullSuggestion: string, currentInput: string): void {
if (this.disposed || !this.ghostElement || !this.term) return;
const ghostText = fullSuggestion.startsWith(currentInput)
? fullSuggestion.substring(currentInput.length)
: "";
if (!ghostText) {
this.hide();
return;
}
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
this.updatePosition();
this.ghostElement.textContent = ghostText;
this.ghostElement.style.display = "block";
// Set font properties once per show (not per frame in updatePosition)
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
}
hide(): void {
if (this.ghostElement) {
this.ghostElement.style.display = "none";
this.ghostElement.textContent = "";
}
this.currentSuggestion = "";
this.currentInput = "";
}
getSuggestion(): string {
return this.currentSuggestion;
}
isVisible(): boolean {
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
this.currentSuggestion);
}
getGhostText(): string {
if (!this.currentSuggestion || !this.currentInput) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
}
getNextWord(): string {
const ghost = this.getGhostText();
if (!ghost) return "";
const trimmed = ghost.replace(/^\s+/, "");
const leadingSpace = ghost.length - trimmed.length;
if (trimmed.length === 0) return ghost; // Only whitespace
// Search for word boundary starting from index 1 (skip leading separator chars like /)
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
if (wordEnd < 0) return ghost; // Single word, accept all
// Include leading whitespace + the word up to (and including) the separator
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
const dims = getXTermCellDimensions(this.term);
const buffer = this.term.buffer.active;
const left = buffer.cursorX * dims.width;
const top = buffer.cursorY * dims.height;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;
this.lastLeft = left;
this.lastTop = top;
this.ghostElement.style.left = `${left}px`;
this.ghostElement.style.top = `${top}px`;
this.ghostElement.style.lineHeight = `${dims.height}px`;
this.ghostElement.style.height = `${dims.height}px`;
}
dispose(): void {
this.disposed = true;
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.term = null;
}
}

View File

@@ -0,0 +1,424 @@
/**
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
const STORAGE_KEY = "netcatty:commandHistory";
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
export interface HistoryEntry {
command: string;
hostId: string;
/** OS type for cross-host matching */
os: "linux" | "windows" | "macos";
/** Number of times this exact command was executed */
frequency: number;
/** Timestamp of last execution */
lastUsedAt: number;
/** Timestamp of first execution */
createdAt: number;
}
interface HistoryStore {
entries: HistoryEntry[];
version: number;
}
let cachedStore: HistoryStore | null = null;
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
try {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (!ok) {
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
localStorageAdapter.write(STORAGE_KEY, store);
}
saveTimer = null;
}, 500);
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
*/
export function recordCommand(
command: string,
hostId: string,
os: "linux" | "windows" | "macos" = "linux",
): void {
const trimmed = command.trim();
if (!trimmed || trimmed.length > 2000) return;
const store = loadStore();
const now = Date.now();
// Find existing entry for same command + host
const existingIdx = store.entries.findIndex(
(e) => e.command === trimmed && e.hostId === hostId,
);
if (existingIdx >= 0) {
store.entries[existingIdx].frequency++;
store.entries[existingIdx].lastUsedAt = now;
} else {
store.entries.push({
command: trimmed,
hostId,
os,
frequency: 1,
lastUsedAt: now,
createdAt: now,
});
}
// Enforce per-host limit (evict by score, not insertion order)
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
const toRemove = new Set(
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
);
store.entries = store.entries.filter(
(e) => e.hostId !== hostId || !toRemove.has(e.command),
);
}
// Enforce global limit
if (store.entries.length > MAX_ENTRIES) {
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, MAX_ENTRIES);
}
saveStore(store);
}
/**
* Score an entry for ranking at a specific timestamp.
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
*/
function scoreEntryAt(entry: HistoryEntry, now: number): number {
const ageMs = now - entry.lastUsedAt;
const ageHours = ageMs / (1000 * 60 * 60);
// Exponential decay: halve relevance every 24 hours
const recencyScore = Math.pow(0.5, ageHours / 24);
return entry.frequency * recencyScore;
}
export interface HistoryQueryOptions {
/** Filter by host ID (strict isolation — only this host's history) */
hostId?: string;
/** Maximum number of results */
limit?: number;
}
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
/** Base command name, e.g. `cd` or `ls` */
commandName: string;
/** Exact command text to exclude from results */
excludeCommand?: string;
/** Optional path prefix to require on the current argument */
argumentPrefix?: string;
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
*/
export function queryHistory(
prefix: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 20 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Fuzzy query: matches commands containing all characters of the query
* in order (not necessarily contiguous). Used as a fallback when prefix
* matching yields few results.
*/
export function fuzzyQueryHistory(
query: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 10 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
if (hostId) {
if (entry.hostId !== hostId) continue;
}
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore });
}
}
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Query the most recently used history entries for the same command name.
* Useful when the user is currently completing a path argument and wants
* a few recent command-line examples (e.g. recent `cd ...` commands).
*/
export function queryRecentHistoryByCommand(
options: RecentHistoryQueryOptions,
): HistoryEntry[] {
const {
commandName,
excludeCommand,
argumentPrefix,
hostId,
limit = 3,
} = options;
if (!commandName || limit <= 0) return [];
const store = loadStore();
const trimmedCommandName = commandName.trim().toLowerCase();
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false;
}
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false;
}
}
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
function getCurrentCommandToken(command: string): string {
const tokens = tokenizeShellLike(command);
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
}
function normalizeArgumentToken(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ")
.toLowerCase();
}
function tokenizeShellLike(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
tokens.push(current);
return tokens;
}
/**
* Compute a fuzzy match score. Returns 0 for no match.
* Higher score = better match quality.
* Rewards: first-char match, consecutive matches, word-boundary matches.
*/
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 0;
if (query.length > target.length) return 0;
let score = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
}
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
* Delete a specific command from history for a host.
*/
export function deleteHistoryEntry(command: string, hostId: string): void {
const store = loadStore();
store.entries = store.entries.filter(
(e) => !(e.command === command && e.hostId === hostId),
);
saveStore(store);
}
/**
* Clear all history for a specific host, or all history if no hostId given.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
if (hostId) {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
}
saveStore(store);
}
/**
* Get total number of stored history entries.
*/
export function getHistoryCount(hostId?: string): number {
const store = loadStore();
if (hostId) {
return store.entries.filter((e) => e.hostId === hostId).length;
}
return store.entries.length;
}

View File

@@ -0,0 +1,616 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Command history (highest priority)
* 2. @withfig/autocomplete specs (subcommands, options, args)
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
* option, or argument position) and provides appropriate suggestions.
*/
import {
queryHistory,
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
getAvailableSpecs,
normalizeCommandName,
resolveNames,
type FigSpec,
type FigSubcommand,
type FigOption,
} from "./figSpecLoader";
import {
shouldDoPathCompletion,
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
export interface CompletionSuggestion {
/** The text to insert */
text: string;
/** Display text (may differ from insert text) */
displayText: string;
/** Optional description */
description?: string;
/** Source of this suggestion */
source: SuggestionSource;
/** Relevance score (higher = more relevant) */
score: number;
/** For history entries: execution frequency */
frequency?: number;
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
}
export interface CompletionContext {
/** Full command line text */
commandLine: string;
/** Current word being typed */
currentWord: string;
/** Index of the current word in the parsed tokens */
wordIndex: number;
/** Parsed command tokens */
tokens: string[];
/** The base command name (first token) */
commandName: string;
/** Whether the current position is after a recognized option that expects an argument */
isOptionArg: boolean;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
// Always include the last token (even if empty, to indicate trailing space)
tokens.push(current);
return tokens;
}
/**
* Parse the current command line into a CompletionContext.
*/
export function parseCommandLine(input: string): CompletionContext {
const tokens = tokenize(input);
const wordIndex = tokens.length - 1;
const currentWord = tokens[wordIndex] || "";
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
return {
commandLine: input,
currentWord,
wordIndex,
tokens,
commandName,
isOptionArg: false,
};
}
/**
* Main completion function. Returns sorted suggestions from all sources.
* Ghost text should use completions[0].text instead of a separate query.
*/
export async function getCompletions(
input: string,
options: {
hostId?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
sessionId?: string;
/** Connection protocol (ssh, local, telnet, serial) */
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15 } = options;
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, undefined)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
// 1. History suggestions (full command line prefix match)
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId,
limit: preferPathSuggestions ? 0 : 5,
};
const historyMatches = queryHistory(input, historyOpts);
for (const entry of historyMatches) {
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 1000 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (preferPathSuggestions && ctx.commandName) {
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId,
limit: 3,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 900 - index,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const specPromise = ctx.commandName && ctx.wordIndex >= 0
? getSpecSuggestions(ctx)
: Promise.resolve([]);
const pathPromise = canQueryPaths && pathCheck.shouldComplete
? getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})
: Promise.resolve([]);
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
for (const suggestion of specSugs) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (pathEntries.length > 0) {
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, options.cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
for (const entry of pathEntries) {
const insertName = isQuotedPath || !entry.name.includes(" ")
? entry.name
: entry.name.replace(/ /g, "\\ ");
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
const suggestion = {
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
displayText: entry.name + suffix,
source: "path",
score: 750,
fileType: entry.type,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// 3. Fuzzy history fallback (if prefix match yields few results)
if (!preferPathSuggestions && suggestions.length < 3 && input.length >= 2) {
const fuzzyMatches = fuzzyQueryHistory(input, {
...historyOpts,
limit: 5,
});
for (const entry of fuzzyMatches) {
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 500 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);
// Deduplicate
const seen = new Set<string>();
const unique: CompletionSuggestion[] = [];
for (const s of suggestions) {
if (seen.has(s.text)) continue;
seen.add(s.text);
unique.push(s);
if (unique.length >= resultLimit) break;
}
return unique;
}
function normalizeHistoryPathPrefix(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ");
}
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return await getCommandNameSuggestions(ctx.currentWord);
}
return [];
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return [];
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return [];
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
for (const sub of spec.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.currentWord + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
// Navigate the spec tree based on typed tokens
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
// and show its children as preview (e.g., "git commit" shows commit's options)
if (currentToken && resolved.subcommands) {
const exactMatch = resolved.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(currentToken);
});
if (exactMatch) {
// Navigate into the matched subcommand and show its children
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
// Show child subcommands
if (childResolved.subcommands) {
for (const sub of childResolved.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.commandLine + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
// Show child options
appendOptionPreviewSuggestions(
suggestions,
ctx.commandLine,
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return suggestions;
}
}
// Suggest subcommands (prefix match, excluding exact matches)
if (resolved.subcommands) {
for (const sub of resolved.subcommands) {
const names = resolveNames(sub.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: sub.description,
source: "subcommand",
score: 800,
});
}
}
}
}
// Suggest options
const hasDirectOptionSuggestions = appendOptionSuggestions(
suggestions,
ctx,
currentToken,
resolved.options,
);
if (!hasDirectOptionSuggestions) {
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
}
// Suggest argument values from suggestions in the spec
if (resolved.args) {
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
for (const arg of args) {
if (arg.suggestions) {
for (const sug of arg.suggestions) {
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
const sugDesc = typeof sug === "string" ? undefined : sug.description;
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
displayText: sugName,
description: sugDesc,
source: "arg",
score: 600,
});
}
}
}
}
}
return suggestions;
}
/**
* Get command name suggestions by matching against available specs.
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
*/
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
const specs = await getAvailableSpecs();
const lower = prefix.toLowerCase();
const suggestions: CompletionSuggestion[] = [];
for (const name of specs) {
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
if (name.includes("/")) continue;
if (name.startsWith(lower) && name !== lower) {
suggestions.push({
text: name,
displayText: name,
source: "command",
score: 600,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
interface ResolvedContext {
subcommands?: FigSubcommand[];
options?: FigOption[];
fallbackOptions?: FigOption[];
args?: FigSubcommand["args"];
}
/**
* Walk the spec tree following the typed tokens to find the current context.
* Handles options with arguments (e.g., --name value) by skipping the value token.
*/
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
let current: FigSubcommand = spec;
let inheritedOptions: FigOption[] = [];
let skipNext = false;
let lastOptionArgs: FigSubcommand["args"] | undefined;
for (const token of consumedTokens) {
// Skip this token if it's the argument value of a previous option
if (skipNext) {
skipNext = false;
lastOptionArgs = undefined;
continue;
}
// Handle option flags
if (token.startsWith("-")) {
// Check if this option expects an argument
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
const names = resolveNames(candidate.name);
return names.includes(token);
});
if (opt?.args) {
// This option expects an argument — the next token is its value
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
if (args.length > 0 && !args[0].isOptional) {
skipNext = true;
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
}
}
continue;
}
// Try to find a matching subcommand
if (current.subcommands) {
const sub = current.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(token);
});
if (sub) {
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
current = sub;
continue;
}
}
// If no subcommand matched, we're at the args level
break;
}
// If skipNext is still true, the currentWord is an option's arg value
// (e.g., "git archive --format |" — currentWord is the format value)
// Return the option's args instead of the subcommand's args.
if (skipNext && lastOptionArgs) {
return {
subcommands: undefined,
options: undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: lastOptionArgs,
};
}
return {
subcommands: current.subcommands,
options: current.options ? [...current.options] : undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: current.args,
};
}
function mergeOptionLists(
left: FigOption[] | undefined,
right: FigOption[] | undefined,
): FigOption[] {
const merged: FigOption[] = [];
const seen = new Set<string>();
for (const option of [...(left ?? []), ...(right ?? [])]) {
const key = resolveNames(option.name).sort().join("\0");
if (seen.has(key)) continue;
seen.add(key);
merged.push(option);
}
return merged;
}
function appendOptionSuggestions(
suggestions: CompletionSuggestion[],
ctx: CompletionContext,
currentToken: string,
options: FigOption[] | undefined,
): boolean {
if (!options || options.length === 0) return false;
let added = false;
for (const opt of options) {
const names = resolveNames(opt.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: opt.description,
source: "option",
score: 700,
});
added = true;
}
}
}
return added;
}
function appendOptionPreviewSuggestions(
suggestions: CompletionSuggestion[],
commandLine: string,
options: FigOption[] | undefined,
limit: number,
): void {
if (!options || options.length === 0 || suggestions.length >= limit) return;
for (const opt of options) {
const names = resolveNames(opt.name);
suggestions.push({
text: commandLine + " " + names[0],
displayText: names[0],
description: opt.description,
source: "option",
score: 700,
});
if (suggestions.length >= limit) break;
}
}
/**
* Rebuild the full command text with a replacement at a specific token index.
*/
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
const rebuilt = [...tokens];
rebuilt[replaceIndex] = replacement;
return rebuilt.join(" ");
}

View File

@@ -0,0 +1,198 @@
/**
* Loader for @withfig/autocomplete command specifications.
* Loads specs via Electron main process IPC (Node.js require),
* which reliably accesses node_modules in both dev and production.
*/
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
export interface FigOption {
name: string | string[];
description?: string;
args?: FigArg | FigArg[];
isRequired?: boolean;
isPersistent?: boolean;
exclusiveOn?: string[];
}
export interface FigArg {
name?: string;
description?: string;
suggestions?: (string | FigSuggestion)[];
template?: string | string[];
isOptional?: boolean;
isVariadic?: boolean;
generators?: unknown;
}
export interface FigSuggestion {
name: string | string[];
description?: string;
icon?: string;
type?: string;
priority?: number;
}
export interface FigSubcommand {
name: string | string[];
description?: string;
subcommands?: FigSubcommand[];
options?: FigOption[];
args?: FigArg | FigArg[];
}
export interface FigSpec extends FigSubcommand {
// Top-level spec may include additional metadata
}
// Bridge type augmentation
interface FigSpecBridge {
listFigSpecs?: () => Promise<string[]>;
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
}
function getBridge(): FigSpecBridge | undefined {
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
}
// Cache loaded specs
const specCache = new Map<string, FigSpec | null>();
// In-flight loading promises to avoid duplicate loads
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
// All available spec names
let availableSpecs: string[] | null = null;
let availableSpecsSet: Set<string> | null = null;
/**
* Get the list of all available command specs via IPC.
*/
export async function getAvailableSpecs(): Promise<string[]> {
// Only return cache if it has actual specs (not an empty failure)
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
try {
const bridge = getBridge();
if (bridge?.listFigSpecs) {
const specs = await bridge.listFigSpecs();
if (Array.isArray(specs) && specs.length > 0) {
availableSpecs = specs;
availableSpecsSet = new Set(specs);
return specs;
}
}
} catch (err) {
console.warn("[Autocomplete] figspec bridge error:", err);
}
// Don't cache empty — allow retry on next call
return [];
}
/**
* Load a command specification by name via IPC.
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
*/
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
if (specCache.has(commandName)) {
return specCache.get(commandName) ?? null;
}
const existing = inFlightLoads.get(commandName);
if (existing) return existing;
const loadPromise = (async (): Promise<FigSpec | null> => {
try {
const bridge = getBridge();
if (!bridge?.loadFigSpec) {
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
return null;
}
const spec = await bridge.loadFigSpec(commandName);
if (spec) {
specCache.set(commandName, spec);
}
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
return spec;
} catch {
// Don't cache failures — allow retry on next request
return null;
} finally {
inFlightLoads.delete(commandName);
}
})();
inFlightLoads.set(commandName, loadPromise);
return loadPromise;
}
/**
* Check if a spec exists for a given command name (without loading it).
*/
export async function hasSpec(commandName: string): Promise<boolean> {
// Only trust positive cache hits (spec loaded successfully).
// Null entries may be stale failures from preload — ignore them.
const cached = specCache.get(commandName);
if (cached) return true;
await getAvailableSpecs();
return availableSpecsSet?.has(commandName) ?? false;
}
/**
* Preload commonly used specs in batches to avoid overwhelming IPC.
* Only call this when autocomplete is enabled.
*/
export function preloadCommonSpecs(): void {
const common = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
];
const BATCH_SIZE = 8;
let offset = 0;
const loadBatch = () => {
const batch = common.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
for (const name of batch) {
loadSpec(name).catch(() => {});
}
offset += BATCH_SIZE;
if (offset < common.length) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => loadBatch());
} else {
setTimeout(loadBatch, 100);
}
}
};
setTimeout(loadBatch, 200);
}
/**
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
*/
export function normalizeCommandName(rawCommand: string): string {
const parts = rawCommand.split("/");
let name = parts[parts.length - 1];
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
return name.toLowerCase();
}
/**
* Resolve names from a Fig spec name field (which can be string or string[]).
*/
export function resolveNames(name: string | string[]): string[] {
return Array.isArray(name) ? name : [name];
}

View File

@@ -0,0 +1,5 @@
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";

View File

@@ -0,0 +1,225 @@
/**
* Prompt detector for terminal autocomplete.
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
/**
* Patterns that indicate the user is NOT at a prompt
* (e.g., inside vim, less, man, top, etc.)
*/
const NON_PROMPT_PATTERNS = [
/^~$/, // vim empty line marker
/^\s*--\s*More\s*--/, // less/more pager
/^\s*\(END\)/, // less end marker
/^:\s*$/, // vim command mode
/^\s*~\s*$/, // vim tilde lines
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
];
export interface PromptDetectionResult {
/** Whether a prompt is detected on the current line */
isAtPrompt: boolean;
/** The detected prompt text (everything before user input) */
promptText: string;
/** The user's current input (after the prompt) */
userInput: string;
/** The cursor column position within the user input */
cursorOffset: number;
}
const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
export function detectPrompt(term: XTerm): PromptDetectionResult {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line) return NO_PROMPT;
// translateToString(false) preserves trailing spaces — important for cursor-based
// input extraction (trailing space triggers empty token for option suggestions)
const lineText = line.translateToString(false);
// Check for non-prompt patterns (pagers, editors, etc.)
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return NO_PROMPT;
}
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
// Try to find the prompt boundary on the current line
const promptEnd = findPromptBoundary(lineText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Use cursor position to determine actual input length — don't trim trailing
// spaces since they're significant for autocomplete (e.g., "git commit " should
// produce an empty trailing token to trigger option suggestions).
const rawInput = lineText.substring(promptEnd);
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
const cursorOffset = Math.max(0, cursorX - promptEnd);
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
// long command wrapped onto multiple rows), look upward for the prompt line.
// The current row's content is continuation of the command.
if (line.isWrapped) {
// Walk up to find the first non-wrapped line (the prompt line)
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) break;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (promptLine) {
const promptLineText = promptLine.translateToString(false);
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
for (let row = promptRow + 1; row <= cursorY; row++) {
const rowLine = buffer.getLine(row);
if (rowLine) fullInput += rowLine.translateToString(false);
}
// Trim to cursor position on the last row
const totalCols = term.cols;
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
}
}
return NO_PROMPT;
}
/** Characters that commonly end a shell prompt */
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
function findPromptBoundary(lineText: string): number {
// Scan for prompt boundary. Take the LAST candidate.
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
if (!PROMPT_CHARS.has(ch)) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
// Must be followed by a space or end-of-line.
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
if (nextChar !== null && nextChar !== " ") {
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
// only if preceded by a path-like pattern (drive letter or backslash)
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
// Looks like a path ending — accept as prompt
} else {
continue;
}
}
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
if (ch === "$") {
// Check what comes AFTER the space — but more importantly check what
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
//
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
if (i > 0) {
const prev = lineText[i - 1];
// If preceded by = or / or another non-separator, it's a variable reference
if (prev === "=" || prev === "/" || prev === ":") continue;
// If preceded by a letter and there's no space between, it could be $HOME-style
// But actually: "user@host:~$ " has letter before $. So check if there's
// a valid prompt pattern before the $.
}
// Check what follows: if after "$ " there's more content with $ in variable positions
// Actually the simplest reliable check: if the character after the space is alphanumeric
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
// That's always true for a prompt. So the $ check is really about false positives mid-line.
//
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
// the first token), it's likely a prompt. If we've already passed spaces (meaning
// we're past the first "word"), a $ is more likely a variable.
let seenSpaceBeforeDollar = false;
for (let j = 0; j < i; j++) {
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
}
// If there was a space before this $, it might be mid-command (like "echo $HOME")
// Only accept if the $ is reasonably close to common prompt patterns
if (seenSpaceBeforeDollar) {
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
// Likely a prompt ending like [user@host ~]$
} else {
continue; // Skip — likely a variable reference mid-command
}
}
}
// Record this as a candidate boundary
lastBoundary = nextChar === " " ? i + 2 : i + 1;
}
return lastBoundary;
}
/**
* Simplified prompt detection: just check if we're likely at a prompt.
*/
export function isLikelyAtPrompt(term: XTerm): boolean {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return false;
const lineText = line.translateToString(false);
if (lineText.trim().length === 0) return false;
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return false;
}
return findPromptBoundary(lineText) >= 0;
}

View File

@@ -0,0 +1,436 @@
/**
* Remote path completion for terminal autocomplete.
* Lists files/directories on the remote (or local) machine
* when the user types commands that expect path arguments.
*/
import type { CompletionContext } from "./completionEngine";
import type { FigArg } from "./figSpecLoader";
/** Directory entry returned from IPC */
export interface DirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
/** Bridge interface for directory listing */
interface PathBridge {
listAutocompleteRemoteDir?: (
sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
listAutocompleteLocalDir?: (
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
}
function getBridge(): PathBridge | undefined {
return (window as Window & { netcatty?: PathBridge }).netcatty;
}
// Cache directory listings for 5 seconds. Full-directory cache is shared between
// popup suggestions and cascading sub-directory panels; filtered cache avoids
// repeated round-trips while the user keeps typing within the same directory.
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
const CACHE_TTL_MS = 5000;
const MAX_CACHE_SIZE = 30;
const MAX_FILTERED_CACHE_SIZE = 60;
/** Commands that commonly accept file/directory path arguments */
const PATH_COMMANDS = new Set([
"cd", "ls", "ll", "la", "dir", "cat", "less", "more", "head", "tail",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl",
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp",
"stat", "file", "source", ".", "bat", "rg", "find", "tree",
"tar", "zip", "unzip", "gzip", "gunzip",
"scp", "rsync", "diff",
"python", "python3", "node", "ruby", "perl", "bash", "sh", "zsh",
]);
/** Commands that only accept directories (not files) */
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
/**
* Check if the current command context expects a path argument.
*/
export function shouldDoPathCompletion(
ctx: CompletionContext,
resolvedArgs?: FigArg | FigArg[],
): { shouldComplete: boolean; foldersOnly: boolean } {
const currentWord = stripWrappingQuotes(ctx.currentWord);
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
currentWord === "." || currentWord === ".." || currentWord === "~") {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
// 2. Fig spec template check
if (resolvedArgs) {
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
for (const arg of args) {
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
if (templates.includes("filepaths") || templates.includes("folders")) {
return {
shouldComplete: true,
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
// Generators field often indicates path completion (e.g., cd)
if (arg.generators) {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
}
}
// 3. Hardcoded command list (for commands without fig specs)
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
// Only if we're past the command name and not typing an option
if (!currentWord.startsWith("-")) {
return {
shouldComplete: true,
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
};
}
}
return { shouldComplete: false, foldersOnly: false };
}
/**
* Parse the current word into directory-to-list and filter prefix.
*/
export function resolvePathComponents(
currentWord: string,
cwd: string | undefined,
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
const quotePrefix = getLeadingQuote(currentWord);
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
const unquotedWord = stripWrappingQuotes(currentWord);
// Handle empty input — list CWD
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
const dir = unquotedWord === "~"
? "~"
: unquotedWord === ".."
? resolveDirLookup("../", cwd)
: (cwd || ".");
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
}
// Find the last path separator
const lastSlash = unquotedWord.lastIndexOf("/");
if (lastSlash >= 0) {
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
const filterPart = unquotedWord.substring(lastSlash + 1);
const decodedDirPart = decodeShellPathFragment(dirPart);
const decodedFilterPart = decodeShellPathFragment(filterPart);
const dirToList = resolveDirLookup(decodedDirPart, cwd);
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
}
// No slash — filter CWD entries by the typed prefix
return {
dirToList: cwd || ".",
filterPrefix: decodeShellPathFragment(unquotedWord),
pathPrefix: quotePrefix,
quoteSuffix,
};
}
export function normalizePathTokenForLookup(token: string, cwd?: string): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd);
if (!filterPrefix) return dirToList;
if (!dirToList || dirToList === ".") {
return filterPrefix;
}
const needsSeparator = !dirToList.endsWith("/");
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
}
/**
* Get path completion suggestions.
*/
export async function getPathSuggestions(
ctx: CompletionContext,
options: {
sessionId?: string;
protocol?: string;
cwd?: string;
foldersOnly: boolean;
},
): Promise<{ name: string; type: DirEntry["type"] }[]> {
const { sessionId, protocol, cwd, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd);
const entries = await listDirectoryEntries(dirToList, {
sessionId,
protocol,
foldersOnly,
filterPrefix,
limit: 100,
});
return sortPathEntries(entries);
}
/**
* List directory contents via IPC, with shared caching and in-flight dedup.
*/
export async function listDirectoryEntries(
dirPath: string,
options: {
sessionId?: string;
protocol?: string;
foldersOnly: boolean;
filterPrefix?: string;
limit?: number;
},
): Promise<DirEntry[]> {
const {
sessionId,
protocol,
foldersOnly,
filterPrefix = "",
limit = 100,
} = options;
const normalizedPrefix = filterPrefix.toLowerCase();
const maxEntries = clampLimit(limit);
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
const fullCacheKey = `${baseKey}:all`;
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
// Full directory cache can satisfy both full and filtered lookups.
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
}
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
const inFlight = inFlightRequests.get(requestKey);
if (inFlight) return inFlight;
// Make IPC call
const promise = (async (): Promise<DirEntry[]> => {
try {
const bridge = getBridge();
if (!bridge) return [];
let result: { success: boolean; entries: DirEntry[] };
if (protocol === "local" || !sessionId) {
if (!bridge.listAutocompleteLocalDir) return [];
result = await bridge.listAutocompleteLocalDir(
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
} else {
if (!bridge.listAutocompleteRemoteDir) return [];
result = await bridge.listAutocompleteRemoteDir(
sessionId,
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
}
if (result.success) {
const timestamp = Date.now();
if (normalizedPrefix) {
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
return result.entries;
}
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(fullDirCache, MAX_CACHE_SIZE);
return result.entries;
}
return [];
} catch {
return [];
} finally {
inFlightRequests.delete(requestKey);
}
})();
inFlightRequests.set(requestKey, promise);
return promise;
}
function clampLimit(limit: number): number {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(200, Math.floor(limit)));
}
function resolveDirLookup(pathToken: string, cwd: string | undefined): string {
if (!pathToken) return cwd || ".";
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
return normalizePosixLikePath(pathToken);
}
function normalizePosixLikePath(input: string): string {
if (!input) return ".";
const hasLeadingSlash = input.startsWith("/");
const hasTildeRoot = input === "~" || input.startsWith("~/");
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
const fixedRootSegments = hasTildeRoot ? 1 : 0;
const raw = hasLeadingSlash
? input.slice(1)
: hasTildeRoot
? input.slice(2)
: input;
const segments = hasTildeRoot ? ["~"] : [];
for (const segment of raw.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (
segments.length > fixedRootSegments &&
segments[segments.length - 1] !== ".."
) {
segments.pop();
} else if (!hasLeadingSlash || hasTildeRoot) {
segments.push(segment);
}
continue;
}
segments.push(segment);
}
let result: string;
if (hasLeadingSlash) {
result = "/" + segments.join("/");
if (result === "/") return result;
} else if (segments.length > 0) {
result = segments.join("/");
} else if (hasTildeRoot) {
result = "~";
} else {
result = ".";
}
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
result += "/";
} else if (hasTrailingSlash && result === "~") {
result = "~/";
}
return result;
}
function isFresh(
cached: { entries: DirEntry[]; timestamp: number } | undefined,
): cached is { entries: DirEntry[]; timestamp: number } {
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
}
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
if (!filterPrefix) return entries.slice(0, limit);
const filtered: DirEntry[] = [];
for (const entry of entries) {
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
filtered.push(entry);
if (filtered.length >= limit) break;
}
}
return filtered;
}
function evictOldest(
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
maxSize: number,
): void {
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
function decodeShellPathFragment(value: string): string {
let result = "";
let escaped = false;
for (const ch of value) {
if (escaped) {
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
result += ch;
}
if (escaped) result += "\\";
return result;
}
function getLeadingQuote(value: string): string {
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
}
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
let result = value;
if (result.startsWith('"') || result.startsWith("'")) {
result = result.slice(1);
}
if (result.endsWith('"') || result.endsWith("'")) {
result = result.slice(0, -1);
}
return result;
}
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
return [...entries].sort((left, right) => {
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
if (leftRank !== rightRank) return leftRank - rightRank;
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
/**
* Utility functions for xterm.js cell dimension access.
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
* Falls back to DOM measurement if the internal API is unavailable.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
export interface CellDimensions {
width: number;
height: number;
}
// Cache to avoid repeated DOM measurements (invalidated on resize)
let cachedDims: CellDimensions | null = null;
let cachedTermId: number = 0;
let termIdCounter = 0;
const termIdMap = new WeakMap<XTerm, number>();
function getTermId(term: XTerm): number {
let id = termIdMap.get(term);
if (id === undefined) {
id = ++termIdCounter;
termIdMap.set(term, id);
}
return id;
}
/**
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
*/
export function getXTermCellDimensions(term: XTerm): CellDimensions {
// Try xterm core renderer API (fast path)
const coreAccess = term as XTerm & {
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
};
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
// Update cache while we have a good value
const id = getTermId(term);
cachedDims = { width: coreDims.width, height: coreDims.height };
cachedTermId = id;
return cachedDims;
}
// Check cache (same terminal instance)
const id = getTermId(term);
if (cachedDims && cachedTermId === id) {
return cachedDims;
}
// Fallback: measure from DOM (triggers single reflow)
const dims = measureCellFromDOM(term);
cachedDims = dims;
cachedTermId = id;
return dims;
}
/**
* Measure cell dimensions by inserting a temporary span into the terminal element.
* Triggers a single reflow (reading offsetWidth + offsetHeight).
*/
function measureCellFromDOM(term: XTerm): CellDimensions {
const element = term.element;
if (!element) return { width: 8, height: 16 };
const span = document.createElement("span");
span.textContent = "W";
Object.assign(span.style, {
position: "absolute",
visibility: "hidden",
fontFamily: term.options.fontFamily || "monospace",
fontSize: `${term.options.fontSize}px`,
lineHeight: "normal",
});
element.appendChild(span);
const width = span.offsetWidth || 8;
const height = span.offsetHeight || 16;
span.remove();
return { width, height };
}
/**
* Invalidate the cached cell dimensions (call on terminal resize).
*/
export function invalidateCellDimensionCache(): void {
cachedDims = null;
}

View File

@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isLinux: boolean; // Only collect stats for Linux servers
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
@@ -56,7 +56,7 @@ export function useServerStats({
sessionId,
enabled,
refreshInterval,
isLinux,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
@@ -86,7 +86,7 @@ export function useServerStats({
const isMountedRef = useRef(true);
const fetchStats = useCallback(async () => {
if (!enabled || !isLinux || !isConnected || !sessionId) {
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
return;
}
@@ -137,7 +137,7 @@ export function useServerStats({
setIsLoading(false);
}
}
}, [sessionId, enabled, isLinux, isConnected]);
}, [sessionId, enabled, isSupportedOs, isConnected]);
// Initial fetch and periodic refresh
useEffect(() => {
@@ -149,8 +149,7 @@ export function useServerStats({
intervalRef.current = null;
}
// Don't run if not enabled or not a Linux system
if (!enabled || !isLinux || !isConnected) {
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats when disabled or not connected
setStats({
cpu: null,
@@ -193,7 +192,7 @@ export function useServerStats({
intervalRef.current = null;
}
};
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {

View File

@@ -10,6 +10,19 @@ interface CompiledRule {
color: string;
}
interface CachedDecorationRange {
x: number;
width: number;
color: string;
}
/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
/**
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
@@ -31,23 +47,22 @@ export class KeywordHighlighter implements IDisposable {
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
this.triggerRefresh("debounced");
}),
// When new data is written, refresh
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
this.triggerRefresh("immediate");
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh()),
this.term.onResize(() => this.triggerRefresh("debounced")),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
this.triggerRefresh("debounced");
}
})
);
@@ -55,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
this.matchCache.clear();
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
@@ -76,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
this.triggerRefresh("immediate");
}
}
@@ -87,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.matchCache.clear();
}
private triggerRefresh() {
private triggerRefresh(mode: "immediate" | "debounced") {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
@@ -101,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
return;
}
if (mode === "immediate") {
// Throttle: skip if a rAF is already pending.
// Don't clear the debounce timer here — in a hidden tab rAF never
// fires, so the fallback timer is the only path that will run.
if (this.animationFrameId !== null) {
return;
}
const now = performance.now();
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
if (now - this.lastRefreshTime < minInterval) {
// Too soon — fall through to debounced path instead of dropping
this.triggerRefresh("debounced");
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
// rAF fired — cancel the fallback timer to avoid a redundant refresh
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.executeRefresh();
});
// Arm a debounced fallback: rAF does not fire in background/hidden
// tabs (Chromium throttles it), so the timer ensures highlights
// still update for ongoing output. If rAF fires first it cancels
// this timer (see above), preventing a double refresh.
if (!this.debounceTimer) {
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
}
return;
}
if (this.animationFrameId !== null) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, delay);
}
/** Shared refresh execution for both rAF and timer callbacks. */
private executeRefresh() {
// Cancel any stale rAF that will never fire (e.g. hidden tab)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Re-check state: may have changed since the refresh was scheduled
if (!this.enabled || this.compiledRules.length === 0) return;
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) this.clearDecorations();
return;
}
this.lastRefreshTime = performance.now();
this.refreshViewport();
}
private clearDecorations() {
@@ -140,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
if (chars.length > 0) {
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
} else {
// Empty cell (codepoint 0) — translateToString() outputs a space
// for it, so we must push one entry to keep the map aligned.
map.push(cellCol);
}
@@ -177,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
const cachedRanges = this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) continue;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
for (const range of cachedRanges) {
const marker = this.term.registerMarker(offset);
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
foregroundColor: range.color,
});
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
const cached = this.matchCache.get(lineText);
if (cached) {
// LRU: move to end
this.matchCache.delete(lineText);
this.matchCache.set(lineText, cached);
return cached;
}
const ranges = this.scanLine(line, lineText);
this.matchCache.set(lineText, ranges);
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
if (this.matchCache.size > maxEntries) {
const oldestKey = this.matchCache.keys().next().value;
if (oldestKey !== undefined) {
this.matchCache.delete(oldestKey);
}
}
return ranges;
}
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
// so we can skip the expensive buildStringToCellMap call entirely.
const asciiOnly = RE_ASCII_ONLY.test(lineText);
let cellMap: number[] | null = null;
let ranges: CachedDecorationRange[] | null = null;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
let cellStartCol: number;
let cellEndCol: number;
if (asciiOnly) {
cellStartCol = strStart;
cellEndCol = strEnd;
} else {
// Lazily build cellMap only when a match is found
if (cellMap === null) {
cellMap = this.buildStringToCellMap(line);
}
cellStartCol = cellMap[strStart] ?? strStart;
cellEndCol = cellMap[strEnd] ?? strEnd;
}
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
if (ranges === null) {
ranges = [];
}
ranges.push({
x: cellStartCol,
width: cellWidth,
color,
});
}
}
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
}
}

View File

@@ -44,7 +44,7 @@ type TerminalBackendApi = {
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
@@ -323,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys: ctx.keys,
@@ -336,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
const hasEncryptedJumpProxyCredential =
hasConfiguredJumpProxyEndpoint &&
Boolean(jumpHost.proxyConfig?.username) &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
@@ -358,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const message = tr(
"terminal.auth.proxyCredentialsUnavailable",
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
@@ -403,21 +421,64 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
}
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
{
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
}
// Build human-readable log line
let logLine: string;
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
switch (status) {
case 'connecting':
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
break;
case 'authenticating':
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
break;
case 'auth-attempt':
if (error?.endsWith('rejected')) {
logLine = `${prefix}${label} - ✗ ${error}`;
} else if (error === 'all methods exhausted') {
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
} else if (error === 'waiting for user input...' || error === 'user responded') {
logLine = `${prefix}${label} - ${error}`;
} else {
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
}
break;
case 'authenticated':
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
break;
case 'connected':
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
break;
case 'forwarding':
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
break;
case 'shell':
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
break;
case 'error':
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
break;
default:
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
}
ctx.setProgressLogs((prev) => [...prev, logLine]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});
@@ -456,6 +517,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
// Only pass local key paths if no vault key is explicitly configured
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
});
};
@@ -547,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
}, 600);
}
// Run OS detection only after successful connection
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -572,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
};
const startTelnet = async (term: XTerm) => {

View File

@@ -101,6 +101,11 @@ export type CreateXTermRuntimeContext = {
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
onOsc52ReadRequest?: () => Promise<boolean>;
// Autocomplete key event handler — returns false if event was consumed
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
};
const detectPlatform = (): XTermPlatform => {
@@ -161,6 +166,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -213,6 +221,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
@@ -371,6 +380,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (e.type !== "keydown") {
return true;
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
e.preventDefault();
ctx.setIsSearchOpen(true);
@@ -391,13 +407,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -559,6 +579,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
scrollToBottomAfterInput(data);
// Notify autocomplete of input
ctx.onAutocompleteInput?.(data);
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
if (data === "\r" || data === "\n") {
const cmd = ctx.commandBufferRef.current.trim();

View File

@@ -1,5 +1,6 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -96,6 +97,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
setNotify(toast);
return () => {
globalShowToast = null;
};

View File

@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
return detected;
};
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
export const formatHostPort = (hostname: string, port?: number | null): string => {
if (port == null) return hostname;
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
const display = isIPv6 ? `[${hostname}]` : hostname;
return `${display}:${port}`;
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -55,6 +55,7 @@ export interface SftpBookmark {
id: string;
path: string;
label: string;
global?: boolean;
}
export interface Host {
@@ -113,6 +114,9 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// Local SSH key file paths (from SSH config IdentityFile or user-added)
// Resolved at connection time — the app reads the file content when connecting.
identityFilePaths?: string[];
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -410,6 +414,8 @@ export interface TerminalSettings {
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
smoothScrolling: boolean; // Animate viewport scrolling instead of jumping instantly
// Mouse
rightClickBehavior: RightClickBehavior;
copyOnSelect: boolean; // Automatically copy selected text
@@ -440,6 +446,14 @@ export interface TerminalSettings {
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
// Autocomplete
autocompleteEnabled: boolean; // Enable terminal command autocomplete
autocompleteGhostText: boolean; // Show inline ghost text suggestions (like fish shell)
autocompletePopupMenu: boolean; // Show popup menu with multiple suggestions
autocompleteDebounceMs: number; // Debounce delay for fetching suggestions (ms)
autocompleteMinChars: number; // Minimum characters before showing suggestions
autocompleteMaxSuggestions: number; // Maximum suggestions in popup menu
}
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
@@ -509,6 +523,9 @@ export const normalizeTerminalSettings = (
return {
...mergedSettings,
autocompleteGhostText: mergedSettings.autocompletePopupMenu
? false
: mergedSettings.autocompleteGhostText,
keywordHighlightRules: normalizeKeywordHighlightRules(
mergedSettings.keywordHighlightRules,
),
@@ -532,6 +549,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollOnOutput: false,
scrollOnKeyPress: false,
scrollOnPaste: true,
smoothScrolling: false,
rightClickBehavior: 'context-menu',
copyOnSelect: false,
middleClickPaste: true,
@@ -547,6 +565,12 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
disableBracketedPaste: false, // Bracketed paste enabled by default
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
autocompleteEnabled: true, // Autocomplete enabled by default
autocompleteGhostText: false, // Mutually exclusive with popup menu
autocompletePopupMenu: true, // Popup menu enabled by default
autocompleteDebounceMs: 100, // 100ms debounce
autocompleteMinChars: 1, // Start suggesting after 1 character
autocompleteMaxSuggestions: 8, // Show up to 8 suggestions
};
export interface TerminalTheme {

View File

@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
warnings: string[];
}
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
* Must have only hex digits and colons, with either:
* - A "::" shorthand (unambiguously IPv6), or
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
const isBareIPv6 = (s: string): boolean => {
if (!BARE_IPV6_RE.test(s)) return false;
if (s.includes('::')) return true;
return (s.match(/:/g) || []).length === 7;
};
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
// Pattern: [user@]hostname[:port]
// Hostname can be IP (v4 or v6) or domain name
// Hostname can be IP (v4 or v6 in brackets) or domain name
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
const match = trimmed.match(regex);
if (!match) return null;
// If the main regex fails, try bare IPv6: [user@]ipv6_address
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
// Port must be specified via brackets: [ipv6]:port
if (!match) {
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
const bareMatch = trimmed.match(bareIpv6Regex);
if (bareMatch) {
const [, bareUser, bareHost] = bareMatch;
if (isBareIPv6(bareHost)) {
return {
hostname: bareHost,
username: bareUser || undefined,
port: undefined,
};
}
}
return null;
}
const [, username, hostname, portStr] = match;

View File

@@ -113,6 +113,15 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
lines.push(` Port ${host.port}`);
}
// Serialize IdentityFile paths
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
for (const keyPath of host.identityFilePaths) {
// Quote paths that contain spaces
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
lines.push(` IdentityFile ${formatted}`);
}
}
// Serialize ProxyJump if host has a chain
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
if (proxyJumpValue) {

View File

@@ -198,6 +198,8 @@ export interface SyncPayload {
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
// Immersive mode
immersiveMode?: boolean;
};
// Sync metadata

View File

@@ -519,6 +519,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
username?: string;
port?: number;
proxyJump?: string;
identityFiles?: string[];
};
const blocks: Block[] = [];
@@ -557,6 +558,12 @@ const importFromSshConfig = (text: string): VaultImportResult => {
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
else if (keyword === "identityfile") {
if (!current.identityFiles) current.identityFiles = [];
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
const unquoted = value.replace(/^["']|["']$/g, "");
current.identityFiles.push(unquoted);
}
}
flush();
@@ -597,6 +604,11 @@ const importFromSshConfig = (text: string): VaultImportResult => {
protocol: "ssh",
});
// Attach IdentityFile paths if present
if (block.identityFiles && block.identityFiles.length > 0) {
host.identityFilePaths = [...block.identityFiles];
}
parsedHosts.push(host);
// Store ProxyJump using hostname key (survives deduplication)

View File

@@ -6,7 +6,12 @@ module.exports = {
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
npmRebuild: false,
// npmRebuild must stay enabled for macOS and Windows builds — without it,
// node-pty's native module is not recompiled for the Electron ABI, causing
// "posix_spawnp failed" on macOS. Linux builds set npm_config_arch in CI
// and run ensure-node-pty-linux.sh before packaging, so the rebuild is
// redundant but harmless there.
npmRebuild: true,
directories: {
buildResources: 'build',
output: 'release'
@@ -91,20 +96,7 @@ module.exports = {
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
},
deb: {

View File

@@ -43,53 +43,89 @@ function subscribeToPtyData(ptyStream, onData) {
throw new Error("PTY stream does not support data subscriptions");
}
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
return String(text || "").replace(/'/g, "'\\''");
}
function escapePowerShellSingleQuoted(text) {
return String(text || "").replace(/'/g, "''");
}
function escapeFishSingleQuoted(text) {
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function escapeCmdForNestedShell(text) {
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
}
function buildWrappedCommand(command, shellKind, marker) {
switch (shellKind) {
case "powershell": {
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
// the PTY delivers it in small chunks (the marker must appear early).
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
const psEscaped = escapePowerShellSingleQuoted(command);
return (
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
);
}
case "cmd":
return [
'set "PAGER=cat"',
'set "SYSTEMD_PAGER="',
'set "GIT_PAGER=cat"',
'set "LESS="',
`echo ${marker}_S`,
command,
`echo ${marker}_E:%errorlevel%`,
"",
].join("\r\n");
case "cmd": {
const cmdEscaped = escapeCmdForNestedShell(command);
return (
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
);
}
case "fish":
return [
"set -gx PAGER cat",
"set -gx SYSTEMD_PAGER ''",
"set -gx GIT_PAGER cat",
"set -gx LESS ''",
`printf '%s\\n' '${marker}_S'`,
command,
"set __NCMCP_rc $status",
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
"",
].join("\n");
// set __NCMCP_... at the start ensures early marker presence in echo.
return (
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
// Clear the current terminal row before the user-visible echo.
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
);
case "posix":
default: {
// Combine into 2 PTY lines to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker + restore exit code
// Single-line compound command with early marker & visible command echo.
//
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
//
// Key design decisions:
//
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
// contains __NCMCP_ in its first few bytes. This is critical:
// preload.cjs filters chunks by buffering incomplete lines that
// contain __NCMCP_. Without this prefix, the first chunk of a
// long echo line might not contain the marker and would leak
// through to the terminal as garbage.
//
// 2) printf clears the current row and outputs "> command\n"
// (no marker) → visible to user without prompt residue.
//
// 3) The user command is executed via eval on a quoted string. This
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const escaped = escapePosixSingleQuoted(command);
return (
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
);
}
}
@@ -106,6 +142,9 @@ function buildWrappedCommand(command, shellKind, marker) {
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
*/
function execViaPty(ptyStream, command, options) {
const {
@@ -113,33 +152,51 @@ function execViaPty(ptyStream, command, options) {
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
// Fast-path: already aborted before we even start
if (abortSignal?.aborted) {
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
}
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let timeoutId = null;
let promptFallbackTimer = null;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
// Buffer for incomplete line data when searching for start marker.
// SSH channels can split data at arbitrary byte boundaries, so the
// start marker may arrive across two chunks. We keep the content
// after the last \n (i.e. the current incomplete line) and prepend
// it to the next chunk so indexOf can match the full marker.
let pendingStart = "";
const onData = (data) => {
const text = data.toString();
if (!foundStart) {
// Look for the start marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
let pos = 0;
while (pos < text.length) {
const idx = text.indexOf(startMarker, pos);
while (pos < combined.length) {
const idx = combined.indexOf(startMarker, pos);
if (idx === -1) break;
// Accept if at start of text, or preceded by \n or \r (line boundary)
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
foundStart = true;
const afterMarker = text.slice(idx);
matched = true;
const afterMarker = combined.slice(idx);
const nlIdx = afterMarker.indexOf("\n");
if (nlIdx !== -1) {
output += afterMarker.slice(nlIdx + 1);
@@ -148,14 +205,42 @@ function execViaPty(ptyStream, command, options) {
}
pos = idx + 1;
}
if (foundStart) checkEnd();
if (!matched) {
// Keep the last incomplete line for cross-chunk matching
const lastNl = combined.lastIndexOf("\n");
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
}
if (foundStart) {
schedulePromptFallback();
checkEnd();
}
return;
}
output += text;
schedulePromptFallback();
checkEnd();
};
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Fallback for shells that visibly return to the same idle prompt but
// never emit the wrapped end marker line.
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, 250);
}
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
@@ -179,39 +264,43 @@ function execViaPty(ptyStream, command, options) {
}
}
function finish(stdout, exitCode) {
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearPromptFallback();
unsubscribe?.();
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").trim();
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
cleaned = cleaned.trim();
if (error) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
} else {
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
unsubscribe?.();
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
// Send Ctrl+C to kill the timed-out command
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
const cleaned = stripAnsi(output).trim();
const timeoutSec = Math.round(timeoutMs / 1000);
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
finish(output, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
unsubscribe = subscribeToPtyData(ptyStream, onData);
@@ -220,6 +309,11 @@ function execViaPty(ptyStream, command, options) {
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel: () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
},
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
@@ -227,6 +321,35 @@ function execViaPty(ptyStream, command, options) {
});
}
// Stream close/error detection — resolve immediately instead of waiting for timeout
if (typeof ptyStream.on === "function") {
const onClose = () => finish(output, null, "Stream closed unexpectedly");
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
try { ptyStream.removeListener("error", onError); } catch { /* */ }
});
}
// node-pty uses onExit instead of close/end
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
}
// AbortSignal handling — send Ctrl+C and resolve when aborted
if (abortSignal) {
const onAbort = () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
@@ -244,6 +367,7 @@ function execViaChannel(sshClient, command, options) {
const {
timeoutMs = 60000,
trackForCancellation = null,
chatSessionId,
} = options || {};
return new Promise((resolve) => {
@@ -276,6 +400,11 @@ function execViaChannel(sshClient, command, options) {
}, timeoutMs);
if (trackForCancellation) {
trackForCancellation.set(marker, {
chatSessionId: chatSessionId || null,
cancel: () => {
try { execStream.close(); } catch { /* ignore */ }
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" });
},
cleanup: () => {
clearTimeout(timeoutId);
try { execStream.close(); } catch { /* ignore */ }
@@ -296,9 +425,209 @@ function execViaChannel(sshClient, command, options) {
});
}
/**
* Execute command on a raw serial port (no shell wrapping).
*
* Used for network devices (Cisco IOS, Huawei VRP, etc.) and embedded systems
* that do not run a standard POSIX/PowerShell/CMD shell.
*
* The command is sent as-is followed by CR. Completion is detected via idle
* timeout (no new data for `idleMs` milliseconds). The idle timer does NOT
* start until the first data chunk arrives, so slow devices won't time out
* before producing any output.
*
* Exit code is always `null` because vendor CLIs do not expose exit codes.
*
* @param {object} serialPort - The SerialPort instance with .write() and .on("data")
* @param {string} command - The raw command to send
* @param {object} [options]
* @param {number} [options.timeoutMs=60000] - Overall timeout
* @param {number} [options.idleMs=3000] - Idle timeout to detect command completion
* @param {Map} [options.trackForCancellation] - Map for cancellation tracking
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
*/
function execViaRawPty(serialPort, command, options) {
const {
timeoutMs = 60000,
idleMs = 3000,
trackForCancellation = null,
chatSessionId,
abortSignal,
} = options || {};
// Simple incrementing key for the cancellation map (no markers sent to device)
const cancelKey = `__NCRAW_${Date.now().toString(36)}_${(++execViaRawPty._seq).toString(36)}`;
if (abortSignal?.aborted) {
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: null, error: "Cancelled" });
}
return new Promise((resolve) => {
let output = "";
let finished = false;
let overallTimer = null;
let idleTimer = null;
const cleanupFns = [];
function safeWrite(data) {
try {
if (typeof serialPort.write === "function") serialPort.write(data);
} catch { /* serial port may already be closed */ }
}
// finish signature differs from execViaPty intentionally: no exitCode param
// because vendor CLIs have no exit code concept (always null).
function finish(stdout, error) {
if (finished) return;
finished = true;
clearTimeout(overallTimer);
clearTimeout(idleTimer);
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
if (trackForCancellation) {
trackForCancellation.delete(cancelKey);
}
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
// Strip echoed command from the beginning of output.
// Network devices typically echo back the typed command on the first line,
// often prefixed by the device prompt (e.g. "Router#show version").
// Only strip when the first line is a close match to avoid removing
// legitimate output on devices that don't echo.
const lines = cleaned.split("\n");
if (lines.length > 1) {
const firstLine = lines[0].trim();
const cmdTrimmed = command.trim();
if (cmdTrimmed && (firstLine === cmdTrimmed || firstLine.endsWith(cmdTrimmed))) {
lines.shift();
}
}
cleaned = lines.join("\n").trim();
if (error) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: null, error });
} else {
resolve({ ok: true, stdout: cleaned, stderr: "", exitCode: null });
}
}
// Track data chunks to distinguish echo phase from real output.
// The first 1-2 chunks are typically the echoed command + prompt.
// Use a longer idle timeout during this phase so that commands like
// ping/traceroute/copy that stay quiet after the echo aren't truncated.
let chunkCount = 0;
const ECHO_PHASE_CHUNKS = 2;
function resetIdleTimer() {
clearTimeout(idleTimer);
// During echo phase (first few chunks), use 2× idleMs to avoid
// truncating commands that produce output after a delay.
const effectiveIdle = chunkCount <= ECHO_PHASE_CHUNKS ? idleMs * 2 : idleMs;
idleTimer = setTimeout(() => {
finish(output, null);
}, effectiveIdle);
}
let noResponseTimer = null;
// Cap output to prevent unbounded accumulation on noisy serial consoles
// (e.g. devices that continuously emit syslog/debug messages). Once the cap
// is reached, stop resetting the idle timer so the function can resolve.
const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB
const onData = (data) => {
// Use latin1 to match the terminal display decoder in terminalBridge.cjs.
const chunk = data.toString("latin1");
chunkCount++;
// Cancel the no-response fallback on first data
if (noResponseTimer) {
clearTimeout(noResponseTimer);
noResponseTimer = null;
}
if (output.length < MAX_OUTPUT_BYTES) {
output += chunk;
// Only reset idle timer while accumulating — once capped, let it fire
// so noisy sessions don't hang until the overall timeout.
resetIdleTimer();
}
};
// Subscribe to serial port data
if (typeof serialPort.on === "function") {
serialPort.on("data", onData);
cleanupFns.push(() => {
try { serialPort.removeListener("data", onData); } catch { /* ignore */ }
});
// Error / close detection
const onError = (err) => finish(output, `Serial port error: ${err?.message || err}`);
const onClose = () => finish(output, "Serial port closed unexpectedly");
serialPort.on("error", onError);
serialPort.on("close", onClose);
cleanupFns.push(() => {
try { serialPort.removeListener("error", onError); } catch { /* */ }
try { serialPort.removeListener("close", onClose); } catch { /* */ }
});
}
// Overall timeout
overallTimer = setTimeout(() => {
safeWrite("\x03");
const timeoutSec = Math.round(timeoutMs / 1000);
finish(output, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
// Cancellation tracking
if (trackForCancellation) {
trackForCancellation.set(cancelKey, {
chatSessionId: chatSessionId || null,
cancel: () => {
safeWrite("\x03");
finish(output, "Cancelled");
},
cleanup: () => {
clearTimeout(overallTimer);
clearTimeout(idleTimer);
},
});
}
// AbortSignal handling
if (abortSignal) {
const onAbort = () => {
safeWrite("\x03");
finish(output, "Cancelled");
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
// Send the raw command followed by CR (network devices expect \r).
safeWrite(command + "\r");
// Start a "no-response" fallback timer. If the device produces no output at
// all (e.g. silent mode-changing commands like "enable", "configure terminal",
// or devices with echo disabled), the idle timer never starts because onData
// never fires. This fallback resolves successfully to avoid waiting for the
// full overall timeout. Uses min(idleMs * 4, timeoutMs / 4) to balance between
// not waiting too long for silent commands and not truncating slow operations.
// Cleared on first data in onData.
const noResponseMs = Math.min(idleMs * 4, Math.floor(timeoutMs / 4));
noResponseTimer = setTimeout(() => {
// Resolve with ok:true but include a hint that no output was received,
// so the AI knows the command may still be running or produced no output.
finish(output || "(no output received — command may have completed silently or may still be running)", null);
}, noResponseMs);
cleanupFns.push(() => clearTimeout(noResponseTimer));
});
}
execViaRawPty._seq = 0;
module.exports = {
execViaPty,
execViaChannel,
execViaRawPty,
detectShellKind,
stripAnsi,
};

View File

@@ -16,6 +16,7 @@ const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
const MAX_PROMPT_TRACK_TAIL = 4096;
// ── ANSI stripping ──
@@ -23,6 +24,36 @@ function stripAnsi(input) {
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
}
function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
return lastLine;
}
return "";
}
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
if (prompt) {
session.lastIdlePrompt = prompt;
session.lastIdlePromptAt = Date.now();
}
return prompt;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -271,6 +302,8 @@ function serializeStreamChunk(chunk) {
module.exports = {
stripAnsi,
extractTrailingIdlePrompt,
trackSessionIdlePrompt,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,

View File

@@ -10,7 +10,6 @@ const http = require("node:http");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const path = require("node:path");
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -60,6 +59,7 @@ const MAX_CONCURRENT_AGENTS = 5;
const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
@@ -223,14 +223,7 @@ function killTrackedProcessTree(rootPid, childPids) {
}
}
/**
* Safely send an IPC message to a renderer, guarding against destroyed senders.
*/
function safeSend(sender, channel, ...args) {
if (sender && !sender.isDestroyed()) {
sender.send(channel, ...args);
}
}
const { safeSend } = require("./ipcUtils.cjs");
function init(deps) {
sessions = deps.sessions;
@@ -881,7 +874,7 @@ function registerHandlers(ipcMain) {
});
// Execute a command on a terminal session (for Catty Agent)
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command }) => {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
@@ -890,17 +883,20 @@ function registerHandlers(ipcMain) {
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
}
// Check command against safety blocklist before executing
const safety = mcpServerBridge.checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) {
return { ok: false, error: "Session not found" };
}
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
// disables an interface on Cisco). Skip for serial sessions.
if (session.protocol !== "serial") {
const safety = mcpServerBridge.checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
try {
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
@@ -915,8 +911,11 @@ function registerHandlers(ipcMain) {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
timeoutMs,
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
});
}
@@ -925,7 +924,22 @@ function registerHandlers(ipcMain) {
if (sshClient && typeof sshClient.exec === "function") {
const { execViaChannel } = require("./ai/ptyExec.cjs");
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaChannel(sshClient, command, { timeoutMs: channelTimeoutMs });
return execViaChannel(sshClient, command, {
timeoutMs: channelTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaRawPty(session.serialPort, command, {
timeoutMs: serialTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
}
return { ok: false, error: "No terminal stream or SSH client available for this session" };
@@ -934,43 +948,13 @@ function registerHandlers(ipcMain) {
}
});
// Write to terminal session (send input like a user typing)
ipcMain.handle("netcatty:ai:terminal:write", async (event, { sessionId, data }) => {
// Validate IPC sender (Issue #17)
// Cancel in-flight Catty Agent command executions for a chat session
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
// Block writes in observer mode (Issue #11)
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Terminal write blocked: permission mode is 'observer'" };
}
// Check input against safety blocklist before writing
const safety = mcpServerBridge.checkCommandSafety(data);
if (safety.blocked) {
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) {
return { ok: false, error: "Session not found" };
}
try {
if (session.stream) {
session.stream.write(data);
return { ok: true };
}
if (session.pty) {
session.pty.write(data);
return { ok: true };
}
if (session.proc) {
session.proc.write(data);
return { ok: true };
}
return { ok: false, error: "No writable stream for session" };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
return { ok: true };
});
async function runCommand(command, args, options) {
@@ -1715,11 +1699,39 @@ function registerHandlers(ipcMain) {
}
let abortController = null;
try {
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
existingController.abort();
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
}
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
abortController = new AbortController();
acpActiveStreams.set(requestId, abortController);
acpRequestSessions.set(requestId, chatSessionId);
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
const consumePendingStartupCancel = () => {
if (!acpPendingCancelRequests.has(requestId)) return false;
acpPendingCancelRequests.delete(requestId);
abortController?.abort();
return true;
};
const shouldAbortStartup = () =>
Boolean(abortController?.signal?.aborted || consumePendingStartupCancel());
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const { streamText, stepCountIs } = require("ai");
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
@@ -1730,6 +1742,7 @@ function registerHandlers(ipcMain) {
if (isCodexAgent && !apiKey) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (shouldAbortStartup()) return { ok: true };
if (!validation.ok) {
if (isCodexAuthError(validation)) {
try {
@@ -1752,6 +1765,7 @@ function registerHandlers(ipcMain) {
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
if (shouldAbortStartup()) return { ok: true };
// Inject Netcatty MCP server for scoped terminal-session access
try {
@@ -1762,23 +1776,12 @@ function registerHandlers(ipcMain) {
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
if (shouldAbortStartup()) return { ok: true };
// Recalculate fingerprint after injection
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
const currentPermissionMode = mcpServerBridge.getPermissionMode();
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
existingController.abort();
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
}
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
@@ -1841,6 +1844,7 @@ function registerHandlers(ipcMain) {
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
@@ -1882,6 +1886,7 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
if (activeProviderSessionId) {
@@ -1891,11 +1896,6 @@ function registerHandlers(ipcMain) {
});
}
abortController = new AbortController();
acpActiveStreams.set(requestId, abortController);
acpRequestSessions.set(requestId, chatSessionId);
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
const contextualPrompt =
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
@@ -1903,8 +1903,7 @@ function registerHandlers(ipcMain) {
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
`Call get_environment first to discover available sessions and their IDs. ` +
`For normal shell commands, use terminal_execute so you receive command output. ` +
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
`SFTP file tools only work for remote SSH sessions, not local terminals.]\n\n${prompt}`;
`For serial/raw sessions (network devices), commands are sent as-is without shell wrapping and exit codes are unavailable.]\n\n${prompt}`;
// Build message content: text + optional attachments
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
@@ -2055,6 +2054,7 @@ function registerHandlers(ipcMain) {
} finally {
acpActiveStreams.delete(requestId);
acpRequestSessions.delete(requestId);
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
@@ -2069,20 +2069,24 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
// Cancel any active PTY executions (send Ctrl+C)
mcpServerBridge.cancelAllPtyExecs();
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
const effectiveRequestId = requestId || activeRun?.requestId || "";
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
if (activeRun && activeRun.requestId === requestId) {
if (activeRun && activeRun.requestId === effectiveRequestId) {
activeRun.cancelRequested = true;
}
const controller = acpActiveStreams.get(requestId);
const controller = acpActiveStreams.get(effectiveRequestId);
let cancelled = false;
if (controller) {
controller.abort();
acpActiveStreams.delete(requestId);
acpActiveStreams.delete(effectiveRequestId);
cancelled = true;
} else if (effectiveRequestId) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
@@ -2093,7 +2097,7 @@ function registerHandlers(ipcMain) {
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
if (effectiveChatSessionId) cancelled = true;
acpRequestSessions.delete(requestId);
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
});

View File

@@ -283,6 +283,17 @@ function registerHandlers(ipcMain) {
return { available: false, supported: true, checking: true };
}
// If a download is already in progress or the update is ready to install,
// skip the check entirely — calling checkForUpdates() while downloading
// can cause electron-updater to error, which corrupts the download state
// and forces the user to download manually (GitHub issue #522).
if (_isDownloading) {
return { available: true, supported: true, downloading: true, version: _lastStatus.version };
}
if (_lastStatus.status === 'ready') {
return { available: true, supported: true, ready: true, version: _lastStatus.version };
}
try {
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
@@ -324,16 +335,22 @@ function registerHandlers(ipcMain) {
// ---- Download update ---------------------------------------------------
ipcMain.handle("netcatty:update:download", async () => {
if (_isDownloading) {
return { success: true };
}
const updater = getAutoUpdater();
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
// Global listeners (registered in setupGlobalListeners) handle all
// progress/downloaded/error events. Just trigger the download.
_isDownloading = true;
_lastStatus = { ..._lastStatus, status: 'downloading', percent: 0, error: null };
await updater.downloadUpdate();
return { success: true };
} catch (err) {
_isDownloading = false;
_lastStatus = { ..._lastStatus, status: 'error', error: err?.message || "Download failed", percent: 0 };
// Don't broadcast here — the global updater "error" listener already handles it
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}

View File

@@ -551,6 +551,4 @@ function registerHandlers(ipcMain) {
module.exports = {
init,
registerHandlers,
checkTarAvailable,
checkRemoteTarAvailable,
};

View File

@@ -0,0 +1,326 @@
/**
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
*
* Log files are stored as JSONL (one JSON object per line) under
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
* atomic. Files older than 30 days are pruned on startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let logDir = null;
let electronApp = null;
let electronShell = null;
let sessionsMap = null;
const LOG_RETENTION_DAYS = 30;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ensureLogDir() {
if (logDir) return logDir;
try {
// Try the stored app reference first, then fall back to requiring electron
// directly so crash logging works even before init() is called.
let userDataPath = null;
if (electronApp) {
userDataPath = electronApp.getPath("userData");
} else {
try {
const { app } = require("node:electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
try {
const { app } = require("electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
// Electron not available yet
}
}
}
if (!userDataPath) return null;
logDir = path.join(userDataPath, "crash-logs");
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
return logDir;
} catch {
return null;
}
}
function todayFileName() {
const d = new Date();
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return `crash-${ymd}.log`;
}
function buildEntry(source, err, extra) {
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
let mem;
try {
const m = process.memoryUsage();
mem = {
rss: Math.round(m.rss / 1048576),
heapUsed: Math.round(m.heapUsed / 1048576),
heapTotal: Math.round(m.heapTotal / 1048576),
};
} catch {
// ignore
}
// Extract extra properties from the error object (code, errno, syscall, etc.)
const errorMeta = {};
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
if (error[key] !== undefined) {
errorMeta[key] = error[key];
}
}
return {
timestamp: new Date().toISOString(),
source,
message: error.message || String(err),
stack: error.stack || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
platform: process.platform,
arch: process.arch,
version: electronApp?.getVersion?.() ?? "unknown",
electronVersion: process.versions?.electron ?? "unknown",
osVersion: os.release(),
memoryMB: mem,
activeSessionCount: sessionsMap?.size ?? -1,
uptimeSeconds: Math.round(process.uptime()),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Write a crash/error entry to today's log file (sync, safe for use in
* uncaughtException handlers).
*/
function captureError(source, err, extra) {
try {
const dir = ensureLogDir();
if (!dir) return;
const entry = buildEntry(source, err, extra);
const filePath = path.join(dir, todayFileName());
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
} catch {
// Never throw from the crash logger itself.
}
}
/**
* Delete log files older than LOG_RETENTION_DAYS.
*/
function pruneOldLogs() {
try {
const dir = ensureLogDir();
if (!dir) return;
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[CrashLog] Pruned old log: ${file}`);
}
} catch {
// skip
}
}
} catch {
// skip
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
/**
* Count newlines in a file by streaming instead of reading entire content.
*/
async function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
stream.on("data", (chunk) => {
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] === "\n") count++;
}
});
stream.on("end", () => resolve(count));
stream.on("error", () => resolve(0));
});
}
async function listLogs() {
const dir = ensureLogDir();
if (!dir) return [];
try {
const files = await fs.promises.readdir(dir);
const results = [];
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = await fs.promises.stat(filePath);
const entryCount = await countLines(filePath);
results.push({
fileName: file,
date: file.replace("crash-", "").replace(".log", ""),
size: stat.size,
entryCount,
});
} catch {
// skip unreadable files
}
}
// Sort newest first
results.sort((a, b) => b.date.localeCompare(a.date));
return results;
} catch {
return [];
}
}
const MAX_READ_ENTRIES = 500;
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
const MAX_TAIL_BYTES = 256 * 1024;
async function readLog(fileName) {
const dir = ensureLogDir();
if (!dir) return [];
// Validate fileName to prevent path traversal
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
try {
const filePath = path.join(dir, fileName);
const stat = await fs.promises.stat(filePath);
let content;
if (stat.size > MAX_TAIL_BYTES) {
// Only read the tail of the file
const buf = Buffer.alloc(MAX_TAIL_BYTES);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
} finally {
await fd.close();
}
const raw = buf.toString("utf-8");
// Drop the first partial line
const firstNewline = raw.indexOf("\n");
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
} else {
content = await fs.promises.readFile(filePath, "utf-8");
}
const lines = content.split("\n").filter(Boolean);
// Only parse the last MAX_READ_ENTRIES lines
const tail = lines.slice(-MAX_READ_ENTRIES);
const entries = [];
for (const line of tail) {
try {
entries.push(JSON.parse(line));
} catch {
// skip malformed lines
}
}
return entries;
} catch {
return [];
}
}
async function clearLogs() {
const dir = ensureLogDir();
if (!dir) return { deletedCount: 0 };
let deletedCount = 0;
try {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
await fs.promises.unlink(path.join(dir, file));
deletedCount++;
} catch {
// skip
}
}
} catch {
// skip
}
return { deletedCount };
}
async function openDir() {
const dir = ensureLogDir();
if (!dir || !electronShell?.openPath) return { success: false };
try {
const errorMessage = await electronShell.openPath(dir);
// shell.openPath resolves to an error string on failure, empty string on success
return { success: !errorMessage };
} catch {
return { success: false };
}
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function init(deps) {
const { electronModule, sessions } = deps;
const { app, shell } = electronModule || {};
electronApp = app;
electronShell = shell;
sessionsMap = sessions || null;
ensureLogDir();
pruneOldLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
}
module.exports = {
init,
captureError,
registerHandlers,
};

View File

@@ -380,10 +380,5 @@ function cleanup() {
module.exports = {
init,
registerHandlers,
startWatching,
stopWatching,
stopWatchersForSession,
listWatchers,
registerTempFile,
cleanup,
};

View File

@@ -726,14 +726,6 @@ function cleanup() {
module.exports = {
init,
registerHandlers,
registerGlobalHotkey,
unregisterGlobalHotkey,
setCloseToTray,
isCloseToTrayEnabled,
handleWindowClose,
toggleWindowVisibility,
getHotkeyStatus,
setTrayMenuData,
updateTrayMenu,
cleanup,
};

View File

@@ -0,0 +1,20 @@
/**
* Shared IPC utilities for bridge modules.
*/
/**
* Safely send an IPC message to a renderer, guarding against destroyed senders.
* @param {Electron.WebContents} sender
* @param {string} channel
* @param {...unknown} args
*/
function safeSend(sender, channel, ...args) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, ...args);
} catch {
// Ignore destroyed webContents during shutdown / HMR reload.
}
}
module.exports = { safeSend };

View File

@@ -2,8 +2,7 @@
* MCP Server Bridge — TCP host in Electron main process
*
* Starts a local TCP server that the netcatty-mcp-server.cjs child process
* connects to. Handles JSON-RPC calls by dispatching to real SSH sessions
* and SFTP clients.
* connects to. Handles JSON-RPC calls by dispatching to real terminal sessions.
*/
"use strict";
@@ -13,10 +12,9 @@ const path = require("node:path");
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
const { execViaPty, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
let sftpClients = null; // Map<sftpId, SFTPWrapper>
let tcpServer = null;
let tcpPort = null;
let authToken = null; // Random token generated when TCP server starts
@@ -24,14 +22,6 @@ let authToken = null; // Random token generated when TCP server starts
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
/**
* Safely quote a string for use in a POSIX shell command.
* Wraps the value in single quotes and escapes any embedded single quotes.
*/
function shellQuote(s) {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
// Each chat session only sees the hosts registered for its scope.
const scopedMetadata = new Map();
@@ -145,19 +135,32 @@ function clearPendingApprovals(chatSessionId) {
function cancelAllPtyExecs() {
for (const [marker, entry] of activePtyExecs) {
try {
entry.cleanup();
// Send Ctrl+C to kill the running command
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
entry.ptyStream.write("\x03");
}
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
activePtyExecs.clear();
}
/**
* Cancel PTY executions scoped to a specific chat session.
* Only affects entries whose chatSessionId matches.
*/
function cancelPtyExecsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const [marker, entry] of activePtyExecs) {
if (entry.chatSessionId !== chatSessionId) continue;
try {
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
}
function init(deps) {
sessions = deps.sessions;
sftpClients = deps.sftpClients;
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
@@ -276,38 +279,9 @@ function getSessionMeta(sessionId, chatSessionId) {
return null;
}
function sessionSupportsSftp(session) {
const sshClient = session?.conn || session?.sshClient;
return !!(sshClient && typeof sshClient.exec === "function");
}
function scopeHasSftpSessions(sessionIds) {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return false;
for (const sessionId of sessionIds) {
const session = sessions?.get(sessionId);
if (sessionSupportsSftp(session)) return true;
}
return false;
}
/**
* Run an array of async task factories with a concurrency limit.
*/
async function limitConcurrency(tasks, limit) {
const results = [];
const executing = new Set();
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const p = task().then(r => { results[i] = r; }).finally(() => executing.delete(p));
executing.add(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
function checkCommandSafety(command) {
for (let i = 0; i < compiledBlocklist.length; i++) {
const re = compiledBlocklist[i];
@@ -424,12 +398,6 @@ async function handleMessage(socket, line) {
// Methods that modify remote state — blocked in observer mode
const WRITE_METHODS = new Set([
"netcatty/exec",
"netcatty/terminalWrite",
"netcatty/sftpWrite",
"netcatty/sftpMkdir",
"netcatty/sftpRemove",
"netcatty/sftpRename",
"netcatty/multiExec",
]);
/**
@@ -469,37 +437,11 @@ async function dispatch(method, params) {
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
if (scopeErr) return { ok: false, error: scopeErr };
}
// For multi-exec, validate all session IDs
if (method === "netcatty/multiExec" && Array.isArray(params?.sessionIds)) {
for (const sid of params.sessionIds) {
const scopeErr = validateSessionScope(sid, params?.chatSessionId);
if (scopeErr) return { ok: false, error: scopeErr };
}
}
switch (method) {
case "netcatty/getContext":
return handleGetContext(params);
case "netcatty/exec":
return handleExec(params);
case "netcatty/terminalWrite":
return handleTerminalWrite(params);
case "netcatty/sftpList":
return handleSftpList(params);
case "netcatty/sftpRead":
return handleSftpRead(params);
case "netcatty/sftpWrite":
return handleSftpWrite(params);
case "netcatty/sftpMkdir":
return handleSftpMkdir(params);
case "netcatty/sftpRemove":
return handleSftpRemove(params);
case "netcatty/sftpRename":
return handleSftpRename(params);
case "netcatty/sftpStat":
return handleSftpStat(params);
case "netcatty/multiExec":
return handleMultiExec(params);
default:
throw new Error(`Unknown method: ${method}`);
}
@@ -536,7 +478,8 @@ function handleGetContext(params) {
const sshClient = session.conn || session.sshClient;
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
if (!hasCommandablePty && !hasSshExec) continue;
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
@@ -548,17 +491,16 @@ function handleGetContext(params) {
username: meta.username || session.username || "",
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
supportsSftp: sessionSupportsSftp(session),
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream),
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
});
}
return {
environment: "netcatty-terminal",
description: "You are operating inside Netcatty, a multi-session terminal manager. " +
"The available sessions may be remote hosts, local terminals, or Mosh-backed shells. " +
"The available sessions may be remote hosts, local terminals, Mosh-backed shells, or serial port connections (network devices, embedded systems). " +
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
"SFTP tools only work for remote SSH sessions. " +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"Always prefer these tools over suggesting the user to do things manually.",
hosts,
hostCount: hosts.length,
@@ -574,14 +516,27 @@ function handleExec(params) {
return { ok: false, error: 'Invalid command', exitCode: 1 };
}
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
// are meaningless on network device CLIs. Serial sessions skip the check because
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
//
// Design note: the serial protocol is explicitly chosen by the user in the UI
// for network devices / embedded systems. While startSerialSession technically
// supports PTY devices, users connecting to a Linux/BusyBox shell should use
// the "local" protocol (which goes through the normal shell path with blocklist).
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
// actually be interpreted even if sent to a serial-connected shell.
if (session.protocol !== "serial") {
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
ok: false,
@@ -598,291 +553,29 @@ function handleExec(params) {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
expectedPrompt: session.lastIdlePrompt || "",
});
}
// If no PTY stream, fall back to exec channel for SSH sessions only.
if (!sshClient || typeof sshClient.exec !== "function") {
return { ok: false, error: "Session does not support command execution" };
}
if (!ptyStream || typeof ptyStream.write !== "function") {
// Fallback: SSH exec channel (invisible to terminal).
// At this point ptyStream is not writable (already returned above if it was).
if (sshClient && typeof sshClient.exec === "function") {
return execViaChannel(sshClient, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
});
}
}
// ── Handler: terminalWrite ──
function handleTerminalWrite(params) {
const { sessionId, input } = params;
if (!sessionId || input == null) throw new Error("sessionId and input are required");
// Validate input against command blocklist
const safety = checkCommandSafety(input);
if (safety.blocked) {
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
if (session.stream) {
session.stream.write(input);
return { ok: true };
}
if (session.pty) {
session.pty.write(input);
return { ok: true };
}
if (session.proc) {
session.proc.write(input);
return { ok: true };
}
return { ok: false, error: "No writable stream" };
}
// ── SFTP Helpers ──
function findSftpForSession(sessionId) {
// Try to find an SFTP client keyed by the same sessionId
if (sftpClients?.has(sessionId)) {
return sftpClients.get(sessionId);
}
// Look through all SFTP clients for one sharing the same SSH connection
const session = sessions?.get(sessionId);
if (!session?.sshClient) return null;
for (const [, client] of sftpClients || []) {
if (client.client === session.sshClient || client._sshClient === session.sshClient) {
return client;
}
}
return null;
}
// ── Handler: sftpList ──
async function handleSftpList(params) {
const { sessionId, path: dirPath } = params;
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
const list = await sftpClient.list(dirPath);
return {
files: list.map(f => ({
name: f.name,
type: f.type === "d" ? "directory" : f.type === "l" ? "symlink" : "file",
size: f.size,
lastModified: f.modifyTime,
permissions: f.rights ? `${f.rights.user}${f.rights.group}${f.rights.other}` : undefined,
})),
};
} catch (err) {
return { ok: false, error: err.message };
}
}
// Fallback: use SSH exec
const result = await handleExec({ sessionId, command: `ls -la ${shellQuote(dirPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { output: result.stdout || "(empty directory)" };
}
// ── Handler: sftpRead ──
async function handleSftpRead(params) {
const { sessionId, path: filePath } = params;
if (params.maxBytes != null && (typeof params.maxBytes !== 'number' || params.maxBytes < 1 || params.maxBytes > 10 * 1024 * 1024)) {
return { ok: false, error: 'maxBytes must be a positive number between 1 and 10485760' };
}
// Clamp maxBytes to a safe upper bound (10MB)
const maxBytes = Math.max(1, Math.min(Number(params.maxBytes) || 10000, 10 * 1024 * 1024));
if (!sessionId || !filePath) throw new Error("sessionId and path are required");
// Fallback to SSH exec (more reliable across SFTP client states)
const result = await handleExec({ sessionId, command: `head -c ${maxBytes} ${shellQuote(filePath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { content: result.stdout || "(empty file)" };
}
// ── Handler: sftpWrite ──
async function handleSftpWrite(params) {
const { sessionId, path: filePath, content } = params;
if (!sessionId || !filePath || content == null) throw new Error("sessionId, path and content are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.put(Buffer.from(content, "utf-8"), filePath);
return { written: filePath };
} catch {
// Fallback to SSH
}
}
// Use base64 encoding to avoid heredoc delimiter collision issues
const b64 = Buffer.from(content, "utf-8").toString("base64");
const result = await handleExec({ sessionId, command: `echo ${shellQuote(b64)} | base64 -d > ${shellQuote(filePath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { written: filePath };
}
// ── Handler: sftpMkdir ──
async function handleSftpMkdir(params) {
const { sessionId, path: dirPath } = params;
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.mkdir(dirPath, true); // recursive
return { created: dirPath };
} catch {
// Fallback
}
}
const result = await handleExec({ sessionId, command: `mkdir -p ${shellQuote(dirPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { created: dirPath };
}
// ── Handler: sftpRemove ──
// Critical paths that must never be removed (module-level constant)
const CRITICAL_PATHS = new Set([
"/", "/root", "/home", "/etc", "/var", "/usr", "/boot",
"/bin", "/sbin", "/lib", "/lib64", "/dev", "/proc", "/sys", "/tmp", "/opt",
]);
async function handleSftpRemove(params) {
const { sessionId, path: targetPath } = params;
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
// Guard against deleting root or critical system directories
// Normalize to resolve "..", "//", and trailing slashes before checking
const normalizedPath = path.posix.normalize(targetPath).replace(/\/+$/, "") || "/";
if (CRITICAL_PATHS.has(normalizedPath) || /^\/[^/]+$/.test(normalizedPath)) {
return { ok: false, error: `Refusing to remove critical or root-level path: ${targetPath}` };
}
// Use rm -r (without -f) so permission errors surface instead of being silently ignored
const result = await handleExec({ sessionId, command: `rm -r ${shellQuote(targetPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { removed: targetPath };
}
// ── Handler: sftpRename ──
async function handleSftpRename(params) {
const { sessionId, oldPath, newPath } = params;
if (!sessionId || !oldPath || !newPath) throw new Error("sessionId, oldPath and newPath are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.rename(oldPath, newPath);
return { renamed: `${oldPath}${newPath}` };
} catch {
// Fallback
}
}
const result = await handleExec({ sessionId, command: `mv ${shellQuote(oldPath)} ${shellQuote(newPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { renamed: `${oldPath}${newPath}` };
}
// ── Handler: sftpStat ──
async function handleSftpStat(params) {
const { sessionId, path: targetPath } = params;
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
const stat = await sftpClient.stat(targetPath);
return {
name: path.basename(targetPath),
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
size: stat.size,
lastModified: stat.modifyTime,
permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined,
};
} catch {
// Fallback
}
}
// Fallback: use stat command
const result = await handleExec({ sessionId, command: `stat -c '{"size":%s,"mode":"%a","mtime":%Y,"type":"%F"}' ${shellQuote(targetPath)}` });
if (!result.ok) return { ok: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout.trim());
return {
name: path.basename(targetPath),
type: parsed.type?.includes("directory") ? "directory" : "file",
size: parsed.size,
lastModified: parsed.mtime * 1000,
permissions: parsed.mode,
};
} catch {
return { ok: false, error: "Failed to parse stat output" };
}
}
// ── Handler: multiExec ──
async function handleMultiExec(params) {
const { sessionIds, command, mode = "parallel", stopOnError = false } = params;
if (!Array.isArray(sessionIds) || !command) throw new Error("sessionIds and command are required");
if (sessionIds.length > 50) {
return { ok: false, error: 'Too many session IDs: maximum is 50' };
}
if (typeof command !== 'string' || !command.trim()) {
return { ok: false, error: 'Invalid command' };
}
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const results = {};
if (mode === "sequential") {
for (const sid of sessionIds) {
const result = await handleExec({ sessionId: sid, command });
results[sid] = {
ok: result.ok,
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
};
if (!result.ok && stopOnError) break;
}
} else {
// Parallel execution with concurrency limit
const tasks = sessionIds.map((sid) => () => {
return Promise.resolve(handleExec({ sessionId: sid, command })).then(result => ({
sid,
ok: result.ok,
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
}));
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
return execViaRawPty(session.serialPort, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
});
const resolved = await limitConcurrency(tasks, 10);
for (const r of resolved) {
results[r.sid] = { ok: r.ok, output: r.output };
}
}
return { results };
return { ok: false, error: "Session does not support command execution" };
}
// ── MCP Server Config Builder ──
@@ -916,11 +609,6 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
}
env.push({
name: "NETCATTY_MCP_ENABLE_SFTP",
value: scopeHasSftpSessions(effectiveIds) ? "1" : "0",
});
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
@@ -966,7 +654,9 @@ module.exports = {
getScopedSessionIds,
getOrCreateHost,
buildMcpServerConfig,
activePtyExecs,
cancelAllPtyExecs,
cancelPtyExecsForSession,
cleanupScopedMetadata,
cleanup,
setMainWindowGetter,

View File

@@ -3,31 +3,40 @@
* Extracted from main.cjs for single responsibility
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { connectThroughChain } = require("./sshBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
isKeyEncrypted,
} = require("./sshAuthHelper.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
function cleanupChainConnections(connections) {
if (!Array.isArray(connections)) return;
for (const chainConn of connections) {
try { chainConn.end(); } catch { /* ignore */ }
}
}
function isTunnelCancelled(tunnelState) {
return Boolean(tunnelState?.cancelled);
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Start a port forwarding tunnel
*/
@@ -44,11 +53,30 @@ async function startPortForward(event, payload) {
username,
password,
privateKey,
certificate,
keyId,
passphrase,
proxy,
jumpHosts = [],
identityFilePaths,
} = payload;
const conn = new SSHClient();
const sender = event.sender;
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!proxy;
let chainConnections = [];
let connectionSocket = null;
const tunnelState = {
type,
conn,
pendingConn: null,
server: null,
chainConnections,
status: 'connecting',
webContentsId: sender.id,
cancelled: false,
};
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
@@ -66,9 +94,53 @@ async function startPortForward(event, payload) {
tryKeyboard: true,
};
if (privateKey) {
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
if (hasCertificate) {
connectOpts.agent = new NetcattyAgent({
mode: "certificate",
webContents: sender,
meta: {
label: keyId || username || "",
certificate,
privateKey,
passphrase,
},
});
} else if (privateKey) {
connectOpts.privateKey = privateKey;
}
// Read identity files from local paths (e.g. SSH config IdentityFile)
// when no explicit key/certificate was already configured.
if (!connectOpts.privateKey && !connectOpts.agent && identityFilePaths?.length > 0) {
for (const keyPath of identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connectOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
hostname,
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
continue;
}
}
break;
} catch (err) {
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
@@ -76,19 +148,101 @@ async function startPortForward(event, payload) {
connectOpts.password = password;
}
// Get default keys
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
sendStatus('connecting');
portForwardingTunnels.set(tunnelId, tunnelState);
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey,
password,
passphrase,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
let defaultKeys = [];
try {
// Get default keys
defaultKeys = await findAllDefaultPrivateKeysFromHelper();
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
{
hostname,
port,
username,
password,
privateKey,
passphrase,
proxy,
jumpHosts,
_defaultKeys: defaultKeys,
_connectionsRef: chainConnections,
_tunnelRef: tunnelState,
},
jumpHosts,
hostname,
port,
tunnelId,
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
tunnelState.chainConnections = chainConnections;
if (isTunnelCancelled(tunnelState)) {
cleanupChainConnections(chainConnections);
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else if (hasProxy) {
connectionSocket = await createProxySocket(proxy, hostname, port, {
onSocket: (socket) => {
tunnelState.pendingConn = socket;
},
});
if (isTunnelCancelled(tunnelState)) {
try { connectionSocket?.end?.(); } catch { /* ignore */ }
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.pendingConn = null;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
}
} catch (err) {
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.cancelled = true;
if (tunnelState.pendingConn) {
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
}
cleanupChainConnections(tunnelState.chainConnections);
if (connectionSocket) {
try { connectionSocket.end?.(); } catch { /* ignore */ }
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
}
portForwardingTunnels.delete(tunnelId);
sendStatus('error', err?.message || String(err));
throw err;
}
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
@@ -133,20 +287,20 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] Server error:`, err.message);
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
server,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'local';
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -165,12 +319,14 @@ async function startPortForward(event, payload) {
}
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'remote';
tunnelState.conn = conn;
tunnelState.server = null;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -273,20 +429,20 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] SOCKS server error:`, err.message);
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
server,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'dynamic';
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -297,10 +453,11 @@ async function startPortForward(event, payload) {
}
});
conn.once('error', (err) => {
conn.on('error', (err) => {
console.error(`[PortForward] SSH error:`, err.message);
if (settled) return;
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
cleanupChainConnections(chainConnections);
settled = true;
reject(err);
});
@@ -314,6 +471,12 @@ async function startPortForward(event, payload) {
if (tunnel.server) {
try { tunnel.server.close(); } catch { }
}
if (Array.isArray(tunnel.chainConnections)) {
cleanupChainConnections(tunnel.chainConnections);
}
if (tunnel.pendingConn) {
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
}
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
@@ -329,18 +492,6 @@ async function startPortForward(event, payload) {
}
});
sendStatus('connecting');
// Register the connection BEFORE the handshake starts so that
// stopPortForwardByRuleId can find and kill it at any point,
// including during the SSH handshake window. The conn.on('ready')
// handler updates the entry to include the server object later.
portForwardingTunnels.set(tunnelId, {
type,
conn,
server: null,
status: 'connecting',
webContentsId: sender.id,
});
conn.connect(connectOpts);
});
}
@@ -363,6 +514,10 @@ async function stopPortForward(event, payload) {
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.pendingConn) {
tunnel.pendingConn.end();
}
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) {
tunnel.conn.end();
}
@@ -417,6 +572,10 @@ function stopAllPortForwards() {
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.pendingConn) {
tunnel.pendingConn.end();
}
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) {
tunnel.conn.end();
}
@@ -446,6 +605,8 @@ function stopPortForwardByRuleId(_event, { ruleId }) {
// close handler resolves gracefully instead of rejecting.
tunnel.cancelled = true;
if (tunnel.server) tunnel.server.close();
if (tunnel.pendingConn) tunnel.pendingConn.end();
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) tunnel.conn.end();
// Don't delete here — let the conn.on('close') handler delete
// the entry so it can read tunnel.cancelled first.

View File

@@ -15,9 +15,12 @@ const net = require("node:net");
* @param {string} [proxy.password] - Optional password for auth
* @param {string} targetHost - Target host to connect through proxy
* @param {number} targetPort - Target port to connect through proxy
* @param {Object} [options]
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
* @returns {Promise<net.Socket>} Connected socket through proxy
*/
function createProxySocket(proxy, targetHost, targetPort) {
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
const { onSocket } = options;
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
@@ -45,6 +48,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
};
socket.on('data', onData);
});
try { onSocket?.(socket); } catch { /* ignore */ }
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
@@ -123,6 +127,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
socket.on('data', onData);
});
try { onSocket?.(socket); } catch { /* ignore */ }
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));

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