mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +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 | ||
|
|
2738adb2f8 | ||
|
|
688f544034 | ||
|
|
f9ad1ddfe0 | ||
|
|
a0d032ec3c | ||
|
|
e9b0dadd2a | ||
|
|
be511f8546 | ||
|
|
214bc8e7f1 | ||
|
|
758029bc29 | ||
|
|
3e17cdd62e | ||
|
|
8b5db49001 | ||
|
|
87f794d17d | ||
|
|
cb35353fde | ||
|
|
ce86e8e426 | ||
|
|
0714ca5ee0 | ||
|
|
b081673c2b | ||
|
|
1fc98cd654 | ||
|
|
28f7193c4a | ||
|
|
dbdc8631dd | ||
|
|
a05e004259 | ||
|
|
cae4169948 | ||
|
|
3987e465b6 | ||
|
|
d8761c156e | ||
|
|
47bb5c2b55 | ||
|
|
dc05697ede | ||
|
|
51d4d97419 | ||
|
|
530f57c95c | ||
|
|
da0938c209 | ||
|
|
9e6846e0d5 | ||
|
|
0b96d70702 | ||
|
|
818c6fd551 |
No files matched your search
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|---------|-------------------|--------|
|
||||
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
|
||||
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
|
||||
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
|
||||
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
|
||||
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
|
||||
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime (internal preview)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}',
|
||||
@@ -652,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).',
|
||||
@@ -709,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',
|
||||
@@ -567,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ú).',
|
||||
@@ -624,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}',
|
||||
@@ -609,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+правый щелчок всё равно открывает меню).',
|
||||
@@ -666,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}',
|
||||
@@ -1049,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}',
|
||||
@@ -1051,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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1981,3 +1981,89 @@ test("tab bar position survives settings export and import, with a safe fallback
|
||||
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 });
|
||||
});
|
||||
+50
-21
@@ -1,4 +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.
|
||||
@@ -261,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',
|
||||
@@ -402,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 } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -564,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);
|
||||
@@ -626,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);
|
||||
@@ -663,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);
|
||||
@@ -824,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);
|
||||
@@ -1145,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: {
|
||||
@@ -1153,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) {
|
||||
@@ -1189,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
|
||||
@@ -1200,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?.();
|
||||
@@ -1215,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,
|
||||
@@ -1244,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':
|
||||
|
||||
@@ -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,
|
||||
@@ -2841,8 +2843,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
currentRow: buffer.baseY + buffer.cursorY,
|
||||
lines,
|
||||
};
|
||||
});
|
||||
}, [sessionId]);
|
||||
}, readTerminalContext);
|
||||
}, [readTerminalContext, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const startHandler = (event: Event) => {
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupVaultAgentBridge } from "../infrastructure/ai/vaultAgentBridgeClient";
|
||||
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -537,6 +538,8 @@ export default function TerminalPopupPage({
|
||||
settings: SettingsState;
|
||||
allowTerminalStart?: boolean;
|
||||
}) {
|
||||
useEffect(() => setupVaultAgentBridge(), []);
|
||||
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TerminalPopupPageInner
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -901,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)}
|
||||
@@ -1320,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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -893,6 +893,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate">{node.name}</span>
|
||||
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
|
||||
{!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { compareHostAddresses } from "../../domain/hostAddressSort";
|
||||
import { upsertKnownHost } from "../../domain/knownHosts";
|
||||
import { sortByVaultOrder, sortVaultStringsByOrder } from "../../domain/vaultOrder";
|
||||
import { matchesHostSearchQuery, matchesSearchQuery } from "../../lib/searchMatcher";
|
||||
@@ -123,6 +124,10 @@ export function useVaultHostCollections({
|
||||
const groupCmp = groupA.localeCompare(groupB);
|
||||
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
|
||||
}
|
||||
case "ip": {
|
||||
const addrCmp = compareHostAddresses(a.hostname, b.hostname);
|
||||
return addrCmp !== 0 ? addrCmp : a.label.localeCompare(b.label);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -29,20 +29,20 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="167.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="242.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="324.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="404.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="487.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="567.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="650.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="733.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
|
||||
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
|
||||
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
|
||||
<circle cx="765.0" cy="85.3" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5917</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -29,20 +29,20 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="167.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="242.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="324.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="404.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="487.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="567.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="650.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="733.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
|
||||
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
|
||||
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
|
||||
<circle cx="765.0" cy="85.3" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5917</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendComposeBarHistory,
|
||||
canNavigateComposeBarHistory,
|
||||
COMPOSE_BAR_HISTORY_MAX,
|
||||
navigateComposeBarHistory,
|
||||
} from './composeBarHistory';
|
||||
|
||||
test('appendComposeBarHistory ignores empty/whitespace-only commands', () => {
|
||||
assert.deepEqual(appendComposeBarHistory(['ls'], ' '), ['ls']);
|
||||
assert.deepEqual(appendComposeBarHistory([], ''), []);
|
||||
});
|
||||
|
||||
test('appendComposeBarHistory appends newest entries and dedupes consecutive repeats', () => {
|
||||
assert.deepEqual(appendComposeBarHistory([], 'ls -la'), ['ls -la']);
|
||||
assert.deepEqual(appendComposeBarHistory(['ls -la'], 'ls -la'), ['ls -la']);
|
||||
assert.deepEqual(
|
||||
appendComposeBarHistory(['ls -la'], 'df -h'),
|
||||
['ls -la', 'df -h'],
|
||||
);
|
||||
});
|
||||
|
||||
test('appendComposeBarHistory preserves multi-line commands and caps length', () => {
|
||||
const multi = 'echo one\necho two';
|
||||
assert.deepEqual(appendComposeBarHistory(['pwd'], multi), ['pwd', multi]);
|
||||
|
||||
const seeded = Array.from({ length: COMPOSE_BAR_HISTORY_MAX }, (_, i) => `cmd-${i}`);
|
||||
const next = appendComposeBarHistory(seeded, 'overflow');
|
||||
assert.equal(next.length, COMPOSE_BAR_HISTORY_MAX);
|
||||
assert.equal(next[0], 'cmd-1');
|
||||
assert.equal(next[next.length - 1], 'overflow');
|
||||
});
|
||||
|
||||
test('canNavigateComposeBarHistory only steals arrows at first/last line', () => {
|
||||
const multi = 'line1\nline2\nline3';
|
||||
assert.equal(canNavigateComposeBarHistory(multi, 0, 'up'), true);
|
||||
assert.equal(canNavigateComposeBarHistory(multi, 3, 'up'), true);
|
||||
assert.equal(canNavigateComposeBarHistory(multi, 6, 'up'), false);
|
||||
assert.equal(canNavigateComposeBarHistory(multi, multi.length, 'down'), true);
|
||||
assert.equal(canNavigateComposeBarHistory(multi, 6, 'down'), false);
|
||||
assert.equal(canNavigateComposeBarHistory('single', 3, 'up'), true);
|
||||
assert.equal(canNavigateComposeBarHistory('single', 3, 'down'), true);
|
||||
assert.equal(canNavigateComposeBarHistory(multi, 0, 'up', 6), false);
|
||||
});
|
||||
|
||||
test('navigateComposeBarHistory walks older/newer entries and restores the draft', () => {
|
||||
const entries = ['one', 'two', 'three'];
|
||||
|
||||
const firstUp = navigateComposeBarHistory(
|
||||
{ entries, index: entries.length, draft: '', currentValue: 'draft' },
|
||||
'up',
|
||||
);
|
||||
assert.deepEqual(firstUp, { index: 2, value: 'three', draft: 'draft' });
|
||||
|
||||
const secondUp = navigateComposeBarHistory(
|
||||
{ entries, index: 2, draft: 'draft', currentValue: 'three' },
|
||||
'up',
|
||||
);
|
||||
assert.deepEqual(secondUp, { index: 1, value: 'two', draft: 'draft' });
|
||||
|
||||
const downToDraft = navigateComposeBarHistory(
|
||||
{ entries, index: 2, draft: 'draft', currentValue: 'three' },
|
||||
'down',
|
||||
);
|
||||
assert.deepEqual(downToDraft, { index: 3, value: 'draft', draft: 'draft' });
|
||||
|
||||
assert.equal(
|
||||
navigateComposeBarHistory(
|
||||
{ entries, index: 0, draft: 'draft', currentValue: 'one' },
|
||||
'up',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
navigateComposeBarHistory(
|
||||
{ entries, index: entries.length, draft: 'draft', currentValue: 'draft' },
|
||||
'down',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
navigateComposeBarHistory(
|
||||
{ entries: [], index: 0, draft: '', currentValue: '' },
|
||||
'up',
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Max in-memory compose-bar send history entries (oldest dropped). */
|
||||
export const COMPOSE_BAR_HISTORY_MAX = 100;
|
||||
|
||||
export type ComposeBarHistoryDirection = 'up' | 'down';
|
||||
|
||||
export interface ComposeBarHistoryNavInput {
|
||||
entries: readonly string[];
|
||||
/** `entries.length` means the live draft; `0..length-1` browses history. */
|
||||
index: number;
|
||||
draft: string;
|
||||
currentValue: string;
|
||||
}
|
||||
|
||||
export interface ComposeBarHistoryNavResult {
|
||||
index: number;
|
||||
value: string;
|
||||
draft: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a sent compose-bar command. Skips empties, consecutive duplicates,
|
||||
* and trims the ring to {@link COMPOSE_BAR_HISTORY_MAX}.
|
||||
*/
|
||||
export function appendComposeBarHistory(
|
||||
entries: readonly string[],
|
||||
command: string,
|
||||
max = COMPOSE_BAR_HISTORY_MAX,
|
||||
): string[] {
|
||||
if (!command || !command.trim()) return [...entries];
|
||||
if (entries[entries.length - 1] === command) return [...entries];
|
||||
return [...entries, command].slice(-Math.max(1, max));
|
||||
}
|
||||
|
||||
/** True when Up/Down should recall history instead of moving within the textarea. */
|
||||
export function canNavigateComposeBarHistory(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
direction: ComposeBarHistoryDirection,
|
||||
selectionEnd = selectionStart,
|
||||
): boolean {
|
||||
if (selectionStart !== selectionEnd) return false;
|
||||
const cursor = Math.max(0, Math.min(selectionStart, value.length));
|
||||
if (direction === 'up') {
|
||||
return !value.slice(0, cursor).includes('\n');
|
||||
}
|
||||
return !value.slice(cursor).includes('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-style history walk for the compose bar: Up goes older, Down goes newer,
|
||||
* and moving past the newest entry restores the saved draft.
|
||||
*/
|
||||
export function navigateComposeBarHistory(
|
||||
state: ComposeBarHistoryNavInput,
|
||||
direction: ComposeBarHistoryDirection,
|
||||
): ComposeBarHistoryNavResult | null {
|
||||
const { entries } = state;
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const liveIndex = entries.length;
|
||||
let index = Number.isFinite(state.index) ? state.index : liveIndex;
|
||||
if (index < 0) index = 0;
|
||||
if (index > liveIndex) index = liveIndex;
|
||||
|
||||
if (direction === 'up') {
|
||||
if (index <= 0) return null;
|
||||
const draft = index === liveIndex ? state.currentValue : state.draft;
|
||||
const nextIndex = index - 1;
|
||||
return { index: nextIndex, value: entries[nextIndex] ?? '', draft };
|
||||
}
|
||||
|
||||
if (index >= liveIndex) return null;
|
||||
const nextIndex = index + 1;
|
||||
if (nextIndex >= liveIndex) {
|
||||
return { index: liveIndex, value: state.draft, draft: state.draft };
|
||||
}
|
||||
return { index: nextIndex, value: entries[nextIndex] ?? '', draft: state.draft };
|
||||
}
|
||||
@@ -581,3 +581,10 @@ test("applyGroupDefaults keeps host algorithm overrides instead of inheriting",
|
||||
);
|
||||
assert.deepEqual(result.algorithms, hostOverrides);
|
||||
});
|
||||
|
||||
test("group notes survive sanitization but are not connection defaults", () => {
|
||||
const config = { path: "Project", notes: "# VPN\nConnect before SSH.", username: "admin" };
|
||||
assert.equal(sanitizeGroupConfig(config).notes, config.notes);
|
||||
assert.equal(resolveGroupDefaults("Project", [config]).notes, undefined);
|
||||
assert.equal(resolveGroupDefaults("Project/Servers", [config]).notes, undefined);
|
||||
});
|
||||
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