mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cbcb73b61 | ||
|
|
d3eb3007bb | ||
|
|
c16f0c43d4 | ||
|
|
4df94736d0 | ||
|
|
91c3defe66 | ||
|
|
faf74ae9be | ||
|
|
cbace2f2ac | ||
|
|
a52726aef4 | ||
|
|
605dc94b13 | ||
|
|
9e74618f2e | ||
|
|
b46910d46e | ||
|
|
d8ea77c28b | ||
|
|
210879b848 | ||
|
|
db0238ce6e | ||
|
|
6f30bbefb5 | ||
|
|
843c81ca44 | ||
|
|
f1e59913c2 | ||
|
|
a95eb62cb1 | ||
|
|
7205c40a7c | ||
|
|
7cf8ebac7e | ||
|
|
48c721bf43 | ||
|
|
756f332db5 | ||
|
|
2ee55d5b4b | ||
|
|
2e588630a1 | ||
|
|
e9cc0d54d2 | ||
|
|
57cd40a192 | ||
|
|
e5dc30ba2f | ||
|
|
e9d2963fe5 | ||
|
|
d0af56b4dd | ||
|
|
09f7fcf248 | ||
|
|
9e63ffd99e | ||
|
|
f24b5f4202 | ||
|
|
26278a8694 | ||
|
|
e10e5ccbc2 | ||
|
|
d9ca951cc9 | ||
|
|
00ec0d04c4 | ||
|
|
dd5749460f | ||
|
|
00c9cf440f | ||
|
|
6cdae7af9c | ||
|
|
3b1a4adc82 | ||
|
|
ac65d465cf | ||
|
|
688f544034 | ||
|
|
f9ad1ddfe0 | ||
|
|
a0d032ec3c | ||
|
|
e9b0dadd2a | ||
|
|
214bc8e7f1 | ||
|
|
758029bc29 | ||
|
|
3e17cdd62e | ||
|
|
8b5db49001 | ||
|
|
87f794d17d | ||
|
|
cb35353fde | ||
|
|
ce86e8e426 | ||
|
|
0714ca5ee0 | ||
|
|
131ed03ffa | ||
|
|
1fc98cd654 | ||
|
|
28f7193c4a | ||
|
|
dbdc8631dd | ||
|
|
a05e004259 | ||
|
|
cae4169948 | ||
|
|
7ec9cd652b | ||
|
|
3987e465b6 | ||
|
|
d8761c156e | ||
|
|
47bb5c2b55 | ||
|
|
dc05697ede | ||
|
|
51d4d97419 | ||
|
|
530f57c95c | ||
|
|
da0938c209 | ||
|
|
9e6846e0d5 | ||
|
|
0b96d70702 | ||
|
|
818c6fd551 |
No files matched your search
@@ -220,6 +220,30 @@ Windows リリースは未署名の場合があります。
|
||||
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### Scoop(Windows x64、コミュニティ管理)
|
||||
|
||||
[Scoop](https://scoop.sh/) をインストール済みの場合、コミュニティが管理する [lemon バケット](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) から Netcatty をインストールできます。このバケットは Netcatty の公式 GitHub Releases からインストーラーをダウンロードします:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新またはアンインストールの前に Netcatty を終了してください:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
アンインストールする場合:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
これはサードパーティ製パッケージであり、Netcatty の公式バケットではありません。更新は GitHub Releases より遅れる場合があります。現在は x64 のみに対応しています。設定は `%APPDATA%\netcatty` に保存され、更新や通常のアンインストール後も残ります。`scoop uninstall netcatty --purge` はこれらの設定も削除します。この方法でインストールした場合は、Scoop で更新を管理してください。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
|
||||
|
||||
@@ -127,6 +127,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
@@ -221,6 +222,30 @@ application and integration are complete.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
Exit Netcatty before updating or uninstalling:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
This is a third-party package, not an official Netcatty bucket, and updates may lag behind GitHub Releases. It currently supports x64 only. Settings remain in `%APPDATA%\netcatty` across updates and normal uninstalls; `scoop uninstall netcatty --purge` also deletes those settings. Use Scoop to manage updates for this installation.
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 发布文件可能仍未签名。
|
||||
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### Scoop(Windows x64,社区维护)
|
||||
|
||||
安装 [Scoop](https://scoop.sh/) 后,可以通过社区维护的 [lemon 软件源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安装 Netcatty。该软件源从 Netcatty 官方 GitHub Releases 下载安装文件:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或卸载前,请先退出 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
这是第三方软件包,并非 Netcatty 官方软件源,更新可能晚于 GitHub Releases。目前仅支持 x64。设置保存在 `%APPDATA%\netcatty`,更新和普通卸载会保留这些设置;`scoop uninstall netcatty --purge` 还会删除这些设置。通过此方式安装后,请使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 發行檔可能仍未簽章。
|
||||
|
||||
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
|
||||
|
||||
### Scoop(Windows x64,社群維護)
|
||||
|
||||
安裝 [Scoop](https://scoop.sh/) 後,可以透過社群維護的 [lemon 軟體來源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安裝 Netcatty。此軟體來源從 Netcatty 官方 GitHub Releases 下載安裝檔:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或解除安裝前,請先結束 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
解除安裝:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
這是第三方套件,並非 Netcatty 官方軟體來源,更新可能晚於 GitHub Releases。目前僅支援 x64。設定儲存在 `%APPDATA%\netcatty`,更新和一般解除安裝會保留這些設定;`scoop uninstall netcatty --purge` 也會刪除這些設定。透過此方式安裝後,請使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
|
||||
|
||||
@@ -57,7 +57,7 @@ import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import { prepareSyncPayloadApply, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -502,8 +502,8 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
prepareApply: () =>
|
||||
prepareSyncPayloadApply(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -525,14 +525,17 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
prepareApply: async () => {
|
||||
const portable = stripSyncPayloadEncryptedCredentials(payload);
|
||||
await applySyncPayload(portable, {
|
||||
const applyPreparedPayload = await prepareSyncPayloadApply(portable, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}, { currentHosts: hosts });
|
||||
await commitReplica();
|
||||
return async () => {
|
||||
await applyPreparedPayload();
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
|
||||
@@ -504,6 +504,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
|
||||
@@ -28,3 +28,35 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,23 +70,31 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -97,19 +105,21 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ test('initialization applies the protected preview before persisting and enablin
|
||||
return liveLocalPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
preparePayloadApply: async (incoming) => async () => {
|
||||
calls.push('apply');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
},
|
||||
@@ -212,7 +212,7 @@ test('initialization applies a concurrent provider merge before releasing the mi
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => currentPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
preparePayloadApply: async (incoming) => async () => {
|
||||
assert.equal(lockHeld, true);
|
||||
applied.push(incoming);
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
@@ -265,7 +265,7 @@ test('initialization rejects a stale preview before backup or apply', async () =
|
||||
buildCurrentPayload: () => changedPayload,
|
||||
buildPreApplyPayload: () => changedPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
preparePayloadApply: async () => async () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
@@ -300,7 +300,7 @@ test('blocked previews cannot enter the protected initialization transaction', a
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => localPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {},
|
||||
preparePayloadApply: async () => async () => {},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
@@ -337,7 +337,7 @@ test('a locked manager cannot enter the protected initialization transaction', a
|
||||
return localPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
preparePayloadApply: async () => async () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async () => {
|
||||
@@ -352,3 +352,56 @@ test('a locked manager cannot enter the protected initialization transaction', a
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
for (const failDuringPublish of [false, true]) {
|
||||
test(`migration header failure precedes protected writes (${failDuringPublish ? 'publish' : 'initialize'})`, async () => {
|
||||
const { prepareSyncPayloadApply } = await import('./syncPayload.ts');
|
||||
const { readInterruptedVaultApply } = await import('./localVaultBackups.ts');
|
||||
const headers = { ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] } };
|
||||
const localPayload: SyncPayload = { ...payload(), ...(failDuringPublish ? {} : { settings: headers }) };
|
||||
let currentPayload = localPayload;
|
||||
let imports = 0;
|
||||
let replicaCommits = 0;
|
||||
let snapshots = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => {
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
throw new Error('keychain locked');
|
||||
} },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload, localTrustedBaseline: null, providers: [], deviceId: 'device-a', now: NOW,
|
||||
});
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
|
||||
saveConvergentReplica: async () => { replicaCommits++; },
|
||||
saveConvergentProviderBaseline: async () => {},
|
||||
syncConvergentProvidersUnderLock: async (
|
||||
_incoming: SyncPayload,
|
||||
apply: (incoming: SyncPayload, commit: () => Promise<void>) => Promise<void>,
|
||||
) => {
|
||||
await apply({ ...currentPayload, settings: headers }, async () => { replicaCommits++; });
|
||||
return new Map();
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
await assert.rejects(initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload }, manager, now: NOW,
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => { snapshots++; return payload(); },
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
preparePayloadApply: async (incoming) => {
|
||||
const apply = await prepareSyncPayloadApply(incoming, { importVaultData: () => { imports++; } });
|
||||
return async () => {
|
||||
await apply();
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
};
|
||||
},
|
||||
}));
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
assert.equal(imports, failDuringPublish ? 1 : 0);
|
||||
assert.equal(replicaCommits, failDuringPublish ? 1 : 0);
|
||||
assert.equal(snapshots, failDuringPublish ? 1 : 0);
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
prepared: PreparedConvergentMigration;
|
||||
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
preparePayloadApply: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
manager?: CloudSyncManager;
|
||||
now?: number;
|
||||
@@ -164,8 +164,9 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
|
||||
);
|
||||
}
|
||||
const applyPreparedPayload = await options.preparePayloadApply(prepared.plan.payload as SyncPayload);
|
||||
return async () => {
|
||||
await options.applyPayload(prepared.plan.payload as SyncPayload);
|
||||
await applyPreparedPayload();
|
||||
for (const baseline of prepared.providerBaselines) {
|
||||
await manager.saveConvergentProviderBaseline(baseline);
|
||||
}
|
||||
@@ -195,8 +196,9 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
|
||||
);
|
||||
}
|
||||
const applyPreparedPayload = await options.preparePayloadApply(mergedPayload);
|
||||
return async () => {
|
||||
await options.applyPayload(mergedPayload);
|
||||
await applyPreparedPayload();
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Context compacted',
|
||||
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent activity',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -53,6 +54,15 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.headers': 'Custom HTTP Headers',
|
||||
'ai.providers.headers.help': 'Optional headers for this provider. Values are hidden and encrypted when saved.',
|
||||
'ai.providers.headers.name': 'Header name',
|
||||
'ai.providers.headers.value': 'Header value',
|
||||
'ai.providers.headers.add': 'Add header',
|
||||
'ai.providers.headers.remove': 'Remove header',
|
||||
'ai.providers.headers.invalid': 'Use valid, unique HTTP headers. Content-Length and Transfer-Encoding are managed automatically.',
|
||||
'ai.providers.headers.loadError': 'Unable to unlock saved headers. Cancel and try again after unlocking your system keychain.',
|
||||
'ai.providers.headers.saveError': 'Unable to save securely. Check that your system keychain is available and try again.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
@@ -225,20 +235,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Reasoning Effort',
|
||||
'ai.codebuddy.effort.default': 'Default',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
|
||||
'ai.codebuddy.maxTurns': 'Max Turns',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
|
||||
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
|
||||
'ai.codebuddy.sandbox': 'Sandbox Mode',
|
||||
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
|
||||
'ai.codebuddy.persistSession': 'Session Persistence',
|
||||
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
|
||||
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
|
||||
@@ -471,6 +485,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Use "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Thinking',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.thinkingDefault': 'Provider default',
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
|
||||
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'sort.ip': 'By IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
@@ -358,6 +359,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
@@ -649,6 +653,8 @@ Highlight the focused split pane:
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Right-click hold opens menu',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'When right-click pastes or selects a word, hold for half a second to open the menu. Does not override terminal apps that capture the mouse.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
|
||||
@@ -706,6 +712,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Tab double-click behavior',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Choose the action when double-clicking a terminal tab.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicate session (new connection)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Copy tab (reuse connection)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
|
||||
@@ -417,6 +417,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Contexto compactado',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -225,20 +226,24 @@ export const esAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.minimal': 'Mínimo',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.effort.max': 'Máximo',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.persistSession': 'Persistencia de sesión',
|
||||
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
@@ -471,6 +476,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Usar "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Razonamiento',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.thinkingDefault': 'Predeterminado del proveedor',
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Más reciente a más antiguo',
|
||||
'sort.oldest': 'Más antiguo a más reciente',
|
||||
'sort.ip': 'Por IP',
|
||||
'sort.group': 'Por grupo',
|
||||
'field.label': 'Etiqueta',
|
||||
'field.type': 'Tipo',
|
||||
@@ -273,6 +274,9 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
@@ -564,6 +568,8 @@ Resalta el panel dividido enfocado:
|
||||
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
|
||||
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Acción al hacer clic derecho en la terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Mantener clic derecho abre el menú',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Si el clic derecho pega o selecciona una palabra, mantén pulsado medio segundo para abrir el menú. No afecta a las aplicaciones que capturan el ratón.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Mostrar menú sobre aplicaciones en pantalla completa',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Muestra el menú contextual incluso cuando tmux, vim u otras aplicaciones en pantalla completa toman el control del mouse. Cuando está desactivado, el clic derecho se pasa a la aplicación (Shift+Clic derecho sigue abriendo el menú).',
|
||||
@@ -621,6 +627,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Comportamiento de doble clic en pestaña',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Elige la acción al hacer doble clic en una pestaña de terminal.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicar sesión (nueva conexión)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Copiar pestaña (reutilizar conexión)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'No hacer nada',
|
||||
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
@@ -206,6 +207,26 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
'ai.codebuddy.advancedSection': 'Расширенные параметры (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Усилие рассуждения',
|
||||
'ai.codebuddy.effort.default': 'По умолчанию',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': 'Управляет глубиной рассуждения модели. Для простых команд используйте Minimal/Low, чтобы экономить токены, а для сложной диагностики — Max/High.',
|
||||
'ai.codebuddy.maxTurns': 'Максимум шагов',
|
||||
'ai.codebuddy.maxTurns.hint': 'Ограничивает максимальное число шагов диалога за запрос, предотвращая зацикливание. Оставьте пустым для значения по умолчанию.',
|
||||
'ai.codebuddy.maxBudget': 'Максимальный бюджет (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Максимальные расходы (USD) за запрос. При превышении выполнение останавливается автоматически. Оставьте пустым, чтобы не ограничивать.',
|
||||
'ai.codebuddy.sandbox': 'Режим песочницы',
|
||||
'ai.codebuddy.sandbox.hint': 'Выполняет вызовы инструментов в песочнице, ограничивая доступ к файловой системе и сети.',
|
||||
'ai.codebuddy.persistSession': 'Сохранение сессии',
|
||||
'ai.codebuddy.persistSession.hint': 'Сохраняет на диск собственную историю сессии CodeBuddy, чтобы CodeBuddy мог возобновить её позже. Отключите, чтобы запретить хранение сессии CodeBuddy; Netcatty всё ещё может сохранять историю чата и метаданные внешней сессии. Контрольные точки файлов пропускаются. Требуется CodeBuddy CLI 2.125.1 или новее.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Контрольные точки файлов',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Включает контрольные точки файловых операций, чтобы изменения файлов ИИ можно было откатить.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
|
||||
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
|
||||
'ai.codebuddy.elicitation.select': 'Выберите вариант',
|
||||
@@ -423,6 +444,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Использовать «{id}»',
|
||||
'ai.chat.thinkingLevel': 'Мышление',
|
||||
'ai.chat.thinkingOff': 'Выкл.',
|
||||
'ai.chat.thinkingDefault': 'По умолчанию провайдера',
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sort.newest': 'Сначала новые',
|
||||
'sort.oldest': 'Сначала старые',
|
||||
'sort.group': 'По группе',
|
||||
'sort.ip': 'По IP',
|
||||
'field.label': 'Метка',
|
||||
'field.type': 'Тип',
|
||||
'auth.keyType': 'Тип {type}',
|
||||
@@ -342,6 +343,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
@@ -606,6 +610,8 @@ export const ruCoreMessages: Messages = {
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Удержание правой кнопки открывает меню',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Если правая кнопка вставляет текст или выделяет слово, удерживайте её полсекунды для открытия меню. Не влияет на приложения, захватывающие мышь.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
|
||||
@@ -663,6 +669,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Действие по двойному щелчку вкладки',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Выберите действие при двойном щелчке вкладки терминала.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Дублировать сеанс (новое подключение)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Копировать вкладку (использовать подключение)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已压缩',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活动',
|
||||
'ai.chat.activity.plan': '计划',
|
||||
@@ -53,6 +54,15 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.headers': '自定义 HTTP 请求头',
|
||||
'ai.providers.headers.help': '为此服务添加可选请求头。内容会隐藏显示并在保存时加密。',
|
||||
'ai.providers.headers.name': '请求头名称',
|
||||
'ai.providers.headers.value': '请求头内容',
|
||||
'ai.providers.headers.add': '添加请求头',
|
||||
'ai.providers.headers.remove': '删除请求头',
|
||||
'ai.providers.headers.invalid': '请填写有效且不重复的请求头。Content-Length 和 Transfer-Encoding 由应用自动设置。',
|
||||
'ai.providers.headers.loadError': '无法解锁已保存的请求头。请取消编辑,解锁系统钥匙串后重试。',
|
||||
'ai.providers.headers.saveError': '无法安全保存。请确认系统钥匙串可用后重试。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
@@ -225,20 +235,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '默认',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大轮次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
|
||||
'ai.codebuddy.maxBudget': '预算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
|
||||
'ai.codebuddy.persistSession': '会话持久化',
|
||||
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '文件检查点',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
|
||||
@@ -471,6 +485,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': '使用 “{id}”',
|
||||
'ai.chat.thinkingLevel': '思考强度',
|
||||
'ai.chat.thinkingOff': '关闭',
|
||||
'ai.chat.thinkingDefault': '跟随提供商默认',
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'sort.ip': '按 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -356,6 +357,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
@@ -1046,6 +1050,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.refreshAfterCommand': '终端命令完成后刷新',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
@@ -289,6 +291,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右键长按显示菜单',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右键设为粘贴或选词时,按住半秒打开菜单。不影响接管鼠标的终端应用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
|
||||
@@ -343,6 +347,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
|
||||
'settings.terminal.behavior.tabDoubleClick': '标签页双击行为',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': '选择双击顶部终端标签页时执行的操作。',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': '复制会话(新建连接)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': '复制标签页(复用连接)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': '无操作',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已壓縮',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活動',
|
||||
'ai.chat.activity.plan': '計畫',
|
||||
@@ -225,20 +226,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '環境變數',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '預設',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大輪次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
|
||||
'ai.codebuddy.maxBudget': '預算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
|
||||
'ai.codebuddy.persistSession': '工作階段持久化',
|
||||
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
|
||||
@@ -470,6 +475,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': '使用「{id}」',
|
||||
'ai.chat.thinkingLevel': '思考強度',
|
||||
'ai.chat.thinkingOff': '關閉',
|
||||
'ai.chat.thinkingDefault': '跟隨提供商預設',
|
||||
'ai.chat.pinModel': '釘選模型',
|
||||
'ai.chat.unpinModel': '取消釘選',
|
||||
'ai.chat.loadingModels': '正在載入模型...',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sort.newest': '從新到舊',
|
||||
'sort.oldest': '從舊到新',
|
||||
'sort.group': '依群組',
|
||||
'sort.ip': '依 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '型別',
|
||||
'auth.keyType': '型別 {type}',
|
||||
@@ -358,6 +359,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
@@ -1048,6 +1052,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
@@ -289,6 +291,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右鍵行為',
|
||||
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右鍵長按顯示選單',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右鍵設為貼上或選詞時,按住半秒開啟選單。不影響接管滑鼠的終端應用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
|
||||
@@ -343,6 +347,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.tabDoubleClick': '分頁雙擊行為',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': '選擇雙擊頂部終端分頁時要執行的動作。',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': '複製工作階段(新連線)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': '複製分頁(重用連線)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': '無動作',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
|
||||
|
||||
@@ -78,3 +78,57 @@ test('prepared apply callback runs after the snapshot with sentinel protection',
|
||||
assert.equal(observedSentinel, true);
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
});
|
||||
|
||||
for (const localRestore of [false, true]) {
|
||||
for (const encryptionFails of [false, true]) {
|
||||
test(`protected header preparation (${localRestore ? 'restore' : 'sync'}, ${encryptionFails ? 'failure' : 'success'})`, async () => {
|
||||
const { prepareSyncPayloadApply, prepareLocalVaultPayloadApply } = await import('./syncPayload.ts');
|
||||
const { STORAGE_KEY_THEME, STORAGE_KEY_AI_PROVIDERS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
|
||||
const calls: string[] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => {
|
||||
calls.push('encrypt');
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
if (encryptionFails) throw new Error('keychain locked');
|
||||
return sealed;
|
||||
} },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
localStorageValues.set(STORAGE_KEY_THEME, 'light');
|
||||
const payload: SyncPayload = { ...emptyPayload(), settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
const importers = { importVaultData: () => {
|
||||
calls.push('import');
|
||||
assert.notEqual(readInterruptedVaultApply(), null);
|
||||
} };
|
||||
const applying = applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => { calls.push('snapshot'); return emptyPayload(); },
|
||||
prepareApply: () => localRestore
|
||||
? prepareLocalVaultPayloadApply(payload, importers, { prepareConvergentRestore: async () => {
|
||||
calls.push('prepareReplica');
|
||||
return async () => { calls.push('commitReplica'); };
|
||||
} })
|
||||
: prepareSyncPayloadApply(payload, importers),
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
});
|
||||
if (encryptionFails) {
|
||||
await assert.rejects(applying);
|
||||
assert.deepEqual(calls, ['encrypt']);
|
||||
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'light');
|
||||
assert.equal(localStorageValues.has(STORAGE_KEY_AI_PROVIDERS), false);
|
||||
} else {
|
||||
await applying;
|
||||
assert.deepEqual(calls, localRestore
|
||||
? ['encrypt', 'prepareReplica', 'snapshot', 'import', 'commitReplica']
|
||||
: ['encrypt', 'snapshot', 'import']);
|
||||
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'dark');
|
||||
assert.equal(JSON.parse(localStorageValues.get(STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders['X-Tenant'], sealed);
|
||||
}
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('compose history is isolated per terminal and removed when its session closes', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'echo a\necho again');
|
||||
recordComposeBarHistory('b', 'echo b');
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['echo a\necho again']);
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
|
||||
pruneComposeBarHistory(['b']);
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('late send completion cannot restore a closed session or overwrite another send', async () => {
|
||||
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
|
||||
pruneComposeBarHistory([]);
|
||||
const first = createComposeBarHistoryRecorder('a');
|
||||
const second = createComposeBarHistoryRecorder('a');
|
||||
second('second');
|
||||
first('first');
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['first', 'second']);
|
||||
const late = createComposeBarHistoryRecorder('a');
|
||||
pruneComposeBarHistory([]);
|
||||
late('late');
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
});
|
||||
|
||||
|
||||
test('failed submissions unblock later successes in submission order', async () => {
|
||||
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
|
||||
pruneComposeBarHistory([]);
|
||||
const failed = createComposeBarHistoryRecorder('a');
|
||||
const accepted = createComposeBarHistoryRecorder('a');
|
||||
accepted('accepted');
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
failed();
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
|
||||
// Session-only memory: survives closing the bar and moving a terminal into a
|
||||
// workspace, but is discarded when that terminal session is removed.
|
||||
type PendingSend = { settled: boolean; command?: string };
|
||||
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
|
||||
|
||||
export function getComposeBarHistory(sessionId: string): readonly string[] {
|
||||
return histories.get(sessionId)?.entries ?? [];
|
||||
}
|
||||
|
||||
export function createComposeBarHistoryRecorder(sessionId: string): (command?: string) => void {
|
||||
let history = histories.get(sessionId);
|
||||
if (!history) {
|
||||
history = { entries: [], pending: [] };
|
||||
histories.set(sessionId, history);
|
||||
}
|
||||
const target = history;
|
||||
const submission: PendingSend = { settled: false };
|
||||
target.pending.push(submission);
|
||||
// Capture the session's object before an async send. A late completion after
|
||||
// session removal cannot recreate that session's history in the map.
|
||||
return (command) => {
|
||||
if (submission.settled) return;
|
||||
submission.settled = true;
|
||||
submission.command = command;
|
||||
// Settle failures too, so they cannot block later successful submissions.
|
||||
while (target.pending[0]?.settled) {
|
||||
const next = target.pending.shift()!;
|
||||
if (next.command !== undefined) {
|
||||
target.entries = appendComposeBarHistory(target.entries, next.command);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function recordComposeBarHistory(sessionId: string, command: string): void {
|
||||
createComposeBarHistoryRecorder(sessionId)(command);
|
||||
}
|
||||
|
||||
export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
|
||||
const active = new Set(sessionIds);
|
||||
for (const id of histories.keys()) {
|
||||
if (!active.has(id)) histories.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
@@ -55,6 +56,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
@@ -80,6 +82,7 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -100,6 +102,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -300,6 +304,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -90,6 +92,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
@@ -139,6 +142,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -176,7 +180,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -185,7 +189,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -200,7 +204,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -210,7 +214,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -431,6 +435,9 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
@@ -575,7 +582,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let count = 0;
|
||||
let options = {
|
||||
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
|
||||
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
|
||||
path: '/browsed', connected: true, refresh: async () => { count += 1; },
|
||||
};
|
||||
function Probe() { useSftpCommandRefresh(options); return null; }
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const update = async (patch: Partial<typeof options>) => {
|
||||
options = { ...options, ...patch };
|
||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
||||
};
|
||||
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
|
||||
publishTerminalCommandCompletion('another-terminal');
|
||||
await finish();
|
||||
assert.equal(count, 0);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
|
||||
await update(patch);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
|
||||
}
|
||||
await update({ busy: true });
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ busy: false });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await update(patch);
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
}
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
|
||||
interface Options {
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
visible: boolean;
|
||||
busy: boolean;
|
||||
matchesTerminal: boolean;
|
||||
paneId: string;
|
||||
connectionId: string | null;
|
||||
path: string | null;
|
||||
connected: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
|
||||
export function useSftpCommandRefresh(options: Options): void {
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const drain = useRef(() => {});
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !options.visible || !options.sessionId) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
let refreshing = false;
|
||||
let disposed = false;
|
||||
const eligible = () => {
|
||||
const live = latest.current;
|
||||
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
|
||||
&& live.sessionId === options.sessionId && live.paneId === options.paneId
|
||||
&& live.connectionId === options.connectionId && live.path === options.path;
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!pending || refreshing || latest.current.busy || !eligible()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (!eligible() || latest.current.busy) return;
|
||||
pending = false;
|
||||
refreshing = true;
|
||||
void latest.current.refresh().catch(() => {}).finally(() => {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
drain.current = schedule;
|
||||
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
|
||||
if (sessionId !== options.sessionId || !eligible()) return;
|
||||
pending = true;
|
||||
schedule();
|
||||
});
|
||||
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
|
||||
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
|
||||
useEffect(() => { drain.current(); }, [options.busy]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSftpPaneActions } from './useSftpPaneActions';
|
||||
import { createEmptyPane, type SftpPane } from './types';
|
||||
import type { SftpFileEntry } from '../../../types';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
|
||||
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
|
||||
let result = [file('kept'), file('new')];
|
||||
const selection = new Set(['kept']);
|
||||
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
|
||||
const left = { current: { tabs: [pane], activeTabId: pane.id } };
|
||||
const calls: string[] = [];
|
||||
let actions: ReturnType<typeof useSftpPaneActions>;
|
||||
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
|
||||
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
|
||||
};
|
||||
function Probe() {
|
||||
actions = useSftpPaneActions({
|
||||
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
|
||||
updateActiveTab: (side, updater) => update(side, pane.id, updater),
|
||||
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
|
||||
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
|
||||
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
|
||||
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
|
||||
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
|
||||
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
|
||||
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
|
||||
assert.deepEqual(calls, ['/browsed']);
|
||||
assert.equal(left.current.tabs[0].selectedFiles, selection);
|
||||
assert.equal(left.current.tabs[0].filter, 'ke');
|
||||
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
|
||||
result = [file('new')];
|
||||
await actions!.refresh('left', { preserveSelection: true });
|
||||
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
|
||||
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
/** Retain selection identity when no selected entry disappeared. */
|
||||
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
|
||||
const names = new Set(files.map((file) => file.name));
|
||||
const retained = new Set([...selected].filter((name) => names.has(name)));
|
||||
return retained.size === selected.size ? selected : retained;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -53,13 +60,14 @@ interface UseSftpPaneActionsParams {
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
preserveSelection?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -179,6 +187,7 @@ export const useSftpPaneActions = ({
|
||||
pane.connection.homeDir,
|
||||
);
|
||||
|
||||
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
|
||||
@@ -281,7 +290,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: normalizedPath }
|
||||
: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -369,7 +378,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path: normalizedPath,
|
||||
files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
@@ -380,7 +389,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
@@ -445,7 +454,7 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
@@ -476,7 +485,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
type Listener = (sessionId: string) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
/** Renderer-local fan-out of the terminal's existing completion detection. */
|
||||
export function publishTerminalCommandCompletion(sessionId: string): void {
|
||||
for (const listener of listeners) listener(sessionId);
|
||||
}
|
||||
|
||||
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type TerminalSessionInfo,
|
||||
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||
@@ -134,6 +135,7 @@ export function useAIChatStreaming({
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
const { t } = useI18n();
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
|
||||
() => new Set(sharedStreamingSessionIds),
|
||||
);
|
||||
@@ -195,7 +197,8 @@ export function useAIChatStreaming({
|
||||
setStreamingForScope,
|
||||
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
|
||||
persistContextCompaction,
|
||||
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
|
||||
translate: t,
|
||||
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction, t]);
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useComposeBarHistory } from './useComposeBarHistory';
|
||||
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('changing workspace focus resets navigation without replacing the draft', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'a1');
|
||||
recordComposeBarHistory('a', 'a2');
|
||||
recordComposeBarHistory('b', 'b1');
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness({ sessionId }: { sessionId: string }) {
|
||||
history = useComposeBarHistory(sessionId);
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { sessionId: 'a' })); });
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'a2');
|
||||
await act(async () => { root.update(React.createElement(Harness, { sessionId: 'b' })); });
|
||||
assert.equal(history.navigate('unsent draft', 'down'), undefined);
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'b1');
|
||||
assert.equal(history.navigate('b1', 'down'), 'unsent draft');
|
||||
recordComposeBarHistory('b', 'later completion');
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'later completion');
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('an in-progress history walk stays stable when a late send evicts the oldest entry', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
for (let index = 0; index < 100; index += 1) recordComposeBarHistory('a', `h${index}`);
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness() { history = useComposeBarHistory('a'); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
const finish = history.prepareRecord();
|
||||
assert.equal(history.navigate('draft', 'up'), 'h99');
|
||||
finish('h100');
|
||||
assert.equal(history.navigate('h99', 'up'), 'h98');
|
||||
assert.equal(history.navigate('h98', 'down'), 'h99');
|
||||
assert.equal(history.navigate('h99', 'down'), 'draft');
|
||||
assert.equal(history.navigate('draft', 'up'), 'h100');
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
|
||||
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
|
||||
|
||||
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
|
||||
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cursor.current = { index: Infinity, draft: '' };
|
||||
}, []);
|
||||
|
||||
// Keep the existing textarea draft when workspace focus changes, but begin
|
||||
// a fresh history walk in the newly focused session.
|
||||
useLayoutEffect(() => {
|
||||
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
|
||||
reset();
|
||||
}, [reset, restoreDraft, sessionId]);
|
||||
|
||||
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
|
||||
|
||||
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
|
||||
// A late send can evict old entries. Keep one walk stable until the user
|
||||
// returns to the draft or edits, then pick up the latest history.
|
||||
const entries = cursor.current.entries ?? getComposeBarHistory(sessionId);
|
||||
const result = navigateComposeBarHistory({
|
||||
...cursor.current,
|
||||
entries,
|
||||
currentValue,
|
||||
}, direction);
|
||||
if (result) {
|
||||
cursor.current = {
|
||||
index: result.index === entries.length ? Infinity : result.index,
|
||||
draft: result.draft,
|
||||
entries: result.index === entries.length ? undefined : entries,
|
||||
};
|
||||
}
|
||||
return result?.value;
|
||||
}, [sessionId]);
|
||||
|
||||
return { prepareRecord, navigate, reset };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -413,6 +415,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
|
||||
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
|
||||
);
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
@@ -1012,6 +1017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
@@ -1198,6 +1204,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -1254,7 +1261,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1264,7 +1271,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1394,6 +1401,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTabBarPosition = useCallback((value: TabBarPosition) => {
|
||||
const next = normalizeTabBarPosition(value);
|
||||
setTabBarPositionState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
@@ -1865,6 +1880,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
uiLanguage,
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
@@ -1891,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
@@ -2063,7 +2080,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
@@ -2139,7 +2158,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -1963,3 +1963,107 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
|
||||
test("tab bar position survives settings export and import, with a safe fallback", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.tabBarPosition, "bottom");
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
|
||||
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
|
||||
await applySyncPayload(invalid, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
test(`right-click long press preference survives sync (${enabled})`, async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ rightClickLongPressMenu: enabled }));
|
||||
const payload = buildSyncPayload(vault());
|
||||
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ rightClickLongPressMenu: !enabled }));
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restored.rightClickLongPressMenu, enabled);
|
||||
});
|
||||
}
|
||||
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
|
||||
localStorage.clear();
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async () => sealed,
|
||||
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
|
||||
}]));
|
||||
const plainSettings = buildSyncPayload(vault([]));
|
||||
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
|
||||
const cloud = await buildCloudSyncPayload(vault([]));
|
||||
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
|
||||
await applySyncPayload(cloud, { importVaultData: () => {} });
|
||||
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
||||
await applySyncPayload(plainSettings, { importVaultData: () => {} });
|
||||
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
||||
cloud.settings!.ai!.providers![0].customHeaders = {};
|
||||
await applySyncPayload(cloud, { importVaultData: () => {} });
|
||||
assert.deepEqual(read().customHeaders, {});
|
||||
});
|
||||
|
||||
for (const localRestore of [false, true]) {
|
||||
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
let imports = 0;
|
||||
let completed = 0;
|
||||
let commits = 0;
|
||||
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
|
||||
await assert.rejects(localRestore
|
||||
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
|
||||
: applySyncPayload(payload, importers));
|
||||
assert.equal(imports, 0);
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(commits, 0);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
});
|
||||
}
|
||||
|
||||
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
|
||||
localStorage.clear();
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
|
||||
const events: string[] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
|
||||
assert.deepEqual(events, ['encrypt', 'import']);
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
|
||||
});
|
||||
+58
-21
@@ -1,3 +1,5 @@
|
||||
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
|
||||
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
@@ -92,6 +94,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -259,11 +262,11 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'kittyKeyboardProtocolEnabled',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'rightClickBehavior', 'rightClickLongPressMenu', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
|
||||
'autoCloseOnExit', 'disconnectedNoticeMode',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
@@ -315,6 +318,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -399,13 +403,13 @@ const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
|
||||
}
|
||||
return incoming.map((provider) => {
|
||||
if (provider.apiKey != null) return provider;
|
||||
const id = typeof provider.id === 'string' ? provider.id : undefined;
|
||||
const localProvider = id != null ? localById.get(id) : undefined;
|
||||
if (localProvider && typeof localProvider.apiKey === 'string') {
|
||||
return { ...provider, apiKey: localProvider.apiKey };
|
||||
}
|
||||
return provider;
|
||||
return {
|
||||
...provider,
|
||||
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
|
||||
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -542,6 +546,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
@@ -559,7 +565,11 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
|
||||
if (providers) ai.providers = providers.map((provider) => {
|
||||
const next = { ...stripDeviceBoundApiKey(provider) };
|
||||
delete next.customHeaders;
|
||||
return next;
|
||||
});
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
@@ -621,7 +631,12 @@ export async function collectCloudSyncableSettings(): Promise<SyncPayload['setti
|
||||
};
|
||||
|
||||
if (providers) {
|
||||
ai.providers = await Promise.all(providers.map(withPortableApiKey));
|
||||
ai.providers = await Promise.all(providers.map(async (provider) => {
|
||||
const next = await withPortableApiKey(provider);
|
||||
return isRecord(provider.customHeaders)
|
||||
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
|
||||
: next;
|
||||
}));
|
||||
}
|
||||
if (webSearchConfig) {
|
||||
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
|
||||
@@ -658,7 +673,10 @@ function collectLocalBackupSettings(): SyncPayload['settings'] {
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
|
||||
async function applySyncableSettings(
|
||||
settings: NonNullable<SyncPayload['settings']>,
|
||||
preparedProviders: Record<string, unknown>[] | undefined,
|
||||
): Promise<void> {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
@@ -795,6 +813,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.tabBarPosition != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
@@ -816,11 +837,10 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
if (ai.providers != null) {
|
||||
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
|
||||
if (preparedProviders != null) {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
mergeAiProvidersPreservingLocalApiKeys(providers),
|
||||
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
|
||||
);
|
||||
}
|
||||
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
|
||||
@@ -1137,7 +1157,7 @@ export async function buildLocalVaultPayloadAsync(
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
function applyPayload(
|
||||
async function preparePayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: {
|
||||
@@ -1145,11 +1165,20 @@ function applyPayload(
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
currentHosts?: Host[];
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<() => Promise<void>> {
|
||||
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
// Prepare credentials before any vault or settings mutation. Encryption can
|
||||
// fail when local credential storage is unavailable; reuse the result below.
|
||||
const providers = sanitizedPayload.settings?.ai?.providers;
|
||||
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
|
||||
const next = await withLocalEncryptedApiKey(provider);
|
||||
return isRecord(provider.customHeaders)
|
||||
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
|
||||
: next;
|
||||
}));
|
||||
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
|
||||
if (!options.includeLocalOnlyData) {
|
||||
@@ -1181,7 +1210,7 @@ function applyPayload(
|
||||
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
@@ -1192,7 +1221,7 @@ function applyPayload(
|
||||
|
||||
// Apply synced settings
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings);
|
||||
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
@@ -1207,21 +1236,28 @@ function applyPayload(
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
export function prepareSyncPayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options?: {
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
currentHosts?: Host[];
|
||||
},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, {
|
||||
): Promise<() => Promise<void>> {
|
||||
return preparePayloadApply(payload, importers, {
|
||||
includeLocalOnlyData: false,
|
||||
applyPluginSidecars: options?.applyPluginSidecars,
|
||||
currentHosts: options?.currentHosts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySyncPayload(
|
||||
...args: Parameters<typeof prepareSyncPayloadApply>
|
||||
): Promise<void> {
|
||||
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
|
||||
await applyPreparedPayload();
|
||||
}
|
||||
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
@@ -1236,9 +1272,10 @@ export async function prepareLocalVaultPayloadApply(
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
||||
return async () => {
|
||||
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
await applyPreparedPayload();
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', (
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'glm-5.1',
|
||||
name: 'GLM 5.1',
|
||||
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
|
||||
thinkingLevels: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
}];
|
||||
|
||||
@@ -50,7 +50,7 @@ import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onBuildLocalPayload: () => SyncPayload;
|
||||
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
@@ -63,7 +63,7 @@ interface SyncDashboardProps {
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onBuildLocalPayload,
|
||||
onApplyMigrationPayload,
|
||||
onPrepareMigrationPayload,
|
||||
onApplyPayload,
|
||||
onApplyConvergentPayload,
|
||||
onApplyLocalPayload,
|
||||
@@ -336,7 +336,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
prepared: preparedConvergentMigration,
|
||||
buildCurrentPayload: onBuildPayload,
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: onApplyMigrationPayload,
|
||||
preparePayloadApply: onPrepareMigrationPayload,
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
});
|
||||
@@ -349,7 +349,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} finally {
|
||||
setConvergentBusy(false);
|
||||
}
|
||||
}, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
|
||||
}, [onPrepareMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
|
||||
|
||||
const handleResolveConvergentConflict = useCallback(async (
|
||||
addressKey: string,
|
||||
@@ -1053,7 +1053,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onBuildLocalPayload: () => SyncPayload;
|
||||
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
|
||||
@@ -57,6 +57,7 @@ import { Switch } from "./ui/switch";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
@@ -547,6 +548,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
protocol: 'ssh' as const,
|
||||
@@ -994,6 +996,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</HostDetailsSection>
|
||||
</>)}
|
||||
|
||||
<HostNotesEditor
|
||||
panelKey={groupPath}
|
||||
value={form.notes ?? ""}
|
||||
onChange={(notes) => update("notes", notes)}
|
||||
/>
|
||||
|
||||
{/* Add Protocol Button — always at the bottom */}
|
||||
{addableProtocols.length > 0 && (
|
||||
<Dropdown open={addProtocolOpen} onOpenChange={setAddProtocolOpen}>
|
||||
|
||||
@@ -168,6 +168,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
children: {},
|
||||
hosts: [],
|
||||
}]}
|
||||
groupConfigs={[{ path: "production", notes: "# VPN instructions" }]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
@@ -188,6 +189,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Group notes"/);
|
||||
assert.match(markup, /data-selected="true"/);
|
||||
assert.match(markup, /data-group-path="production"/);
|
||||
assert.match(markup, /role="tree"/);
|
||||
@@ -478,3 +480,20 @@ test("HostTreeView announces sibling position within each tree level", () => {
|
||||
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
|
||||
assert.equal(byKey.get("host:host-c")?.setSize, 1);
|
||||
});
|
||||
|
||||
test("HostTreeView IP sorting stays inside each group and leaves manual order intact", () => {
|
||||
const pair = (prefix: string, group = ""): Host[] => [
|
||||
{ ...baseHost, id: `${prefix}-ten`, label: "Alpha", hostname: "10.0.0.10", group, order: 1 },
|
||||
{ ...baseHost, id: `${prefix}-two`, label: "Zulu", hostname: "10.0.0.2", group, order: 2 },
|
||||
];
|
||||
const groupTree: GroupNode[] = [{
|
||||
name: "prod", path: "prod", hosts: pair("prod", "prod"),
|
||||
children: { east: { name: "east", path: "prod/east", hosts: pair("east", "prod/east"), children: {} } },
|
||||
}];
|
||||
const options = {
|
||||
groupTree, ungroupedHosts: pair("root"), expandedPaths: new Set(["prod", "prod/east"]), groupConfigs: [],
|
||||
};
|
||||
const keys = (sortMode: "ip" | "manual") => buildVisibleHostTreeItems({ ...options, sortMode }).map(item => item.key);
|
||||
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
|
||||
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
|
||||
});
|
||||
+21
-14
@@ -1,3 +1,4 @@
|
||||
import { compareHostAddresses } from "../domain/hostAddressSort";
|
||||
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -39,7 +40,7 @@ const getTreeGroupDropIntent = (
|
||||
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
|
||||
Array.from(dataTransfer.types).includes(type);
|
||||
|
||||
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
|
||||
|
||||
export type VisibleHostTreeItem =
|
||||
| {
|
||||
@@ -90,6 +91,7 @@ const sortHostTreeHosts = (hosts: Host[], sortMode: HostTreeSortMode): Host[] =>
|
||||
if (sortMode === 'newest') return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
if (sortMode === 'oldest') return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
if (sortMode === 'manual') return 0;
|
||||
if (sortMode === 'ip') return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return sortMode === 'manual' ? sortByVaultOrder(sorted) : sorted;
|
||||
@@ -538,19 +540,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
? <CheckSquare size={16} />
|
||||
: <Square size={16} />
|
||||
: undefined}
|
||||
labelActions={!isMultiSelectMode && (
|
||||
<button
|
||||
aria-label={`Edit ${node.name}`}
|
||||
tabIndex={-1}
|
||||
data-host-tree-group-edit-button={node.path}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
labelActions={(
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
|
||||
{!isMultiSelectMode && (
|
||||
<button
|
||||
aria-label={`Edit ${node.name}`}
|
||||
tabIndex={-1}
|
||||
data-host-tree-group-edit-button={node.path}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -880,7 +880,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={(mode) => {
|
||||
if (mode !== "group") setSortMode(mode);
|
||||
if (mode !== "group" && mode !== "ip") setSortMode(mode);
|
||||
}}
|
||||
modes={["manual", "az", "za", "newest", "oldest"]}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compareHostAddresses } from "../domain/hostAddressSort";
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
@@ -200,6 +201,8 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'ip':
|
||||
return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
|
||||
@@ -545,6 +545,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setHostClickBehavior={settings.setHostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
tabBarPosition={settings.tabBarPosition}
|
||||
setTabBarPosition={settings.setTabBarPosition}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
|
||||
/**
|
||||
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
|
||||
*
|
||||
@@ -1619,6 +1620,22 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
useSftpCommandRefresh({
|
||||
sessionId: focusedSessionId ?? activeSessionId,
|
||||
enabled: autoRefresh,
|
||||
visible: isVisible && ownerPanelOpen,
|
||||
busy: hasActiveWork || sftp.leftPane.loading,
|
||||
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
|
||||
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
|
||||
&& sftp.leftPane.connection?.hostId === activeHost.id,
|
||||
paneId: sftp.leftPane.id,
|
||||
connectionId,
|
||||
path: connectionPath,
|
||||
connected: sftp.leftPane.connection?.status === "connected",
|
||||
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
|
||||
});
|
||||
|
||||
|
||||
const {
|
||||
handleGoToTerminalCwd,
|
||||
@@ -1860,6 +1877,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
data-section="terminal-sftp-panel"
|
||||
onClick={handlePaneFocus}
|
||||
>
|
||||
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
|
||||
{t("sftp.refreshAfterCommand")}
|
||||
</label>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div
|
||||
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
|
||||
|
||||
@@ -1738,6 +1738,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
modes={['manual', 'az', 'za', 'newest', 'oldest', 'group']}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
|
||||
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -2367,9 +2368,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandSubmitted?.(...args);
|
||||
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
publishTerminalCommandCompletion(sessionId);
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
}, [pluginTerminalLifecycle]);
|
||||
}, [pluginTerminalLifecycle, sessionId]);
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -3377,6 +3379,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionName: sessionDisplayName,
|
||||
sourceSessionId: sessionId,
|
||||
sessionRef,
|
||||
scrollOnPasteRef,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
|
||||
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
@@ -301,6 +302,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
closePane: closeSidePanelPaneForTab,
|
||||
resizeSplit: resizeSidePanelSplitForTab,
|
||||
} = useTerminalSidePanelLayoutState();
|
||||
useEffect(() => {
|
||||
pruneComposeBarHistory(sessions.map((session) => session.id));
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setMagnifiedPane((current) => {
|
||||
if (!current) return current;
|
||||
@@ -2137,9 +2142,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
toast.info(t('scripts.recording.started'));
|
||||
}, [getActiveTerminalSessionId, t]);
|
||||
|
||||
const handleComposeSend = useCallback((text: string) => {
|
||||
const handleComposeSend = useCallback(async (text: string) => {
|
||||
const activeWorkspace = activeWorkspaceRef.current;
|
||||
if (!activeWorkspace) return;
|
||||
if (!activeWorkspace) return false;
|
||||
let recordHistory = false;
|
||||
const pendingSends: Promise<boolean>[] = [];
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
@@ -2155,11 +2162,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (isTerminalSensitiveInputActive(sid)) continue;
|
||||
const executor = snippetExecutorsRef.current.get(sid);
|
||||
if (executor) {
|
||||
executor(text, false, { broadcast: false });
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
(sent) => sent && !isTerminalSensitiveInputActive(sid),
|
||||
));
|
||||
} else {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sid);
|
||||
if (!session || !canUseDirectSessionWriteFallback(session)) continue;
|
||||
terminalBackend.writeToSession(sid, payload, { sensitive: false });
|
||||
recordHistory = recordHistory || session.status === 'connected';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2171,16 +2181,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (targetId) {
|
||||
const executor = snippetExecutorsRef.current.get(targetId);
|
||||
if (executor) {
|
||||
executor(text, false);
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
pendingSends.push(Promise.resolve(executor(text, false)).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === targetId);
|
||||
if (!session || !canUseDirectSessionWriteFallback(session)) return;
|
||||
if (!session || !canUseDirectSessionWriteFallback(session)) return false;
|
||||
recordHistory = session.status === 'connected' && !isTerminalSensitiveInputActive(targetId);
|
||||
terminalBackend.writeToSession(targetId, payload, {
|
||||
sensitive: isTerminalSensitiveInputActive(targetId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const results = await Promise.allSettled(pendingSends);
|
||||
return recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
|
||||
}, [isBroadcastEnabled, terminalBackend]);
|
||||
|
||||
const sessionLogConfig = useMemo(
|
||||
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
} = await import("../application/state/terminalDragData.ts");
|
||||
const {
|
||||
activateLogViewTab,
|
||||
createTopTabCopyDoubleClickHandler,
|
||||
createTopTabSessionDoubleClickHandler,
|
||||
formatSessionTopTabLabel,
|
||||
formatSessionTopTabTooltip,
|
||||
resolveSessionTabCodingCliIconState,
|
||||
@@ -41,6 +41,7 @@ const {
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.tsx", import.meta.url), "utf8");
|
||||
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
|
||||
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
|
||||
@@ -53,7 +54,6 @@ const appSource = [
|
||||
].join("\n");
|
||||
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
|
||||
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
|
||||
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
@@ -248,17 +248,37 @@ test("disabling dynamic titles freezes a stored coding CLI icon and stops title
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tabs copy the session on double click through the existing copy handler", () => {
|
||||
test("session top tab double click dispatches the configured behavior", () => {
|
||||
const copiedSessionIds: string[] = [];
|
||||
const handleDoubleClick = createTopTabCopyDoubleClickHandler(
|
||||
(sessionId) => copiedSessionIds.push(sessionId),
|
||||
"session-1",
|
||||
const duplicatedSessionIds: string[] = [];
|
||||
const handlers = (["duplicate", "copy", "disabled"] as const).map((behavior) =>
|
||||
createTopTabSessionDoubleClickHandler({
|
||||
behavior,
|
||||
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
|
||||
onDuplicateSession: (sessionId) => duplicatedSessionIds.push(sessionId),
|
||||
sessionId: `session-${behavior}`,
|
||||
}),
|
||||
);
|
||||
|
||||
handlers.forEach((handler) => handler({} as Parameters<typeof handler>[0]));
|
||||
|
||||
assert.deepEqual(duplicatedSessionIds, ["session-duplicate"]);
|
||||
assert.deepEqual(copiedSessionIds, ["session-copy"]);
|
||||
assert.match(topTabItemsSource, /behavior: tabDoubleClickBehavior/);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
|
||||
});
|
||||
|
||||
test("duplicate double-click behavior falls back to copying when unavailable", () => {
|
||||
const copiedSessionIds: string[] = [];
|
||||
const handleDoubleClick = createTopTabSessionDoubleClickHandler({
|
||||
behavior: "duplicate",
|
||||
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
|
||||
|
||||
assert.deepEqual(copiedSessionIds, ["session-1"]);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
|
||||
});
|
||||
|
||||
test("session close button double click stays on the close button", () => {
|
||||
|
||||
+28
-9
@@ -11,7 +11,7 @@ import {
|
||||
appendHostFromWorkspaceDrop,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from '../domain/focusSidebarHostDrop';
|
||||
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
|
||||
import type { DynamicTabTitleMode, KeyBinding, TerminalTabDoubleClickBehavior } from '../domain/models';
|
||||
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
@@ -168,6 +168,7 @@ interface TopTabsProps {
|
||||
showHostTreeSidebar: boolean;
|
||||
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
pluginViewTabs: readonly PluginViewTab[];
|
||||
onClosePluginViewTab: (tabId: string) => void;
|
||||
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
showHostTreeSidebar,
|
||||
switchTabKeyBinding,
|
||||
dynamicTabTitleMode,
|
||||
tabDoubleClickBehavior,
|
||||
editorTabs,
|
||||
pluginViewTabs,
|
||||
onClosePluginViewTab,
|
||||
@@ -226,6 +228,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const {
|
||||
tabBarPosition,
|
||||
hotkeyScheme,
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
@@ -267,7 +270,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
|
||||
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
|
||||
const [rootTabsCompact, setRootTabsCompact] = useState(false);
|
||||
const showWindowControls = !isMacClient;
|
||||
const tabsAtBottom = tabBarPosition === 'bottom';
|
||||
const showWindowControls = !isMacClient && !tabsAtBottom;
|
||||
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
@@ -899,6 +903,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
tabDoubleClickBehavior={tabDoubleClickBehavior}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(session.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(session.id)}
|
||||
@@ -994,12 +999,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}, [isMacClient, maximize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-tab-window-titlebar
|
||||
data-section="top-tabs"
|
||||
className={cn("h-9 shrink-0 items-end justify-end bg-secondary app-drag", tabsAtBottom ? "flex" : "hidden")}
|
||||
style={dragRegionNoSelect}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{!isMacClient && <WindowControls />}
|
||||
</div>
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-position={tabBarPosition}
|
||||
data-section="top-tabs"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
className="relative w-full shrink-0 bg-secondary app-drag"
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
order: tabsAtBottom ? 2 : undefined,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
@@ -1010,12 +1027,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState={updateScrollState}
|
||||
/>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div className={cn("absolute inset-x-0 h-1 app-drag pointer-events-auto z-10", tabsAtBottom ? "bottom-0" : "top-0")} style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-9 flex items-end gap-0 app-drag overflow-visible"
|
||||
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
|
||||
style={{
|
||||
...dragRegionStyle,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
|
||||
paddingRight: showWindowControls ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
@@ -1063,7 +1080,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
ref={hostTreeToggleSlotRef}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
|
||||
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
|
||||
data-section="top-tabs-host-tree-toggle"
|
||||
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
|
||||
style={noDragRegionStyle}
|
||||
@@ -1173,7 +1190,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
>
|
||||
@@ -1187,7 +1204,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
|
||||
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
|
||||
style={dragRegionStyle}
|
||||
data-section="top-tabs-toolbar-actions"
|
||||
>
|
||||
@@ -1262,6 +1279,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1305,6 +1323,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
|
||||
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
|
||||
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
|
||||
prev.tabDoubleClickBehavior === next.tabDoubleClickBehavior &&
|
||||
prev.hostById === next.hostById
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,3 +162,12 @@ test("Hosts manual sort mode uses saved order", () => {
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts IP sort is restored and compares octets instead of labels", () => {
|
||||
const markup = renderVault("ip", [
|
||||
{ ...host("ten", "Alpha Ten", 1), hostname: "10.0.0.10" },
|
||||
{ ...host("two", "Zulu Two", 2), hostname: "10.0.0.2" },
|
||||
]);
|
||||
assert.ok(markup.includes("Zulu Two"));
|
||||
assert.ok(markup.indexOf("Zulu Two") < markup.indexOf("Alpha Ten"));
|
||||
});
|
||||
@@ -207,7 +207,8 @@ const isSortMode = (value: string): value is SortMode =>
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "group";
|
||||
value === "group" ||
|
||||
value === "ip";
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
|
||||
@@ -1612,6 +1612,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<ComposerThinkingChip
|
||||
levels={thinkingLevels}
|
||||
selectedLevel={visibleThinking}
|
||||
allowDefault={hasProviderSwitcher && !!onThinkingLevelChange}
|
||||
disabled={lockTurnConfiguration}
|
||||
open={showThinkingPicker}
|
||||
menuPos={showThinkingPicker ? menuPos : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Brain, Check, ChevronDown } from 'lucide-react';
|
||||
import { Brain, Check, ChevronDown, CircleSlash } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -9,6 +9,8 @@ export interface ComposerThinkingChipProps {
|
||||
levels: readonly string[];
|
||||
selectedLevel?: string;
|
||||
disabled?: boolean;
|
||||
/** Offer a "provider default" entry that clears the override (empty selection). */
|
||||
allowDefault?: boolean;
|
||||
open: boolean;
|
||||
menuPos: { left: number; bottom: number } | null;
|
||||
onToggle: (rect: DOMRect | undefined) => void;
|
||||
@@ -23,6 +25,7 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
|
||||
levels,
|
||||
selectedLevel,
|
||||
disabled = false,
|
||||
allowDefault = false,
|
||||
open,
|
||||
menuPos,
|
||||
onToggle,
|
||||
@@ -71,16 +74,17 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
|
||||
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{levels.map((level) => {
|
||||
const isSelected = selectedLevel === level;
|
||||
{(allowDefault ? [undefined, ...levels] : levels).map((level) => {
|
||||
const isDefault = level === undefined;
|
||||
const isSelected = isDefault ? !selectedLevel : selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
key={isDefault ? '__default__' : level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!isSelected) onSelect(level);
|
||||
if (!isSelected) onSelect(isDefault ? '' : level);
|
||||
else onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
@@ -88,8 +92,12 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
|
||||
{isSelected
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />}
|
||||
<Brain size={12} className="text-violet-400/70 shrink-0" />
|
||||
<span className="text-foreground/85">{formatLevel(level)}</span>
|
||||
{isDefault
|
||||
? <CircleSlash size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
: <Brain size={12} className="text-violet-400/70 shrink-0" />}
|
||||
<span className="text-foreground/85">
|
||||
{isDefault ? t('ai.chat.thinkingDefault') : formatLevel(level)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { HostNotesIndicator } from "./HostNotesIndicator";
|
||||
|
||||
test("notes indicators distinguish groups from hosts and hide empty notes", () => {
|
||||
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="Host" />), /aria-label="Host notes"/);
|
||||
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="# Project" label="Group notes" />), /aria-label="Group notes"/);
|
||||
assert.equal(renderToStaticMarkup(<HostNotesIndicator notes=" " label="Group notes" />), "");
|
||||
});
|
||||
@@ -7,11 +7,13 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card"
|
||||
export interface HostNotesIndicatorProps {
|
||||
notes?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
|
||||
notes,
|
||||
className,
|
||||
label = "Host notes",
|
||||
}) => {
|
||||
const trimmed = notes?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -25,7 +27,7 @@ export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
|
||||
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
aria-label="Host notes"
|
||||
aria-label={label}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,12 @@ const dynamicTabTitleModeOptions = (
|
||||
}
|
||||
).DYNAMIC_TAB_TITLE_MODE_OPTIONS;
|
||||
|
||||
const tabDoubleClickBehaviorOptions = (
|
||||
terminalBehaviorSettings as {
|
||||
TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
|
||||
}
|
||||
).TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS;
|
||||
|
||||
test("middle-click settings expose only supported behaviors", () => {
|
||||
assert.ok(Array.isArray(middleClickBehaviorOptions));
|
||||
assert.deepEqual(
|
||||
@@ -34,6 +40,15 @@ test("dynamic tab title settings expose off, agent-only, and all modes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("tab double-click settings expose duplicate, copy, and disabled modes", () => {
|
||||
assert.ok(Array.isArray(tabDoubleClickBehaviorOptions));
|
||||
assert.deepEqual(
|
||||
tabDoubleClickBehaviorOptions.map((option) => option.value),
|
||||
["duplicate", "copy", "disabled"],
|
||||
);
|
||||
assert.match(source, /updateTerminalSetting\("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior\)/);
|
||||
});
|
||||
|
||||
test("terminal behavior settings expose word separator editing", () => {
|
||||
assert.match(source, /settings\.terminal\.behavior\.wordSeparators/);
|
||||
assert.match(source, /terminalSettings\.wordSeparators/);
|
||||
|
||||
@@ -112,7 +112,7 @@ function getSavedManagedAgentPathInfo(
|
||||
return {
|
||||
path: command,
|
||||
binPath: command,
|
||||
version: null,
|
||||
version: managed.cliVersion ?? null,
|
||||
available: savedAvailable,
|
||||
installed: true,
|
||||
authenticated: undefined,
|
||||
|
||||
@@ -60,6 +60,8 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior: (behavior: "connect" | "select") => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
setTabBarPosition: (position: 'top' | 'bottom') => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
showHostTreeSidebar: boolean;
|
||||
@@ -129,6 +131,8 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
setTabBarPosition,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
@@ -479,6 +483,29 @@ function SettingsAppearanceTab(props: {
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-tab-bar-position"
|
||||
label={t('settings.appearance.tabBarPosition')}
|
||||
>
|
||||
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
|
||||
{(['top', 'bottom'] as const).map((position) => (
|
||||
<button
|
||||
key={position}
|
||||
type="button"
|
||||
aria-pressed={tabBarPosition === position}
|
||||
onClick={() => setTabBarPosition(position)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
tabBarPosition === position
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.appearance.tabBarPosition.${position}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-show-sftp-tab"
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import {
|
||||
buildCloudSyncPayload,
|
||||
applySyncPayload,
|
||||
prepareSyncPayloadApply,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
prepareLocalVaultPayloadApply,
|
||||
} from "../../../application/syncPayload";
|
||||
@@ -49,9 +49,9 @@ export default function SettingsSyncTab(props: {
|
||||
);
|
||||
}, [vault, getEffectivePortForwardingRules]);
|
||||
|
||||
const onApplyMigrationPayload = useCallback(
|
||||
const onPrepareMigrationPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applySyncPayload(payload, {
|
||||
prepareSyncPayloadApply(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
@@ -63,11 +63,11 @@ export default function SettingsSyncTab(props: {
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: () => onApplyMigrationPayload(payload),
|
||||
prepareApply: () => onPrepareMigrationPayload(payload),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[onApplyMigrationPayload, onBuildLocalPayload, t],
|
||||
[onPrepareMigrationPayload, onBuildLocalPayload, t],
|
||||
);
|
||||
|
||||
const onApplyConvergentPayload = useCallback(
|
||||
@@ -76,14 +76,17 @@ export default function SettingsSyncTab(props: {
|
||||
commitReplica: () => Promise<void>,
|
||||
) => applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: async () => {
|
||||
await onApplyMigrationPayload(payload);
|
||||
await commitReplica();
|
||||
prepareApply: async () => {
|
||||
const applyPreparedPayload = await onPrepareMigrationPayload(payload);
|
||||
return async () => {
|
||||
await applyPreparedPayload();
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[onApplyMigrationPayload, onBuildLocalPayload, t],
|
||||
[onPrepareMigrationPayload, onBuildLocalPayload, t],
|
||||
);
|
||||
|
||||
const onApplyLocalPayload = useCallback(
|
||||
@@ -112,7 +115,7 @@ export default function SettingsSyncTab(props: {
|
||||
<CloudSyncSettings
|
||||
onBuildPayload={onBuildPayload}
|
||||
onBuildLocalPayload={onBuildLocalPayload}
|
||||
onApplyMigrationPayload={onApplyMigrationPayload}
|
||||
onPrepareMigrationPayload={onPrepareMigrationPayload}
|
||||
onApplyPayload={onApplyPayload}
|
||||
onApplyConvergentPayload={onApplyConvergentPayload}
|
||||
onApplyLocalPayload={onApplyLocalPayload}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
|
||||
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings, TerminalTabDoubleClickBehavior } from "../../../domain/models";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -31,6 +31,15 @@ export const DYNAMIC_TAB_TITLE_MODE_OPTIONS: Array<{
|
||||
{ value: "all", labelKey: "settings.terminal.behavior.dynamicTabTitle.all" },
|
||||
];
|
||||
|
||||
export const TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS: Array<{
|
||||
value: TerminalTabDoubleClickBehavior;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{ value: "duplicate", labelKey: "settings.terminal.behavior.tabDoubleClick.duplicate" },
|
||||
{ value: "copy", labelKey: "settings.terminal.behavior.tabDoubleClick.copy" },
|
||||
{ value: "disabled", labelKey: "settings.terminal.behavior.tabDoubleClick.disabled" },
|
||||
];
|
||||
|
||||
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
|
||||
t,
|
||||
terminalSettings,
|
||||
@@ -83,6 +92,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.rightClick.longPressMenu")}
|
||||
description={t("settings.terminal.behavior.rightClick.longPressMenu.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.rightClickLongPressMenu ?? false}
|
||||
onChange={(v) => updateTerminalSetting("rightClickLongPressMenu", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.rightClick.fullscreenMenu")}
|
||||
description={t("settings.terminal.behavior.rightClick.fullscreenMenu.desc")}
|
||||
@@ -204,6 +223,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-tab-double-click"
|
||||
label={t("settings.terminal.behavior.tabDoubleClick")}
|
||||
description={t("settings.terminal.behavior.tabDoubleClick.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.tabDoubleClickBehavior}
|
||||
options={TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
onChange={(v) => updateTerminalSetting("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior)}
|
||||
className="w-44"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-dynamic-tab-title"
|
||||
label={t("settings.terminal.behavior.dynamicTabTitle")}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
|
||||
import {
|
||||
isCodebuddySessionPersistenceSupported,
|
||||
type CodebuddyAdvancedOptions,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
|
||||
|
||||
const INTERNET_ENV_OPTIONS = [
|
||||
@@ -15,10 +18,12 @@ const INTERNET_ENV_OPTIONS = [
|
||||
|
||||
const EFFORT_OPTIONS = [
|
||||
{ value: "", labelKey: "ai.codebuddy.effort.default" },
|
||||
{ value: "minimal", labelKey: "ai.codebuddy.effort.minimal" },
|
||||
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
|
||||
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
|
||||
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
|
||||
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
|
||||
{ value: "max", labelKey: "ai.codebuddy.effort.max" },
|
||||
] as const;
|
||||
|
||||
export const CodebuddyCard: React.FC<{
|
||||
@@ -50,6 +55,13 @@ export const CodebuddyCard: React.FC<{
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
const persistSessionSupported = Boolean(
|
||||
found && isCodebuddySessionPersistenceSupported(pathInfo?.version),
|
||||
);
|
||||
// Unsupported/unknown CLIs keep their own default (persistence enabled),
|
||||
// even if a stale false value remains in saved settings.
|
||||
const persistSessionEnabled = !persistSessionSupported
|
||||
|| advancedOptions?.persistSession !== false;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
@@ -193,7 +205,7 @@ export const CodebuddyCard: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced SDK options (SDK 0.3.230) */}
|
||||
{/* Advanced SDK options (SDK 0.3.258) */}
|
||||
{onAdvancedOptionsChange && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
@@ -279,6 +291,33 @@ export const CodebuddyCard: React.FC<{
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Session Persistence */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.persistSession')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.persistSession.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
disabled={!persistSessionSupported}
|
||||
aria-checked={persistSessionEnabled}
|
||||
onClick={() => {
|
||||
if (!persistSessionSupported) return;
|
||||
updateAdvanced({ persistSession: advancedOptions?.persistSession === false ? undefined : false });
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
|
||||
!persistSessionSupported && "cursor-not-allowed opacity-50",
|
||||
persistSessionEnabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
persistSessionEnabled ? "translate-x-[18px]" : "translate-x-[3px]",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{/* File Checkpointing */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
|
||||
@@ -95,12 +95,13 @@ export const ModelSelector: React.FC<{
|
||||
presetModels?: readonly string[];
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
providerId?: AIProviderId;
|
||||
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
skipTLSVerify?: boolean;
|
||||
onModelMetadata?: (model: FetchedModel) => void;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, customHeaders, providerId, style, skipTLSVerify, onModelMetadata }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -124,6 +125,7 @@ export const ModelSelector: React.FC<{
|
||||
baseURL,
|
||||
effectiveModelsEndpoint,
|
||||
apiKey,
|
||||
customHeaders,
|
||||
resolvedStyle,
|
||||
skipTLSVerify,
|
||||
});
|
||||
@@ -152,7 +154,7 @@ export const ModelSelector: React.FC<{
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey, customHeaders);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
@@ -171,7 +173,7 @@ export const ModelSelector: React.FC<{
|
||||
} finally {
|
||||
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { TooltipProvider } from '../../../ui/tooltip';
|
||||
import { ProviderConfigForm } from './ProviderConfigForm';
|
||||
import type { ProviderConfig } from '../../../../infrastructure/ai/types';
|
||||
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 3)]).toString('base64')}`;
|
||||
|
||||
test('loading saved headers preserves model metadata and blocks saving until decrypted', async () => {
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
|
||||
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
}
|
||||
let unlock!: (value: string) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
credentialsDecrypt: () => new Promise<string>((resolve) => { unlock = resolve; }),
|
||||
credentialsEncrypt: async () => sealed,
|
||||
} });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const root = createRoot(dom.window.document.getElementById('root')!);
|
||||
let saved: Partial<ProviderConfig> | undefined;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider>
|
||||
<ProviderConfigForm provider={{ id: 'custom', name: 'Custom', providerId: 'custom', enabled: true,
|
||||
customHeaders: { 'X-Tenant': sealed }, modelContextWindows: { model: 12345 },
|
||||
}} onSave={(updates) => { saved = updates; }} onCancel={() => {}} />
|
||||
</TooltipProvider></I18nProvider>));
|
||||
const save = () => [...dom.window.document.querySelectorAll('button')].find((button) => button.textContent === 'Save')!;
|
||||
assert.equal(save().disabled, true);
|
||||
await act(async () => { unlock('tenant-secret'); });
|
||||
assert.equal(save().disabled, false);
|
||||
assert.equal((dom.window.document.querySelector('[aria-label="Header value"]') as HTMLInputElement).value, 'tenant-secret');
|
||||
await act(async () => save().click());
|
||||
assert.deepEqual(saved?.customHeaders, { 'X-Tenant': sealed });
|
||||
assert.deepEqual(saved?.modelContextWindows, { model: 12345 });
|
||||
await act(async () => (dom.window.document.querySelector('[aria-label="Remove header"]') as HTMLButtonElement).click());
|
||||
await act(async () => save().click());
|
||||
assert.deepEqual(saved?.customHeaders, {});
|
||||
assert.equal(dom.window.document.querySelector('[aria-label="Header name"]'), null);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { decryptProviderHeaders, encryptProviderHeaders, parseProviderHeaderRows, type HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
|
||||
import { ProviderHeadersEditor } from './ProviderHeadersEditor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
@@ -55,6 +57,8 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
|
||||
/** Selectable thinking-depth levels; empty string means "provider default" (field not sent). */
|
||||
const REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const;
|
||||
|
||||
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
|
||||
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
|
||||
@@ -81,6 +85,27 @@ export const ProviderConfigForm: React.FC<{
|
||||
iconId: provider.iconId ?? "",
|
||||
iconDataUrl: provider.iconDataUrl ?? "",
|
||||
});
|
||||
const [headersSourceVersion, setHeadersSourceVersion] = useState(0);
|
||||
const [headerRows, setHeaderRows] = useState<HeaderRow[]>([]);
|
||||
const [headersLoading, setHeadersLoading] = useState(true);
|
||||
const [headersLoadError, setHeadersLoadError] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const parsedHeaders = useMemo(() => {
|
||||
try { return { headers: parseProviderHeaderRows(headerRows), valid: true }; }
|
||||
catch { return { headers: {}, valid: false }; }
|
||||
}, [headerRows]);
|
||||
const customHeaders = parsedHeaders.headers;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setHeadersLoading(true);
|
||||
setHeadersLoadError(false);
|
||||
decryptProviderHeaders(provider.customHeaders).then((headers) => {
|
||||
if (!cancelled) setHeaderRows(Object.entries(headers).map(([name, value]) => ({ name, value })));
|
||||
}).catch(() => { if (!cancelled) setHeadersLoadError(true); })
|
||||
.finally(() => { if (!cancelled) setHeadersLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [provider.customHeaders]);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -105,12 +130,14 @@ export const ProviderConfigForm: React.FC<{
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
apiKeySourceVersion,
|
||||
headersSourceVersion,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
}), [
|
||||
provider.providerId,
|
||||
form.baseURL,
|
||||
apiKeySourceVersion,
|
||||
headersSourceVersion,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
@@ -119,11 +146,13 @@ export const ProviderConfigForm: React.FC<{
|
||||
const probeFingerprint = useMemo(() => JSON.stringify({
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
apiKey: form.apiKey,
|
||||
customHeaders,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
}), [
|
||||
form.apiKey,
|
||||
customHeaders,
|
||||
form.baseURL,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
@@ -182,7 +211,8 @@ export const ProviderConfigForm: React.FC<{
|
||||
}, [probeFingerprint]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
/** Numeric advanced params; the string-valued reasoningEffort uses {@link handleAdvancedParamSelect}. */
|
||||
const handleAdvancedParam = useCallback((key: Exclude<keyof ProviderAdvancedParams, "reasoningEffort">, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
@@ -197,6 +227,17 @@ export const ProviderConfigForm: React.FC<{
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
const handleAdvancedParamSelect = useCallback((raw: string) => {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
if (raw) {
|
||||
next.reasoningEffort = raw;
|
||||
} else {
|
||||
delete next.reasoningEffort;
|
||||
}
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleIconFileSelect = useCallback(async (file: File | null) => {
|
||||
setIconError(null);
|
||||
@@ -257,6 +298,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
bridge: getFetchBridge(),
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
customHeaders,
|
||||
providerId: provider.providerId,
|
||||
style: resolvedStyle,
|
||||
presetModelsEndpoint: preset?.modelsEndpoint,
|
||||
@@ -310,9 +352,10 @@ export const ProviderConfigForm: React.FC<{
|
||||
} finally {
|
||||
if (probeRequestIdRef.current === requestId) setIsTesting(false);
|
||||
}
|
||||
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
}, [form.apiKey, customHeaders, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving || headersLoading || headersLoadError || !parsedHeaders.valid) return;
|
||||
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));
|
||||
@@ -320,6 +363,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
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));
|
||||
if (ap.reasoningEffort && (REASONING_EFFORT_OPTIONS as readonly string[]).includes(ap.reasoningEffort)) {
|
||||
cleanedParams.reasoningEffort = ap.reasoningEffort;
|
||||
}
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
@@ -352,15 +398,24 @@ export const ProviderConfigForm: React.FC<{
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
if (form.apiKey) {
|
||||
updates.apiKey = await encryptField(form.apiKey);
|
||||
} else {
|
||||
updates.apiKey = undefined;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(false);
|
||||
try {
|
||||
updates.customHeaders = await encryptProviderHeaders(customHeaders);
|
||||
// Encrypt API key before saving
|
||||
if (form.apiKey) {
|
||||
updates.apiKey = await encryptField(form.apiKey);
|
||||
} else {
|
||||
updates.apiKey = undefined;
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
onSave(updates);
|
||||
} catch {
|
||||
setSaveError(true);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [form, customHeaders, isSaving, headersLoading, headersLoadError, parsedHeaders.valid, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
@@ -539,6 +594,17 @@ export const ProviderConfigForm: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderHeadersEditor
|
||||
rows={headerRows}
|
||||
onChange={(rows) => { setHeaderRows(rows); setHeadersSourceVersion((version) => version + 1); }}
|
||||
disabled={headersLoading || headersLoadError || isSaving}
|
||||
/>
|
||||
{(!parsedHeaders.valid || headersLoadError || saveError) && (
|
||||
<p role="alert" className="text-xs text-destructive">{t(!parsedHeaders.valid
|
||||
? 'ai.providers.headers.invalid' : headersLoadError
|
||||
? 'ai.providers.headers.loadError' : 'ai.providers.headers.saveError')}</p>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
|
||||
@@ -572,7 +638,8 @@ export const ProviderConfigForm: React.FC<{
|
||||
baseURL={resolvedBaseURL}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
presetModels={preset?.defaultModels}
|
||||
apiKey={form.apiKey}
|
||||
apiKey={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : form.apiKey}
|
||||
customHeaders={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : customHeaders}
|
||||
providerId={provider.providerId}
|
||||
style={resolvedStyle}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
@@ -698,6 +765,20 @@ export const ProviderConfigForm: React.FC<{
|
||||
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>
|
||||
{/* reasoning_effort */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">reasoning_effort</label>
|
||||
<select
|
||||
value={form.advancedParams.reasoningEffort ?? ""}
|
||||
onChange={(e) => handleAdvancedParamSelect(e.target.value)}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('ai.providers.advancedParams.default')}</option>
|
||||
{REASONING_EFFORT_OPTIONS.map((level) => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -709,6 +790,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
|
||||
disabled={isSaving || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Check size={14} className="size-3.5 shrink-0" />
|
||||
@@ -719,7 +801,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
size="sm"
|
||||
className={PROVIDER_ACTION_CLASS}
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || isDecrypting}
|
||||
disabled={isTesting || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
|
||||
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useI18n } from '../../../../application/i18n/I18nProvider';
|
||||
import type { HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
|
||||
import { Button } from '../../../ui/button';
|
||||
|
||||
export function ProviderHeadersEditor({ rows, onChange, disabled }: {
|
||||
rows: HeaderRow[];
|
||||
onChange: (rows: HeaderRow[]) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputClass = 'min-w-0 flex-1 h-8 rounded-md border border-input bg-background px-2 text-xs disabled:opacity-50';
|
||||
return <fieldset disabled={disabled} className="space-y-2">
|
||||
<legend className="text-xs font-medium text-muted-foreground">{t('ai.providers.headers')}</legend>
|
||||
<p className="text-[11px] text-muted-foreground">{t('ai.providers.headers.help')}</p>
|
||||
{rows.map((row, index) => <div key={index} className="flex items-center gap-2">
|
||||
<input aria-label={t('ai.providers.headers.name')} placeholder="X-Header" className={inputClass}
|
||||
value={row.name} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} />
|
||||
<input type="password" autoComplete="off" aria-label={t('ai.providers.headers.value')}
|
||||
placeholder={t('ai.providers.headers.value')} className={inputClass}
|
||||
value={row.value} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, value: e.target.value } : item))} />
|
||||
<Button type="button" variant="ghost" size="sm" aria-label={t('ai.providers.headers.remove')}
|
||||
onClick={() => onChange(rows.filter((_, i) => i !== index))}><X size={14} /></Button>
|
||||
</div>)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onChange([...rows, { name: '', value: '' }])}>
|
||||
<Plus size={12} className="mr-1.5" />{t('ai.providers.headers.add')}
|
||||
</Button>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { SftpPaneFileList } from './SftpPaneFileList';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { installDomEnvironment } from '../test-support/renderReactDom';
|
||||
import { createEmptyPane } from '../../application/state/sftp/types';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('refresh keeps scroll, while explicit sorting and filters reveal the selection', async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
t.after(() => dom.cleanup());
|
||||
let scrolls = 0;
|
||||
const container = { querySelectorAll: () => [{ dataset: { entryName: 'selected' }, scrollIntoView: () => { scrolls += 1; } }] };
|
||||
const noop = () => {};
|
||||
const selected = { name: 'selected', type: 'file' as const, size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '' };
|
||||
let props: React.ComponentProps<typeof SftpPaneFileList> = {
|
||||
t: (key) => key, pane: { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', isLocal: false, status: 'connected', currentPath: '/browsed' }, selectedFiles: new Set(['selected']), files: [selected] },
|
||||
side: 'left', isPaneFocused: true,
|
||||
sorting: { sortField: 'name', sortOrder: 'asc', directoriesFirst: true,
|
||||
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
|
||||
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
|
||||
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
|
||||
fileListRef: { current: container as unknown as HTMLDivElement },
|
||||
handleFileListScroll: noop, shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [selected],
|
||||
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
|
||||
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => '', setNewFileName: noop, setFileNameError: noop,
|
||||
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
|
||||
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
|
||||
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
|
||||
rowHeight: 28, visibleRows: [],
|
||||
};
|
||||
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(render(), { createNodeMock: (element) => element.props['data-section'] === 'terminal-sftp-list' ? container : null }); });
|
||||
assert.equal(scrolls, 1);
|
||||
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
|
||||
await act(async () => { renderer.update(render()); });
|
||||
assert.equal(scrolls, 1, 'refresh must not pull the viewport back to the selection');
|
||||
props = { ...props, sorting: { ...props.sorting, sortOrder: 'desc' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
|
||||
await act(async () => { renderer.update(render()); });
|
||||
assert.equal(scrolls, 2, 'sorting retains the prior reveal-selection behavior');
|
||||
props = { ...props, pane: { ...props.pane, filter: 'selected' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
|
||||
await act(async () => { renderer.update(render()); });
|
||||
assert.equal(scrolls, 3, 'filter changes retain the prior reveal-selection behavior');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
@@ -196,7 +196,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
return () => sftpListOrderStore.clearPane(pane.id);
|
||||
}, [sortedDisplayFiles, pane.id]);
|
||||
|
||||
const selectionScrollContext = useMemo(() => ({
|
||||
paneId: pane.id, filter: pane.filter, showHiddenFiles: pane.showHiddenFiles,
|
||||
sortField, sortOrder, directoriesFirst, rowHeight, shouldVirtualize,
|
||||
}), [
|
||||
pane.id, pane.filter, pane.showHiddenFiles, sortField, sortOrder,
|
||||
directoriesFirst, rowHeight, shouldVirtualize,
|
||||
]);
|
||||
const lastScrolledSelectionRef = useRef<{
|
||||
selection: Set<string>;
|
||||
context: object;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
// Refreshes preserve selection identity; explicit sorting/filter/layout
|
||||
// changes still reveal the selected row using the usual behavior.
|
||||
const last = lastScrolledSelectionRef.current;
|
||||
if (last?.selection === pane.selectedFiles && last.context === selectionScrollContext) return;
|
||||
lastScrolledSelectionRef.current = { selection: pane.selectedFiles, context: selectionScrollContext };
|
||||
if (pane.selectedFiles.size !== 1) return;
|
||||
const selectedName = Array.from(pane.selectedFiles)[0];
|
||||
if (!selectedName) return;
|
||||
@@ -220,7 +236,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
currentScrollTop: container.scrollTop,
|
||||
viewportHeight: container.clientHeight,
|
||||
});
|
||||
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
|
||||
}, [fileListRef, pane.selectedFiles, rowHeight, selectionScrollContext, shouldVirtualize, sortedDisplayFiles]);
|
||||
|
||||
// Use refs for frequently-changing values in context-menu actions
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { TreeNode } from "./SftpPaneTreeNode.tsx";
|
||||
|
||||
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
@@ -18,7 +21,55 @@ test("tree activation enters directories and keeps file opening separate", () =>
|
||||
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
|
||||
});
|
||||
|
||||
test("tree row double click routes through the open action", () => {
|
||||
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
|
||||
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
|
||||
const dom = new JSDOM('<div id="root"></div>');
|
||||
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
|
||||
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
const container = dom.window.document.getElementById("root")!;
|
||||
const root = createRoot(container);
|
||||
const calls: string[] = [];
|
||||
const noop = () => {};
|
||||
const render = async (item: SftpFileEntry, isLoading = false) => {
|
||||
await act(async () => root.render(React.createElement(TreeNode, {
|
||||
entry: item, entryPath: `/root/${item.name}`, depth: 0,
|
||||
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
||||
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
|
||||
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
|
||||
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
|
||||
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
|
||||
})));
|
||||
calls.length = 0;
|
||||
};
|
||||
const doubleClick = async (target: Element) => {
|
||||
for (const [type, detail] of [["click", 1], ["click", 2], ["dblclick", 2]] as const) {
|
||||
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail })); });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await render(entry("docs", "directory"));
|
||||
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
|
||||
assert.deepEqual(calls, ["select", "select", "toggle"]);
|
||||
calls.length = 0;
|
||||
await doubleClick(container.querySelector('.lucide-chevron-right')!);
|
||||
assert.deepEqual(calls, ["toggle", "toggle"]);
|
||||
await render(entry("docs", "directory"), true);
|
||||
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
|
||||
assert.deepEqual(calls, ["select", "select"]);
|
||||
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
|
||||
await render(item);
|
||||
await doubleClick(container.querySelector('[data-section]')!);
|
||||
assert.deepEqual(calls, ["select", "select", "open"]);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
keys.forEach((key, index) => {
|
||||
const descriptor = originals[index];
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -67,7 +67,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
onClick={e => onNodeClick(entry, entryPath, e)}
|
||||
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
||||
onDoubleClick={() => {
|
||||
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
|
||||
if (isDir) void onToggleExpand(entry, entryPath);
|
||||
else onOpenEntry(entry, entryPath);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
if (!isParentEntry) {
|
||||
onContextMenu(entry, entryPath, e);
|
||||
@@ -84,7 +88,10 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
className="flex min-w-0 items-center gap-1"
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
>
|
||||
<span className="shrink-0 w-4 flex items-center justify-center">
|
||||
<span
|
||||
className="shrink-0 w-4 flex items-center justify-center"
|
||||
onDoubleClick={e => { if (!isParentEntry && isDir) e.stopPropagation(); }}
|
||||
>
|
||||
{isParentEntry ? (
|
||||
<CornerUpLeft size={14} className="text-muted-foreground" />
|
||||
) : isDir ? (
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
const createTextarea = () => ({
|
||||
value: '', selectionStart: 0, selectionEnd: 0,
|
||||
focus() {},
|
||||
setSelectionRange(start: number, end: number) { this.selectionStart = start; this.selectionEnd = end; },
|
||||
});
|
||||
let textarea = createTextarea();
|
||||
let finish!: (sent: boolean) => void;
|
||||
let rejectSend!: (error: Error) => void;
|
||||
const onSend = () => new Promise<boolean>((resolve, reject) => { finish = resolve; rejectSend = reject; });
|
||||
const render = (sessionId: string) => (
|
||||
<TooltipProvider><TerminalComposeBar sessionId={sessionId} onSend={onSend} onClose={() => {}} /></TooltipProvider>
|
||||
);
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(render('a'), { createNodeMock: (el) => {
|
||||
if (el.type !== 'textarea') return null;
|
||||
textarea = createTextarea();
|
||||
return textarea;
|
||||
} });
|
||||
});
|
||||
const key = (name: string) => root.root.findByType('textarea').props.onKeyDown({
|
||||
key: name, nativeEvent: {}, currentTarget: textarea, preventDefault() {},
|
||||
});
|
||||
textarea.value = 'unsent draft';
|
||||
await act(async () => { root.update(render('b')); });
|
||||
assert.equal(textarea.value, 'unsent draft');
|
||||
await act(async () => { root.update(render('a')); });
|
||||
recordComposeBarHistory('a', 'old command from A');
|
||||
key('ArrowUp');
|
||||
assert.equal(textarea.value, 'old command from A');
|
||||
await act(async () => { root.update(render('b')); });
|
||||
assert.equal(textarea.value, 'unsent draft');
|
||||
key('Enter');
|
||||
assert.equal(textarea.value, '');
|
||||
assert.deepEqual(getComposeBarHistory('b'), []);
|
||||
await act(async () => { finish(false); });
|
||||
assert.deepEqual(getComposeBarHistory('b'), []);
|
||||
textarea.value = 'accepted command';
|
||||
key('Enter');
|
||||
textarea.value = 'next draft';
|
||||
await act(async () => { finish(true); });
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['accepted command']);
|
||||
assert.equal(textarea.value, 'next draft');
|
||||
key('ArrowUp');
|
||||
assert.equal(textarea.value, 'accepted command');
|
||||
key('ArrowDown');
|
||||
assert.equal(textarea.value, 'next draft');
|
||||
textarea.value = 'first';
|
||||
key('Enter');
|
||||
const finishFirst = finish;
|
||||
textarea.value = 'second';
|
||||
key('Enter');
|
||||
const finishSecond = finish;
|
||||
await act(async () => { finishSecond(true); });
|
||||
await act(async () => { finishFirst(true); });
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second']);
|
||||
key('ArrowUp');
|
||||
assert.equal(textarea.value, 'second');
|
||||
textarea.value = 'rejected';
|
||||
key('Enter');
|
||||
const finishRejected = finish;
|
||||
textarea.value = 'after rejected';
|
||||
key('Enter');
|
||||
await act(async () => { finish(true); });
|
||||
await act(async () => { finishRejected(false); });
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second', 'after rejected']);
|
||||
const errorLog = t.mock.method(console, 'error', () => {});
|
||||
textarea.value = 'exception';
|
||||
key('Enter');
|
||||
const rejectFirst = rejectSend;
|
||||
textarea.value = 'after exception';
|
||||
key('Enter');
|
||||
await act(async () => { finish(true); });
|
||||
await act(async () => { rejectFirst(new Error('send failed')); });
|
||||
assert.equal(errorLog.mock.callCount(), 1);
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second', 'after rejected', 'after exception']);
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
|
||||
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -267,7 +269,9 @@ const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopo
|
||||
});
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
onSend: (text: string) => void;
|
||||
// Return false for rejected or sensitive input so it is not recalled.
|
||||
onSend: (text: string) => boolean | void | Promise<boolean | void>;
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
@@ -280,6 +284,7 @@ export interface TerminalComposeBarProps {
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
sessionId,
|
||||
onClose,
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
@@ -288,6 +293,10 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoreDraft = useCallback((draft: string) => {
|
||||
if (textareaRef.current) textareaRef.current.value = draft;
|
||||
}, []);
|
||||
const { prepareRecord, navigate, reset } = useComposeBarHistory(sessionId, restoreDraft);
|
||||
const isComposingRef = useRef(false);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
|
||||
@@ -338,18 +347,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
if (!el) return;
|
||||
const text = el.value;
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
const recordSent = prepareRecord();
|
||||
let result: ReturnType<typeof onSend>;
|
||||
try {
|
||||
result = onSend(text);
|
||||
} catch (error) {
|
||||
recordSent();
|
||||
throw error;
|
||||
}
|
||||
void Promise.resolve(result).then((sent) => {
|
||||
recordSent(sent !== false ? text : undefined);
|
||||
}).catch((error: unknown) => {
|
||||
recordSent();
|
||||
console.error('Compose bar send failed', error);
|
||||
});
|
||||
reset();
|
||||
el.value = '';
|
||||
el.focus();
|
||||
}, [onSend]);
|
||||
}, [onSend, prepareRecord, reset]);
|
||||
|
||||
const insertCommand = useCallback((command: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
reset();
|
||||
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
|
||||
el.value = el.value ? `${el.value}${prefix}${command}` : command;
|
||||
el.focus();
|
||||
}, []);
|
||||
}, [reset]);
|
||||
|
||||
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
|
||||
if (sendImmediately) {
|
||||
@@ -368,6 +392,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}, [insertCommand, onSend, onSnippetClick]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isComposingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
|
||||
!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
const el = e.currentTarget;
|
||||
const direction = e.key === 'ArrowUp' ? 'up' : 'down';
|
||||
if (canNavigateComposeBarHistory(el.value, el.selectionStart, direction, el.selectionEnd)) {
|
||||
const value = navigate(el.value, direction);
|
||||
if (value !== undefined) {
|
||||
e.preventDefault();
|
||||
el.value = value;
|
||||
const position = direction === 'up' ? 0 : value.length;
|
||||
el.setSelectionRange(position, position);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
@@ -375,7 +415,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, onClose]);
|
||||
}, [handleSend, navigate, onClose]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -489,6 +529,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
style={{ color: theme.resolvedFg }}
|
||||
placeholder={t('terminal.composeBar.placeholder')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={reset}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('long press opens the existing menu without running the short-click action', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal('window', window);
|
||||
installGlobal('document', window.document);
|
||||
installGlobal('navigator', window.navigator);
|
||||
installGlobal('HTMLElement', window.HTMLElement);
|
||||
installGlobal('HTMLInputElement', window.HTMLInputElement);
|
||||
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
|
||||
installGlobal('Element', window.Element);
|
||||
installGlobal('SVGElement', window.SVGElement);
|
||||
installGlobal('Node', window.Node);
|
||||
installGlobal('NodeFilter', window.NodeFilter);
|
||||
installGlobal('MutationObserver', window.MutationObserver);
|
||||
installGlobal('CustomEvent', window.CustomEvent);
|
||||
installGlobal('DOMRect', window.DOMRect);
|
||||
installGlobal('Event', window.Event);
|
||||
installGlobal('KeyboardEvent', window.KeyboardEvent);
|
||||
installGlobal('MouseEvent', window.MouseEvent);
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
|
||||
const rootNode = window.document.getElementById('root');
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
let pastes = 0;
|
||||
let mode = 'none';
|
||||
const fire = async (type: string) => {
|
||||
await act(async () => {
|
||||
window.document.querySelector('[data-testid="surface"]')!.dispatchEvent(new window.MouseEvent(type, {
|
||||
bubbles: true, cancelable: true, button: 2, buttons: type === 'mouseup' ? 0 : 2,
|
||||
clientX: 30, clientY: 30,
|
||||
}));
|
||||
});
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en">
|
||||
<TerminalContextMenu sessionId="3153" status="connected" rightClickBehavior="paste"
|
||||
rightClickLongPressMenu getMouseTrackingMode={() => mode} onPaste={() => pastes++}>
|
||||
<div data-testid="surface">Terminal</div>
|
||||
</TerminalContextMenu>
|
||||
</I18nProvider>));
|
||||
await act(async () => {
|
||||
const surface = window.document.querySelector('[data-testid="surface"]')!;
|
||||
for (const type of ['mousedown', 'contextmenu', 'mouseup']) {
|
||||
surface.dispatchEvent(new window.MouseEvent(type, {
|
||||
bubbles: true, cancelable: true, button: 2,
|
||||
}));
|
||||
}
|
||||
});
|
||||
assert.equal(pastes, 1);
|
||||
assert.equal(window.document.querySelector('[role="menu"]'), null);
|
||||
await fire('mousedown');
|
||||
await fire('contextmenu');
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
|
||||
assert.ok(window.document.querySelector('[role="menu"]'));
|
||||
await fire('mouseup');
|
||||
await fire('contextmenu'); // Windows release-time native event must not paste.
|
||||
assert.equal(pastes, 1);
|
||||
const pasteItem = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find(item => item.textContent === 'Paste');
|
||||
assert.ok(pasteItem);
|
||||
await act(async () => pasteItem.click());
|
||||
assert.equal(pastes, 2);
|
||||
assert.equal(window.document.querySelector('[role="menu"]'), null);
|
||||
mode = 'vt200';
|
||||
await fire('mousedown');
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
|
||||
await fire('mouseup');
|
||||
assert.equal(window.document.querySelector('[role="menu"]'), null);
|
||||
assert.equal(pastes, 2);
|
||||
let reconnects = 0;
|
||||
await act(async () => root.render(<I18nProvider locale="en">
|
||||
<TerminalContextMenu sessionId="3153" status="disconnected" rightClickBehavior="paste"
|
||||
rightClickLongPressMenu isReconnectable onReconnect={() => reconnects++}
|
||||
getMouseTrackingMode={() => mode} onPaste={() => pastes++}>
|
||||
<div data-testid="surface">Terminal</div>
|
||||
</TerminalContextMenu>
|
||||
</I18nProvider>));
|
||||
await fire('mousedown');
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
|
||||
await fire('mouseup');
|
||||
const reconnectItem = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find(item => item.textContent === 'Reconnect');
|
||||
assert.ok(reconnectItem);
|
||||
assert.equal(pastes, 2);
|
||||
await act(async () => reconnectItem.click());
|
||||
assert.equal(reconnects, 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
window.close();
|
||||
for (const [key, previous] of previousGlobals) {
|
||||
if (previous) Object.defineProperty(globalThis, key, previous);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { installRightClickLongPress } from './runtime/rightClickLongPress';
|
||||
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
|
||||
import { isHistoryPreviewContextMenuTarget } from './runtime/terminalHistoryScrollOverride';
|
||||
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
@@ -44,11 +45,13 @@ export interface TerminalContextMenuProps {
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings?: KeyBinding[];
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
rightClickLongPressMenu?: boolean;
|
||||
isAlternateScreen?: boolean;
|
||||
/** Read the current xterm mouse-tracking mode when handling a right-click. */
|
||||
getMouseTrackingMode?: () => string | undefined;
|
||||
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
|
||||
showContextMenuOverFullscreenApps?: boolean;
|
||||
onSaveScreen?: () => void;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onUploadClipboardImage?: () => void;
|
||||
@@ -203,9 +206,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
hotkeyScheme = 'mac',
|
||||
keyBindings,
|
||||
rightClickBehavior = 'context-menu',
|
||||
rightClickLongPressMenu = false,
|
||||
isAlternateScreen = false,
|
||||
getMouseTrackingMode,
|
||||
showContextMenuOverFullscreenApps = false,
|
||||
onSaveScreen,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onUploadClipboardImage,
|
||||
@@ -224,6 +229,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onRename,
|
||||
onDetach,
|
||||
}) => {
|
||||
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const canStartLongPress = useEffectEvent(() => rightClickLongPressMenu
|
||||
&& rightClickBehavior !== 'context-menu'
|
||||
&& (showReconnectAction || !isMouseTrackingActive({
|
||||
mouseTracking: isAlternateScreen,
|
||||
terminalMouseTrackingMode: getMouseTrackingMode?.(),
|
||||
})));
|
||||
useEffect(() => {
|
||||
if (surfaceRef.current) return installRightClickLongPress(surfaceRef.current, canStartLongPress);
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const terminalContext = buildTerminalPluginContributionContext({
|
||||
@@ -277,7 +293,6 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
const splitHShortcut = getShortcut('split-horizontal');
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
|
||||
|
||||
const terminalMouseTrackingMode = getMouseTrackingMode?.();
|
||||
|
||||
@@ -348,6 +363,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
<ContextMenu onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
ref={surfaceRef}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{children}
|
||||
@@ -438,6 +454,12 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{onSaveScreen && (
|
||||
<ContextMenuItem onClick={onSaveScreen}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t('terminal.menu.saveScreen')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={onClear}>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.clearBuffer')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
|
||||
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
|
||||
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
|
||||
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -579,9 +580,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
rightClickLongPressMenu={terminalSettings?.rightClickLongPressMenu}
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
getMouseTrackingMode={() => termRef.current?.modes.mouseTrackingMode}
|
||||
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
|
||||
onSaveScreen={terminalContextActions.onSaveScreen}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
|
||||
@@ -1304,10 +1307,15 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
{isComposeBarOpen && !inWorkspace && (
|
||||
<TerminalComposeBar
|
||||
onSend={(text) => {
|
||||
key={sessionId}
|
||||
sessionId={sessionId}
|
||||
onSend={async (text) => {
|
||||
if (sessionRef.current) {
|
||||
executeSnippetCommand(text, false);
|
||||
const sensitive = isTerminalSensitiveInputActive(sessionId);
|
||||
const sent = await executeSnippetCommand(text, false);
|
||||
return sent && !sensitive && !isTerminalSensitiveInputActive(sessionId);
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onSnippetClick={(snippet) => void executeSnippet(snippet)}
|
||||
snippets={snippets}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
findHistoryPreviewOverlay,
|
||||
} from "../runtime/terminalHistoryScrollOverride";
|
||||
|
||||
import { readTerminalScreenText } from "../terminalContextBuffer";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
type BroadcastPasteRefs = {
|
||||
sourceSessionId: string;
|
||||
sessionRef: RefObject<string | null>;
|
||||
@@ -52,6 +56,7 @@ export const broadcastTerminalPasteData = (
|
||||
export const useTerminalContextActions = ({
|
||||
termRef,
|
||||
sourceSessionId,
|
||||
sessionName,
|
||||
sessionRef,
|
||||
onHasSelectionChange,
|
||||
scrollOnPasteRef,
|
||||
@@ -71,6 +76,7 @@ export const useTerminalContextActions = ({
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sourceSessionId: string;
|
||||
sessionRef: RefObject<string | null>;
|
||||
sessionName?: string;
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
scrollOnPasteRef?: RefObject<boolean>;
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
@@ -91,6 +97,35 @@ export const useTerminalContextActions = ({
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const savingScreenRef = useRef(false);
|
||||
const onSaveScreen = useCallback(async () => {
|
||||
const term = termRef.current;
|
||||
if (!term || savingScreenRef.current) return;
|
||||
// Capture before opening the dialog: output may continue while it is open.
|
||||
const preview = findHistoryPreviewOverlay(term.element?.parentElement);
|
||||
const terminalData = preview?.textContent ?? readTerminalScreenText(term);
|
||||
savingScreenRef.current = true;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.exportSessionLog) throw new Error("Screen export unavailable");
|
||||
const result = await bridge.exportSessionLog({
|
||||
terminalData,
|
||||
hostLabel: sessionName || "terminal-screen",
|
||||
hostname: "",
|
||||
startTime: Date.now(),
|
||||
format: "txt",
|
||||
plainText: true,
|
||||
});
|
||||
if (!result.success && !result.canceled) throw new Error("Screen export failed");
|
||||
} catch (err) {
|
||||
logger.warn("Failed to save terminal screen", err);
|
||||
toast.error(t("terminal.saveScreen.failed"));
|
||||
} finally {
|
||||
savingScreenRef.current = false;
|
||||
}
|
||||
}, [sessionName, t, termRef]);
|
||||
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
return broadcastTerminalPasteData(data, {
|
||||
sourceSessionId,
|
||||
@@ -239,6 +274,7 @@ export const useTerminalContextActions = ({
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
return {
|
||||
onSaveScreen,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onUploadClipboardImage: supportsRemoteImagePaste ? onUploadClipboardImage : undefined,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
|
||||
|
||||
const event = {
|
||||
key: "ArrowLeft", metaKey: true, altKey: false, ctrlKey: false,
|
||||
shiftKey: false, isComposing: false, keyCode: 37,
|
||||
};
|
||||
|
||||
test("macOS Command arrows send beginning/end-of-line controls", () => {
|
||||
assert.equal(commandArrowLineJumpSequence(event, true), "\x01");
|
||||
assert.equal(commandArrowLineJumpSequence({ ...event, key: "ArrowRight" }, true), "\x05");
|
||||
});
|
||||
|
||||
test("other platforms, keys, modifiers and composition are untouched", () => {
|
||||
assert.equal(commandArrowLineJumpSequence(event, false), null);
|
||||
for (const override of [
|
||||
{ metaKey: false }, { altKey: true }, { ctrlKey: true }, { shiftKey: true },
|
||||
{ isComposing: true }, { keyCode: 229 }, { key: "ArrowUp" }, { key: "ArrowDown" },
|
||||
{ key: "Home" }, { key: "a" },
|
||||
]) {
|
||||
assert.equal(commandArrowLineJumpSequence({ ...event, ...override }, true), null);
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise the runtime fallback itself, including its protocol/screen guards.
|
||||
// This avoids constructing the renderer's WebGL/addon stack in Node.
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const start = runtime.indexOf(" const lineJumpSequence =");
|
||||
const end = runtime.indexOf(" // Autocomplete key handler", start);
|
||||
const applyStart = runtime.indexOf(" if (lineJumpSequence && ctx.sessionRef.current)");
|
||||
const applyEnd = runtime.indexOf(" // macOS Option+", applyStart);
|
||||
assert.ok(start > 0 && end > start && applyEnd > applyStart);
|
||||
const fallback = `(function () { ${runtime.slice(start, end)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
|
||||
|
||||
for (const mode of ["shell", "alternate", "kitty", "win32", "disconnected", "non-mac", "password", "sudo-picker"]) {
|
||||
test(`runtime Command arrow fallback: ${mode}`, () => {
|
||||
const writes: string[] = [];
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
const result = runInNewContext(fallback, {
|
||||
e: { ...event, preventDefault: () => prevented++, stopPropagation: () => stopped++ },
|
||||
term: { buffer: { active: { type: mode === "alternate" ? "alternate" : "normal" } }, modes: { win32InputMode: mode === "win32" } },
|
||||
ctx: {
|
||||
sessionRef: { current: mode === "disconnected" ? null : "session" },
|
||||
passwordPromptActiveRef: { current: mode === "password" },
|
||||
},
|
||||
sudoAutofill: { isPromptPending: () => mode === "sudo-picker" },
|
||||
kittyKeyboardMode: {},
|
||||
isKittyKeyboardModeActive: () => mode === "kitty",
|
||||
isMacPlatform: () => mode !== "non-mac",
|
||||
commandArrowLineJumpSequence,
|
||||
handleTerminalInputData: (data: string, options: { skipBroadcast: boolean }) => {
|
||||
assert.equal(options.skipBroadcast, true, "a peer TUI must not receive mapped Ctrl+A/E");
|
||||
writes.push(data);
|
||||
},
|
||||
scrollToBottomAfterInput: () => {},
|
||||
});
|
||||
assert.equal(result, mode !== "shell");
|
||||
assert.deepEqual(writes, mode === "shell" ? ["\x01"] : []);
|
||||
assert.equal(prevented, mode === "shell" ? 1 : 0);
|
||||
assert.equal(stopped, prevented);
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ["ArrowLeft", "ArrowRight"]) {
|
||||
test(`Command ${key} bypasses directory autocomplete, bare arrows still navigate it`, () => {
|
||||
const autocompleteEnd = runtime.indexOf(" const kittySequenceForKeyDown =", end);
|
||||
assert.ok(autocompleteEnd > end);
|
||||
const route = `(function () { ${runtime.slice(start, autocompleteEnd)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
|
||||
for (const metaKey of [true, false]) {
|
||||
let autocompleteCalls = 0;
|
||||
const writes: string[] = [];
|
||||
runInNewContext(route, {
|
||||
e: { ...event, key, metaKey, preventDefault: () => {}, stopPropagation: () => {} },
|
||||
term: { buffer: { active: { type: "normal" } }, modes: {} },
|
||||
ctx: {
|
||||
sessionRef: { current: "session" },
|
||||
// A focused directory panel consumes either arrow.
|
||||
onAutocompleteKeyEvent: () => { autocompleteCalls++; return false; },
|
||||
},
|
||||
sudoAutofill: undefined,
|
||||
kittyKeyboardMode: {},
|
||||
isKittyKeyboardModeActive: () => false,
|
||||
isMacPlatform: () => true,
|
||||
commandArrowLineJumpSequence,
|
||||
handleTerminalInputData: (data: string) => writes.push(data),
|
||||
scrollToBottomAfterInput: () => {},
|
||||
});
|
||||
assert.equal(autocompleteCalls, metaKey ? 0 : 1);
|
||||
assert.deepEqual(writes, metaKey ? [key === "ArrowLeft" ? "\x01" : "\x05"] : []);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** macOS line editing fallback for shells using readline/ZLE bindings. */
|
||||
export function commandArrowLineJumpSequence(
|
||||
e: Pick<KeyboardEvent, "key" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey" | "isComposing" | "keyCode">,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!isMac || !e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return null;
|
||||
if (e.isComposing || e.keyCode === 229) return null;
|
||||
if (e.key === "ArrowLeft") return "\x01";
|
||||
if (e.key === "ArrowRight") return "\x05";
|
||||
return null;
|
||||
}
|
||||
@@ -2811,6 +2811,7 @@ test("startSSH enables sudo autofill only with the host saved password", async (
|
||||
test("startSSH does not use unsaved retry passwords for sudo autofill", async () => {
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
let captured: NetcattySSHOptions | undefined;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
@@ -2818,7 +2819,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startSSHSession: async (options: NetcattySSHOptions) => { captured = options; return "ssh-session"; },
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
@@ -2856,6 +2857,18 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(captured?.password, "temporary-secret");
|
||||
assert.equal(captured?.sftpReuseOptions?.hostId, "host-1");
|
||||
assert.equal(captured?.sftpReuseOptions?.password, undefined);
|
||||
assert.equal(JSON.stringify(captured?.sftpReuseOptions).includes("temporary-secret"), false);
|
||||
assert.equal("password" in ctx.host, false);
|
||||
|
||||
// An unreadable saved credential must not block a manual password override.
|
||||
Object.assign(ctx.host, { password: ENCRYPTED_CREDENTIAL_PLACEHOLDER });
|
||||
captured = undefined;
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured?.password, "temporary-secret");
|
||||
assert.equal(captured?.sftpReuseOptions, undefined);
|
||||
});
|
||||
|
||||
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
|
||||
|
||||
@@ -60,6 +60,7 @@ import { hasConnectionPassedTcpDial } from "../connectionTimeouts";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import { isPluginHostProtocol, sanitizePluginConnection } from "../../../domain/pluginConnection";
|
||||
import { hydrateVaultStoredKeys } from "../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { buildSftpHostCredentials } from "../../../application/state/sftp/useSftpHostCredentials";
|
||||
|
||||
const collectConnectKeyIds = (
|
||||
host: Host,
|
||||
@@ -647,6 +648,23 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
);
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
|
||||
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
|
||||
// Keep the original profile identity for SFTP borrowing when the user
|
||||
// supplies a password for this terminal only. Never persist that password.
|
||||
let sftpReuseOptions: NetcattySSHOptions | undefined;
|
||||
if (pendingAuth?.authMethod === "password" && !pendingAuth.savedToHost) {
|
||||
try {
|
||||
sftpReuseOptions = buildSftpHostCredentials({
|
||||
host: ctx.host,
|
||||
hosts: ctx.resolvedChainHosts,
|
||||
keys,
|
||||
identities: ctx.identities ?? [],
|
||||
knownHosts: ctx.knownHosts,
|
||||
terminalSettings: globalTerminalSettings,
|
||||
});
|
||||
} catch {
|
||||
// A broken saved credential must not prevent a manual SSH login.
|
||||
}
|
||||
}
|
||||
const startedSessionId = await ctx.terminalBackend.startSSHSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostLabel: ctx.host.label,
|
||||
@@ -657,6 +675,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
|
||||
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||||
certificate: attempt.key?.certificate,
|
||||
publicKey: attempt.key?.publicKey,
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
|
||||
import { optionYankLastArgSequence } from "./optionYankLastArg";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { dispatchWin32InputModeEvent } from "./win32InputMode";
|
||||
@@ -1958,8 +1959,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the local shell chord before autocomplete can consume directory
|
||||
// arrows, but apply it only after app/snippet shortcuts below.
|
||||
const lineJumpSequence =
|
||||
term.buffer.active.type === "normal" &&
|
||||
ctx.passwordPromptActiveRef?.current !== true &&
|
||||
!sudoAutofill?.isPromptPending() &&
|
||||
!term.modes.win32InputMode &&
|
||||
!isKittyKeyboardModeActive(kittyKeyboardMode)
|
||||
? commandArrowLineJumpSequence(e, isMacPlatform())
|
||||
: null;
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
|
||||
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
if (!consumed) return false; // Event was consumed by autocomplete
|
||||
}
|
||||
@@ -2267,6 +2279,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep app/snippet shortcuts and negotiated keyboard protocols ahead of
|
||||
// the shell-only macOS line-editing fallback (issue #3139).
|
||||
if (lineJumpSequence && ctx.sessionRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// This is a local shell editing shortcut. Peers may be running a TUI
|
||||
// or another keyboard protocol, so do not fan out the mapped Ctrl key.
|
||||
handleTerminalInputData(lineJumpSequence, { skipBroadcast: true });
|
||||
scrollToBottomAfterInput(lineJumpSequence);
|
||||
return false;
|
||||
}
|
||||
|
||||
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
|
||||
// #826). After kitty mode so apps using the kitty protocol keep their own
|
||||
// arrow encoding; read live so the toggle applies without reconnecting.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installRightClickLongPress, RIGHT_CLICK_LONG_PRESS_MS } from './rightClickLongPress';
|
||||
|
||||
for (const nativeTiming of ['press', 'release'] as const) {
|
||||
for (const long of [false, true]) {
|
||||
test(`${nativeTiming}-time contextmenu: ${long ? 'long' : 'short'} press dispatches once`, (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const dom = new JSDOM('<div id="root"><span></span></div>');
|
||||
const root = dom.window.document.querySelector<HTMLElement>('#root')!;
|
||||
const target = root.firstElementChild!;
|
||||
const dispose = installRightClickLongPress(root, () => true);
|
||||
const received: boolean[] = [];
|
||||
root.addEventListener('contextmenu', (event) => received.push(event.shiftKey));
|
||||
const fire = (type: string) => target.dispatchEvent(new dom.window.MouseEvent(type, {
|
||||
bubbles: true, cancelable: true, button: 2, buttons: type === 'mouseup' ? 0 : 2,
|
||||
}));
|
||||
fire('mousedown');
|
||||
if (nativeTiming === 'press') fire('contextmenu');
|
||||
assert.deepEqual(received, []);
|
||||
t.mock.timers.tick(long ? RIGHT_CLICK_LONG_PRESS_MS : 100);
|
||||
fire('mouseup');
|
||||
if (nativeTiming === 'release') fire('contextmenu');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(received, [long]);
|
||||
dispose();
|
||||
dom.window.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const cancelBy of ['move', 'blur', 'outside-release', 'dispose', 'mouse-mode']) {
|
||||
test(`long press cancels on ${cancelBy}`, (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const dom = new JSDOM('<div id="root"></div>');
|
||||
const root = dom.window.document.querySelector<HTMLElement>('#root')!;
|
||||
let enabled = true;
|
||||
const dispose = installRightClickLongPress(root, () => enabled);
|
||||
let menus = 0;
|
||||
root.addEventListener('contextmenu', () => menus++);
|
||||
root.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true, button: 2 }));
|
||||
if (cancelBy === 'move') root.dispatchEvent(new dom.window.MouseEvent('mousemove', { bubbles: true, buttons: 2, clientX: 20 }));
|
||||
if (cancelBy === 'blur') dom.window.dispatchEvent(new dom.window.Event('blur'));
|
||||
if (cancelBy === 'outside-release') dom.window.document.body.dispatchEvent(new dom.window.MouseEvent('mouseup', { bubbles: true, button: 2 }));
|
||||
if (cancelBy === 'dispose') dispose();
|
||||
if (cancelBy === 'mouse-mode') enabled = false;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(menus, 0);
|
||||
dispose();
|
||||
dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('disabled or mouse-owned gestures retain the full original event sequence', () => {
|
||||
const dom = new JSDOM('<div></div>');
|
||||
const root = dom.window.document.querySelector('div')!;
|
||||
const dispose = installRightClickLongPress(root, () => false);
|
||||
const received: string[] = [];
|
||||
for (const type of ['mousedown', 'contextmenu', 'mouseup']) {
|
||||
root.addEventListener(type, () => received.push(type));
|
||||
root.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, button: 2 }));
|
||||
}
|
||||
assert.deepEqual(received, ['mousedown', 'contextmenu', 'mouseup']);
|
||||
dispose();
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Delay the configured short-click action only while a local long press is
|
||||
// possible. Mouse-reporting applications retain their original press/release.
|
||||
export const RIGHT_CLICK_LONG_PRESS_MS = 500;
|
||||
|
||||
export function installRightClickLongPress(
|
||||
root: HTMLElement,
|
||||
canStart: () => boolean,
|
||||
): () => void {
|
||||
const doc = root.ownerDocument;
|
||||
const win = doc.defaultView!;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let press: MouseEvent | undefined;
|
||||
let suppressContextMenu = false;
|
||||
const replayEvents = new WeakSet<Event>();
|
||||
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
press = undefined;
|
||||
};
|
||||
const dispatch = (event: MouseEvent, longPress: boolean) => {
|
||||
const replay = new win.MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
// The existing Shift+right-click path opens the same menu.
|
||||
shiftKey: longPress,
|
||||
});
|
||||
replayEvents.add(replay);
|
||||
// Dispatch on the surface to avoid xterm's textarea focus interception.
|
||||
root.dispatchEvent(replay);
|
||||
};
|
||||
const onDown = (event: MouseEvent) => {
|
||||
cancel();
|
||||
suppressContextMenu = false;
|
||||
if (event.button !== 2 || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey
|
||||
|| !canStart()
|
||||
|| (event.target as Element | null)?.closest('[data-terminal-history-preview]')) return;
|
||||
press = event;
|
||||
suppressContextMenu = true;
|
||||
timer = setTimeout(() => {
|
||||
const pending = press;
|
||||
cancel();
|
||||
if (pending && canStart()) dispatch(pending, true);
|
||||
}, RIGHT_CLICK_LONG_PRESS_MS);
|
||||
};
|
||||
const onUp = (event: MouseEvent) => {
|
||||
if (event.button !== 2 || !press) return;
|
||||
const pending = press;
|
||||
cancel();
|
||||
if (root.contains(event.target as Node) && canStart()) dispatch(pending, false);
|
||||
};
|
||||
const onMove = (event: MouseEvent) => {
|
||||
if (press && (Math.hypot(event.clientX - press.clientX, event.clientY - press.clientY) > 8
|
||||
|| !(event.buttons & 2))) cancel();
|
||||
};
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
// Native contextmenu arrives on press on macOS/Linux, on release on Windows.
|
||||
if (suppressContextMenu && event.button === 2 && !replayEvents.has(event)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
root.addEventListener('mousedown', onDown, true);
|
||||
root.addEventListener('contextmenu', onContextMenu, true);
|
||||
doc.addEventListener('mouseup', onUp, true);
|
||||
doc.addEventListener('mousemove', onMove, true);
|
||||
win.addEventListener('blur', cancel);
|
||||
return () => {
|
||||
cancel();
|
||||
root.removeEventListener('mousedown', onDown, true);
|
||||
root.removeEventListener('contextmenu', onContextMenu, true);
|
||||
doc.removeEventListener('mouseup', onUp, true);
|
||||
doc.removeEventListener('mousemove', onMove, true);
|
||||
win.removeEventListener('blur', cancel);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { useTerminalContextActions } from './hooks/useTerminalContextActions';
|
||||
|
||||
test('saving captures the screen before the dialog, prevents duplicate dialogs, and supports preview and cancellation', async () => {
|
||||
const dom = new JSDOM('<div id="root"></div><div id="terminal"></div>', { url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const requests: Array<Parameters<NonNullable<NetcattyBridge['exportSessionLog']>>[0]> = [];
|
||||
let finishSave: (result: { success: boolean; canceled?: boolean }) => void = () => {};
|
||||
Object.assign(dom.window, { netcatty: { exportSessionLog: (payload: typeof requests[number]) => {
|
||||
requests.push(payload);
|
||||
return new Promise((resolve) => { finishSave = resolve; });
|
||||
} } });
|
||||
let visibleText = 'corrected';
|
||||
const term = {
|
||||
cols: 80, rows: 1, element: dom.window.document.createElement('div'),
|
||||
buffer: { active: { type: 'normal', viewportY: 0, length: 1,
|
||||
getLine: () => ({ translateToString: () => visibleText }),
|
||||
} },
|
||||
};
|
||||
dom.window.document.getElementById('terminal')!.append(term.element);
|
||||
const termRef = { current: term as unknown as Terminal };
|
||||
let actions!: ReturnType<typeof useTerminalContextActions>;
|
||||
function Harness() {
|
||||
actions = useTerminalContextActions({ termRef, sourceSessionId: 'session', sessionName: 'My terminal',
|
||||
sessionRef: { current: null }, isLocalConnection: true, supportsRemoteImagePaste: false,
|
||||
terminalBackend: { writeToSession() {} },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(dom.window.document.getElementById('root')!);
|
||||
try {
|
||||
await act(async () => root.render(<Harness />));
|
||||
const firstSave = actions.onSaveScreen();
|
||||
visibleText = 'later output';
|
||||
await actions.onSaveScreen();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].terminalData, 'corrected');
|
||||
assert.equal(requests[0].plainText, true);
|
||||
assert.equal(requests[0].hostLabel, 'My terminal');
|
||||
finishSave({ success: false, canceled: true });
|
||||
await firstSave;
|
||||
const preview = dom.window.document.createElement('pre');
|
||||
preview.setAttribute('data-terminal-history-preview', 'true');
|
||||
preview.textContent = 'history\n\n row';
|
||||
term.element.parentElement!.append(preview);
|
||||
const secondSave = actions.onSaveScreen();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].terminalData, 'history\n\n row');
|
||||
finishSave({ success: true });
|
||||
await secondSave;
|
||||
termRef.current = null as unknown as Terminal;
|
||||
await actions.onSaveScreen();
|
||||
assert.equal(requests.length, 2);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -57,3 +57,26 @@ test("readActiveTerminalBufferTextRange reads the active alternate buffer withou
|
||||
"vim title\nfile body\n:",
|
||||
);
|
||||
});
|
||||
|
||||
import { readTerminalScreenText } from "./terminalContextBuffer.ts";
|
||||
|
||||
test("screen export follows the scrolled viewport and excludes hidden history", () => {
|
||||
const buffer = { ...createBuffer(["old", "visible one", " visible two", "newest"]),
|
||||
viewportY: 1, length: 4, type: "normal" };
|
||||
assert.equal(readTerminalScreenText({ cols: 80, rows: 2, buffer: { active: buffer } } as never),
|
||||
"visible one\n visible two");
|
||||
});
|
||||
|
||||
test("screen export preserves blank and wrapped physical rows and clips hidden columns", () => {
|
||||
const buffer = { ...createBuffer(["123456", "78", "", "end", ""]),
|
||||
viewportY: 0, length: 5, type: "normal" };
|
||||
assert.equal(readTerminalScreenText({ cols: 4, rows: 5, buffer: { active: buffer } } as never),
|
||||
"1234\n78\n\nend\n");
|
||||
});
|
||||
|
||||
test("screen export reads only the alternate screen while fullscreen apps are active", () => {
|
||||
const buffer = { ...createBuffer(["vim", "text", ""]),
|
||||
viewportY: 0, length: 3, type: "alternate" };
|
||||
assert.equal(readTerminalScreenText({ cols: 80, rows: 3, buffer: { active: buffer } } as never),
|
||||
"vim\ntext\n");
|
||||
});
|
||||
@@ -40,3 +40,13 @@ export function readTerminalBufferTextRange(
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Read the rendered viewport, preserving its physical row boundaries. */
|
||||
export function readTerminalScreenText(term: Pick<XTerm, "buffer" | "cols" | "rows">): string {
|
||||
const buffer = term.buffer.active;
|
||||
const startLine = buffer.type === "alternate" ? 0 : buffer.viewportY;
|
||||
return readActiveTerminalBufferTextRange(term, {
|
||||
startLine,
|
||||
endLine: Math.min(buffer.length, startLine + term.rows) - 1,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publishTerminalCommandCompletion } from "../../application/state/terminalCommandCompletion";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
|
||||
@@ -189,6 +190,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
|
||||
};
|
||||
const pluginAwareOnCommandCompleted = () => {
|
||||
publishTerminalCommandCompletion(sessionId);
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTopTabsChromeRoots } from '../../application/app/topTabsChromeTheme';
|
||||
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
@@ -227,22 +228,22 @@ export const clearHostTreePreviewVars = () => {
|
||||
|
||||
export const clearTopTabsPreviewVars = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
for (const tabsRoot of getTopTabsChromeRoots()) {
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
}
|
||||
};
|
||||
|
||||
export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
|
||||
@@ -338,8 +338,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
workspaceOuterRef.current,
|
||||
)}
|
||||
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
{activeWorkspace && focusedSessionId && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
sessionId={focusedSessionId}
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { canUseDirectSessionWriteFallback } from './terminalLayerSessionRouting';
|
||||
|
||||
// Exercise the actual workspace send callback without constructing terminals.
|
||||
const source = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf(' const handleComposeSend =');
|
||||
const end = source.indexOf(' const sessionLogConfig =', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = handleComposeSend;', {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
type Status = 'connected' | 'connecting' | 'disconnected';
|
||||
type Executor = () => boolean | Promise<boolean>;
|
||||
function setup(statuses: Status[], broadcast = false, executors = new Map<string, Executor>(), sensitive = new Set<string>()) {
|
||||
const writes: string[] = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
useCallback: (callback: unknown) => callback,
|
||||
activeWorkspaceRef: { current: { id: 'workspace', focusedSessionId: '0' } },
|
||||
sessionsRef: { current: statuses.map((status, index) => ({ id: String(index), workspaceId: 'workspace', status })) },
|
||||
isBroadcastEnabled: () => broadcast,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
snippetExecutorsRef: { current: executors },
|
||||
canUseDirectSessionWriteFallback,
|
||||
terminalBackend: { writeToSession: (id: string) => writes.push(id) },
|
||||
};
|
||||
runInNewContext(code, context);
|
||||
return { send: context.send, writes };
|
||||
}
|
||||
|
||||
for (const broadcast of [false, true]) {
|
||||
for (const status of ['connected', 'connecting', 'disconnected'] as const) {
|
||||
test(`fallback history requires a connected session (${status}, broadcast=${broadcast})`, async () => {
|
||||
const { send, writes } = setup([status], broadcast);
|
||||
assert.equal(await send('command'), status === 'connected');
|
||||
assert.deepEqual(writes, ['0']); // Existing fallback dispatch is unchanged.
|
||||
});
|
||||
}
|
||||
for (const accepted of [false, true]) {
|
||||
test(`executor result controls history (${accepted}, broadcast=${broadcast})`, async () => {
|
||||
const { send } = setup(['connected'], broadcast, new Map([['0', async () => accepted]]));
|
||||
assert.equal(await send('command'), accepted);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
|
||||
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
|
||||
});
|
||||
|
||||
test('sensitive input is excluded for executor and fallback paths', async () => {
|
||||
for (const broadcast of [false, true]) {
|
||||
for (const executors of [new Map<string, Executor>(), new Map<string, Executor>([['0', async () => true]])]) {
|
||||
assert.equal(await setup(['connected'], broadcast, executors, new Set(['0'])).send('secret'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('broadcast still records a successful send when another executor rejects', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
['1', async () => { throw new Error('failed'); }],
|
||||
]);
|
||||
assert.equal(await setup(['connected', 'connected'], true, executors).send('command'), true);
|
||||
});
|
||||
|
||||
test('all failed workspace executors do not record history', async () => {
|
||||
for (const broadcast of [false, true]) {
|
||||
const executors = new Map<string, Executor>([['0', async () => { throw new Error('failed'); }]]);
|
||||
assert.equal(await setup(['connected'], broadcast, executors).send('command'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('executor waking into a sensitive prompt does not record history', async () => {
|
||||
const sensitive = new Set<string>();
|
||||
const executors = new Map<string, Executor>([['0', async () => { sensitive.add('0'); return true; }]]);
|
||||
assert.equal(await setup(['connected'], false, executors, sensitive).send('command'), false);
|
||||
});
|
||||
|
||||
const soloSource = readFileSync(new URL('../terminal/TerminalView.tsx', import.meta.url), 'utf8');
|
||||
const soloStart = soloSource.indexOf('onSend={async (text) => {');
|
||||
const soloEnd = soloSource.indexOf('\n }}', soloStart);
|
||||
assert.ok(soloStart >= 0 && soloEnd > soloStart);
|
||||
const soloCode = ts.transpileModule('globalThis.send = ' + soloSource.slice(
|
||||
soloStart + 'onSend={'.length, soloEnd + '\n }'.length,
|
||||
), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)]) {
|
||||
test(`solo send uses executor acknowledgement (${typeof result})`, async () => {
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: () => result,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), await result);
|
||||
});
|
||||
}
|
||||
|
||||
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: null as string | null },
|
||||
executeSnippetCommand: async () => true,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), false);
|
||||
context.sessionRef.current = 'a';
|
||||
context.isTerminalSensitiveInputActive = () => true;
|
||||
assert.equal(await context.send('secret'), false);
|
||||
context.isTerminalSensitiveInputActive = () => false;
|
||||
context.executeSnippetCommand = async () => { throw new Error('failed'); };
|
||||
await assert.rejects(context.send('command'), /failed/);
|
||||
});
|
||||
@@ -43,12 +43,26 @@ export function SessionTabContextMenuContent({
|
||||
}: SessionTabContextMenuContentProps) {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={isSessionReconnectDisabled(sessionStatus, reconnectActive)}
|
||||
onClick={() => onReconnectSession(sessionId)}
|
||||
>
|
||||
{t('terminal.menu.reconnect')}
|
||||
</ContextMenuItem>
|
||||
{onCopySession && (
|
||||
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySessionToNewWindow && (
|
||||
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
|
||||
{t('tabs.copyTabToNewWindow')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDuplicateSession && (
|
||||
<ContextMenuItem onClick={() => onDuplicateSession(sessionId)}>
|
||||
{t('tabs.duplicateSession')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDetachSession && (
|
||||
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
@@ -57,26 +71,12 @@ export function SessionTabContextMenuContent({
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySession && (
|
||||
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDuplicateSession && (
|
||||
<ContextMenuItem onClick={() => onDuplicateSession(sessionId)}>
|
||||
{t('tabs.duplicateSession')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySessionToNewWindow && (
|
||||
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
|
||||
{t('tabs.copyTabToNewWindow')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDetachSession && (
|
||||
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
disabled={isSessionReconnectDisabled(sessionStatus, reconnectActive)}
|
||||
onClick={() => onReconnectSession(sessionId)}
|
||||
>
|
||||
{t('terminal.menu.reconnect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(sessionId)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { resolveSessionCodingCliProvider } from '../../domain/codingCliProviderM
|
||||
import type { CodingCliProvider } from '../../domain/codingCliProviders';
|
||||
import { resolveCodingCliActivityPhase, type CodingCliActivityPhase } from '../../domain/codingCliTitleParse';
|
||||
import { resolveSessionTabTitle, resolveWorkspaceTabLabel } from '../../domain/sessionTabTitle';
|
||||
import type { DynamicTabTitleMode } from '../../domain/models';
|
||||
import type { DynamicTabTitleMode, TerminalTabDoubleClickBehavior } from '../../domain/models';
|
||||
import { CodingCliProviderIcon } from '../icons/CodingCliProviderIcon';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../../types';
|
||||
@@ -242,9 +242,28 @@ export const formatSessionTopTabLabel = (
|
||||
};
|
||||
|
||||
export const createTopTabCopyDoubleClickHandler = (
|
||||
onCopySession: (sessionId: string) => void,
|
||||
sessionId: string,
|
||||
): React.MouseEventHandler<HTMLDivElement> => () => onCopySession(sessionId);
|
||||
onCopy: (id: string) => void,
|
||||
id: string,
|
||||
): React.MouseEventHandler<HTMLDivElement> => () => onCopy(id);
|
||||
|
||||
export const createTopTabSessionDoubleClickHandler = ({
|
||||
behavior,
|
||||
onCopySession,
|
||||
onDuplicateSession,
|
||||
sessionId,
|
||||
}: {
|
||||
behavior: TerminalTabDoubleClickBehavior;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onDuplicateSession?: (sessionId: string) => void;
|
||||
sessionId: string;
|
||||
}): React.MouseEventHandler<HTMLDivElement> => () => {
|
||||
if (behavior === 'disabled') return;
|
||||
if (behavior === 'duplicate') {
|
||||
(onDuplicateSession ?? onCopySession)(sessionId);
|
||||
return;
|
||||
}
|
||||
onCopySession(sessionId);
|
||||
};
|
||||
|
||||
export const stopCloseButtonDoubleClickPropagation = (
|
||||
event: Pick<React.MouseEvent, 'stopPropagation'>,
|
||||
@@ -713,6 +732,7 @@ interface SessionTopTabProps {
|
||||
onEditHost?: (host: Host) => void;
|
||||
renderBulkCloseItems: RenderBulkCloseItems;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
|
||||
t: TranslateFn;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
@@ -739,6 +759,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onEditHost,
|
||||
renderBulkCloseItems,
|
||||
dynamicTabTitleMode,
|
||||
tabDoubleClickBehavior,
|
||||
t,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
@@ -757,8 +778,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
activeTabStore.setActiveTabId(session.id);
|
||||
}, [session.id]);
|
||||
const handleDoubleClick = useMemo(
|
||||
() => createTopTabCopyDoubleClickHandler(onCopySession, session.id),
|
||||
[onCopySession, session.id],
|
||||
() => createTopTabSessionDoubleClickHandler({
|
||||
behavior: tabDoubleClickBehavior,
|
||||
onCopySession,
|
||||
onDuplicateSession,
|
||||
sessionId: session.id,
|
||||
}),
|
||||
[tabDoubleClickBehavior, onCopySession, onDuplicateSession, session.id],
|
||||
);
|
||||
const addressTooltip = formatSessionTopTabTooltip(session);
|
||||
const tabTitle = formatSessionTopTabLabel(session, dynamicTabTitleMode);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,SortAsc,SortDesc } from 'lucide-react';
|
||||
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,Network,SortAsc,SortDesc } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from './button';
|
||||
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
|
||||
|
||||
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
|
||||
|
||||
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
|
||||
manual: { labelKey: 'sort.manual', icon: <GripVertical className="w-4 h-4 shrink-0" />, triggerIcon: <GripVertical className="w-4 h-4" /> },
|
||||
@@ -13,6 +13,7 @@ const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElemen
|
||||
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
|
||||
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
|
||||
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
|
||||
ip: { labelKey: 'sort.ip', icon: <Network className="w-4 h-4 shrink-0" />, triggerIcon: <Network className="w-4 h-4" /> },
|
||||
};
|
||||
|
||||
interface SortDropdownProps {
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user