Compare commits

...
Author SHA1 Message Date
陈大猫 3438570c53 fix(linux): use Secret Service keyring on non-GNOME/KDE desktops
Chromium only enables gnome-libsecret or kwallet when it recognizes a
GNOME/KDE desktop name, so niri/sway/Hyprland fall through to BASIC_TEXT
even when org.freedesktop.secrets is available. Set --password-store
before app ready when Secret Service is present, without overriding an
explicit switch or KDE's kwallet default.
2026-11-09 00:59:03 +08:00
陈大猫 d536b22dee fix(host-details): show inherited group proxy and jump hosts (#4188)
* fix(host-details): show inherited group proxy and jump hosts

Host editor summaries for HTTP/SOCKS5/command proxy and ProxyJump
chains now display the same values applyGroupDefaults uses at
connect time, without writing inherited settings onto the host
until the user edits them.

Fixes #4187

* fix(host-details): keep custom proxy and empty jump-host overrides

Selecting Custom while a group saved proxy is inherited now keeps a host-owned draft instead of snapping back to that profile. Removing the last hop of a multi-hop inherited chain keeps an empty override so applyGroupDefaults cannot restore the group hops.

* fix(host-details): keep empty override when clearing inherited chain

Clear Chain on an inherited-only jump-host list now stores { hostIds: [] }
instead of deleting the field so group hops cannot snap back.
2026-11-08 21:30:36 +08:00
陈大猫 74d5e126d4 fix(system-manager): show all CPU cores in the overview list (#4184)
* fix(system-manager): show all CPU cores in the overview list

The overview CPU card hardcoded cpuPerCore.slice(0, 12), so a 16-thread
host showed "16 核" at the top but only C1–C12 below. Render every
reported core and allow the list to scroll when it overflows.

Fixes #4183

* ci: retrigger lint-and-test after plugin-contract runner shutdown
2026-11-08 12:59:27 +08:00
jinshenganyuciandbincxz 4a55492c17 feat(sftp): create archives from the context menu (#3936)
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 20:44:49 +08:00
rnarany 51d9f715da feat(autocomplete): localize common commands and polish terminal hints (#4172)
* feat(autocomplete): add zh-CN descriptions for zip, unzip, make, nano

Cover 32 missing Fig descriptions, generate zh-TW, add batch coverage tests, and record source and semantic checks.

* fix(autocomplete): correct zip, unzip, and make semantics

Limit zip -9 to the compression level, warn that unzip -0 is a suspect upstream option, and describe make -N as the Apple/NeXT compatibility extension.

* fix(autocomplete): simplify unzip -0 warning and correct NeXT note

Keep the -O gbk version caveat in the research record and describe Next/NeXT as a capitalization difference.

* feat(autocomplete): complete remaining common command descriptions

Add 177 zh-CN descriptions for python, python3, node, vim, sort, awk, and sed; generate zh-TW, cover the batch with real Fig specs, and record one deferred shared node key.

* fix(autocomplete): correct sed n and sort parallel semantics

Add the automatic-print condition to sed n, remove the universal CPU default from sort --parallel, and remove duplicated adopted-entry sections from the source record.

* fix: repair macOS terminal setup and align autocomplete details

- Read OSC 7 setup payloads through base64 stdin so BSD/macOS works
- Keep the FIDO askpass socket path within the Unix sun_path limit
- Align autocomplete descriptions in a shared fixed column
- Fix an invalid optional-parameter declaration and lint warnings
- Harden macOS-sensitive OSC 7, SFTP, temp-dir and privacy tests

* feat(autocomplete): add zh-CN descriptions for file and text utilities

Cover ln, stat, wc, cut, uniq, tee and xargs with 70 localized
descriptions. Add coverage and localization tests, preload the new common
specs, and record the upstream wc/xargs wording corrections.

* fix(autocomplete): keep popup scrollbars draggable

Panel mousedown handlers no longer preventDefault across the whole box, so the right-side detail scrollbar and the suggestion list scrollbar can be dragged while padding clicks still preserve terminal focus.

* fix(autocomplete): keep detail panel open while selecting text

Delay row hover clearing so the pointer can move into the right-side detail panel, and allow native text selection there without stealing terminal focus on ordinary padding clicks.
2026-11-07 20:00:43 +08:00
陈大猫 23d3fe454b Merge pull request #4158 from GOUKI9999/feat/workspace-pane-rearrange
Rearrange workspace panes and retain empty/single-member workspaces.

Includes #4155 with its workspace lifecycle, chat continuity, empty-state, action-wiring and rendering-performance fixes. Final Codex review is clean; all CI checks and real Electron smoke passed.
2026-11-07 19:57:39 +08:00
bincxz 3622d482a4 perf(workspace): keep pane drop callbacks stable across tab switches 2026-11-07 19:34:58 +08:00
bincxz c1c5787cab fix(workspace): preserve resumed history when the last member closes 2026-11-07 19:17:55 +08:00
bincxz 419a6734fc Merge remote-tracking branch 'origin/main' into codex/preview-pr4158 2026-11-07 19:14:13 +08:00
bincxz b2be70aae7 fix(workspace): align empty state icon with workspace tabs 2026-11-07 19:02:56 +08:00
bincxz 9491b800b6 fix(workspace): retain displayed chat through final member close 2026-11-07 18:52:11 +08:00
bincxz 3c36e7e1a2 fix(workspace): prevent hidden pane moves while magnified 2026-11-07 18:32:49 +08:00
陈大猫andCodex 642bf03ea7 fix(mcp): recognize unbracketed space-separated Bash prompts as idle (#4176)
Accept space-separated Bash $ prompts while preserving delimiter and pending-input guards. Add recognition and extraction regression coverage.

Fixes #4175

Co-authored-by: Codex <codex@openai.com>
2026-11-07 18:26:57 +08:00
bincxz 22856a368a fix(workspace): guard stale drops and preserve chat scope priority 2026-11-07 18:18:38 +08:00
bincxz 4ed3d09c7a fix(workspace): complete pane dragging and empty workspace recovery 2026-11-07 16:08:00 +08:00
945722fbe8 fix(ai): place host_open tabs in the invoking chat's bound workspace (#4156)
* fix(ai): place host_open tabs in the invoking chat's bound workspace

* fix(ai): sync generated host open workspace guidance

* fix(ai): preserve invoking turn scope for resumed chats

---------

Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 15:41:46 +08:00
陈大猫 77f90999c7 fix(ui): accent border for terminal pane magnification (#4171)
* fix(ui): accent border for terminal pane magnification

* chore: retrigger CI after Actions runner shutdown flake

lint-and-test was cancelled mid-run by runner shutdown (not a product failure) on 42326458479 and 42328660175.
2026-11-07 10:44:59 +08:00
Mateus A.M Ferreiraandbincxz a62cc1bd3f feat(sftp): add a one-click Duplicate action for files and folders (#4133)
* feat(sftp): add a one-click Duplicate action for files and folders

Duplicate copies the selected entries next to their originals in the same
folder, covering files, folders, and multi-selection. It reuses the existing
transfer queue and the transfer pipeline's "(copy)" naming helper, so progress,
cancel, and error reporting behave like Copy to other pane.

The target name is resolved before the transfer starts. Resolving through the
batch conflict defaults instead would re-enter processTransfer for a task that
is still marked in flight and stall the copy.

Adds the action to both the list and tree context menus, plus translations for
en, es, ru, zh-CN, and zh-TW.

Closes #4132

* fix(sftp): keep Duplicate safe around navigation rows and reserved names

Hide and ignore the tree's synthetic ".." row: duplicating it resolved the
source to an ancestor directory and could walk the copy into its own output.
The transfer hook also drops "." / ".." inputs for duplicate batches, and the
same-pane handler filters them defensively.

Pick duplicate names around destinations already reserved by a queued or paused
transfer. Those rows have no file on disk yet, so stat-only naming returned the
same "(copy)" path and the follow-up active-path check turned the second
one-click duplicate into a conflict dialog. The naming helper now accepts an
occupied-path predicate fed from the active transfer set.

* fix(sftp): block stale entry menus on parent navigation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 10:07:08 +08:00
GOUKI9999andGOUKI9999 c8f8adcc87 fix(ai): keep the chat panel close button visible at narrow widths (#4161)
Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
2026-11-07 10:06:16 +08:00
陈大猫 7bb91d0fe2 fix(terminal): do not restore pane zoom on Escape (#4170) 2026-11-07 09:50:54 +08:00
陈大猫 a5a4117d8e fix(sftp): navigate into tree folders on double-click (#4166)
* fix(sftp): navigate into tree folders on double-click

* ci: retrigger cancelled lint-and-test for #4166
2026-11-07 08:17:07 +08:00
陈大猫 2ecf471a29 fix: prevent accidental Quick Connect dismissal (#4165) 2026-11-07 07:10:27 +08:00
陈大猫 968e84ff59 feat(plugins): install published plugin packages from GitHub (#4164)
* feat(plugins): install published plugin packages from GitHub

* fix(plugins): prebundle GitHub parser and reset consumed preview trust

* fix(plugins): localize GitHub install errors and trust review
2026-11-07 07:10:08 +08:00
GOUKI9999 19ab38217b feat(workspace): rearrange member panes within a workspace by drag
Issue #4037 (phase 3 of #4098). An existing member pane can now be
dragged back onto its own workspace to rearrange it, landing with the
same snap-in split semantics an orphan drop gets.

- domain: add pure movePaneInWorkspace(root, sessionId, hint) composing
  the existing prune + insert conventions (no-op for unknown/single-pane
  self-drops; prune-first keeps target-inside-subtree drops correct).
- state: moveWorkspacePane / moveWorkspacePaneInWorkspaces touch only
  workspaces[].root — session ids, tab order, focus fields and every AI
  binding (anchorChatId etc.) are untouched by construction.
- drop flow: handleWorkspaceDrop routes same-workspace member drops to
  the move op; cross-workspace drops stay rejected (#4098 policy) and
  orphan drops are unchanged.
- preview: member panes drag via pointer events, so the split-hint
  preview and drop are mirrored on the pointer stream while a member of
  the active workspace is dragged; dropping a pane onto itself previews
  and drops as a no-op.

Identity guarantee: a move never changes session ids, workspace id,
anchorChatId, focusedSessionId (focus is keyed by session id, so it
visually follows the moved pane), focusSessionOrder or tab order; the
moved tree roundtrips through netcatty_session_restore_v1 unchanged.
2026-11-06 17:07:16 +08:00
GOUKI9999 98d75c13fe feat(ai): anchor workspace AI chats by id, not position
Phase 2 of #4098: every remaining positional fallback in the workspace
AI scope is replaced with id-based resolution.

- Workspace gains anchorChatId: pinned by the AI side panel whenever a
  chat becomes active in the workspace scope, persisted through the
  session restore snapshot, and validated lazily by consumers.
- resolveDisplayedPanelView: the auto-resume fallback now resolves the
  anchored chat first, then the most recently updated chat (updatedAt),
  else draft; sessions[0] is gone.
- retargetWorkspaceActiveChatAfterMemberLoss: when the focused member
  did not survive, the retarget lands on the most-recently-active
  survivor by chat recency, not the first positional member.
- plugin keybinding context: focused member, then the terminal member
  owning the anchored chat, then the most-recently-active member; a
  single-member workspace uses its only member.
- New domain/aiSessionRecency helper shared by all survivor rankings.
- /rebind and session_recover semantics (#4019) are unchanged.
2026-11-06 15:34:15 +08:00
GOUKI9999 ba135c0e98 feat(ai): keep workspaces alive below two members
Phase 2 of #4098: the workspace becomes a first-class object that
survives membership changes.

- Close/detach no longer dissolves a workspace at <=1 member. The
  workspace entity (and its id) survives with its remaining members,
  even at zero, where it keeps an empty split root placeholder that
  renders as an empty workspace tab.
- Focus handoff after member loss resolves to the most-recently-active
  surviving member (AI chat updatedAt via the AI session store), never
  positionally to remainingSessionIds[0]; with no recency signal focus
  is cleared and the pane renderer uses its display default.
- session restore: drop the empty-workspace skip. Workspaces closed to
  zero members persist an empty root and come back after restart with
  the same id, so their AI chats re-link. Roots that prune to null
  because their member sessions cannot be restored are still dropped.
- applyCloseSessionToSessions no longer orphans the survivor; the
  dissolved-workspace tab-order replacement machinery is removed.
2026-11-06 15:33:35 +08:00
175 changed files with 8895 additions and 498 deletions

No files matched your search

+1
View File
@@ -141,6 +141,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
+1
View File
@@ -144,6 +144,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
- **Edit in place** — built-in editor for quick changes
- **Create remote archives** — select files or folders, right-click **Compress...**, and create a named `.tar.gz` or `.zip` archive in the displayed destination directory; the server needs `tar`/`gzip` or `zip` for the chosen format.
### 🎨 Personalization
- **Custom themes** — tune the app appearance to your taste
+1
View File
@@ -141,6 +141,7 @@
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
+1
View File
@@ -141,6 +141,7 @@
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
- **就地編輯** —— 內建編輯器,快速修改檔案
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
### 🎨 個人化
- **自訂主題** —— 依你的喜好調整外觀
+19 -20
View File
@@ -377,7 +377,7 @@ test('move-focus shortcut cannot send input behind a magnified pane', () => {
assert.equal(moveCalls, 0);
});
test('Escape restores magnification after transient dialogs are closed', () => {
test('Escape leaves magnification unchanged and reaches the terminal', () => {
let restores = 0;
let prevented = false;
let stopped = false;
@@ -405,30 +405,29 @@ test('Escape restores magnification after transient dialogs are closed', () => {
},
}), event);
assert.equal(restores, 1);
assert.equal(prevented, true);
assert.equal(stopped, true);
assert.equal(restores, 0);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('consumed Escape does not restore magnification', () => {
let restores = 0;
test('Escape closes the quick switcher', () => {
let open = true;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: false,
setIsQuickSwitcherOpen: () => {},
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused',
focus: () => false,
restore: () => {
restores += 1;
return true;
},
toggle: () => false,
},
},
isQuickSwitcherOpen: open,
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
}), { key: 'Escape', defaultPrevented: false } as KeyboardEvent);
assert.equal(open, false);
});
test('consumed Escape leaves the quick switcher open', () => {
let open = true;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: open,
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
assert.equal(restores, 0);
assert.equal(open, true);
});
test('close tab hotkey routes native plugin view tabs through their owner', () => {
-9
View File
@@ -365,8 +365,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
const {
isQuickSwitcherOpen,
setIsQuickSwitcherOpen,
sftpPaneMagnificationRef,
terminalPaneMagnificationRef,
} = getCtx();
{
if (e.key !== 'Escape' || e.defaultPrevented) return;
@@ -374,13 +372,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
setIsQuickSwitcherOpen(false);
return;
}
if (getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.restore()) {
e.preventDefault();
e.stopPropagation();
}
}
}
+15 -5
View File
@@ -4,6 +4,8 @@ import { PluginContributionHost } from '../../components/plugins/PluginContribut
import type { TerminalSession, Workspace } from '../../types';
import { useActiveTabId } from '../state/activeTabStore';
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
import { getPersistedAiSessionsSnapshot } from '../state/aiStateSnapshots';
/**
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
@@ -23,11 +25,19 @@ export function AppPluginKeybindingHost({
workspaces: Workspace[];
}) {
const activeTabId = useActiveTabId();
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
}), [activeTabId, sessions, workspaces]);
const keybindingContext = useMemo(() => {
// AI chat recency drives the workspace anchor fallback (#4098 phase 2).
// Read without subscribing: keybinding context is best-effort and refreshes
// on every tab/session/workspaces change that matters here.
const live = getAISessionsSnapshot().sessions;
const aiSessions = live.length > 0 ? live : getPersistedAiSessionsSnapshot();
return resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
aiSessions,
});
}, [activeTabId, sessions, workspaces]);
return (
<PluginContributionHost
@@ -4,12 +4,13 @@ import test from 'node:test';
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
test('terminal Escape restoration runs before xterm can stop propagation', () => {
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
const effectSource = source.slice(effectStart, effectStart + 900);
test('quick-switcher Escape handling stays in the bubble phase', () => {
const effectStart = source.indexOf('const onKeyDown = (e: KeyboardEvent) => {\n _handleEscapeKeyDown(e);');
const effectEnd = source.indexOf('}, [appLockLocked]);', effectStart);
const effectSource = source.slice(effectStart, effectEnd);
assert.notEqual(effectStart, -1);
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
assert.doesNotMatch(source, /onCaptureKeyDown/);
});
+19 -12
View File
@@ -58,7 +58,9 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
@@ -613,7 +615,7 @@ export function AppSideEffects() {
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
// dedicated transfer resume so quick-connect rows do not break tray connect.
@@ -1233,19 +1235,11 @@ export function AppSideEffects() {
useEffect(() => {
if (appLockLocked) return;
const onCaptureKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target;
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
_handleEscapeKeyDown(e);
};
const onKeyDown = (e: KeyboardEvent) => {
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onCaptureKeyDown, true);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onCaptureKeyDown, true);
window.removeEventListener('keydown', onKeyDown);
};
}, [appLockLocked]);
@@ -1505,8 +1499,21 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string; chatSessionId?: string }) => {
// A resumed history chat can run in a panel other than its saved binding.
// Prefer that turn's captured scope, never the window's current focus.
// Outside a running turn, retain the saved chat binding as the fallback.
const chat = options?.chatSessionId
? getAISessionsSnapshot().sessions.find((session) => session.id === options.chatSessionId)
: undefined;
const turnScope = options?.chatSessionId
? peekAgentRuntimeCleanup()?.getActiveScope?.(options.chatSessionId)
: undefined;
const target = resolveAgentHostOpenWorkspace(options?.workspaceId, turnScope ? { scope: turnScope } : chat, {
activeTabId: activeTabStore.getActiveTabId(),
workspaceIds: workspaces.map(workspace => workspace.id),
terminalSessions: sessionsRef.current,
});
if (target.error) return { ok: false as const, error: target.error };
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
+4 -2
View File
@@ -264,7 +264,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -279,7 +279,7 @@ function AppViewInner({ domains }: AppViewProps) {
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
@@ -774,12 +774,14 @@ function AppViewInner({ domains }: AppViewProps) {
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onMoveWorkspacePane={moveWorkspacePane}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSetWorkspaceAnchorChat={setWorkspaceAnchorChat}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshotActions,
publishSessionSnapshot,
} from '../state/sessionSnapshotStore';
import { getAppShellPropsSnapshot } from './appShellPropsStore';
import { registerAppHandlers } from './appHandlersBridge';
// Keep both production publishers and the store between them real. Only the
// state owner and unrelated theme side effects are replaced with fixtures.
const sessionFixtureUrl = `data:text/javascript,${encodeURIComponent(`
let session;
export function setSession(value) { session = value; }
export function useSessionState() { return session; }
`)}`;
const themeFixtureUrl = `data:text/javascript,${encodeURIComponent(`
const noop = () => {};
const theme = {};
const runtime = {
globalAppearance: theme, accentedGlobalAppearance: theme,
clearIntent: noop, settleManualIntent: noop, pickTheme: noop,
resolveFocusedAppearance: noop, currentTerminalTheme: theme,
};
export function useThemeRuntime() { return runtime; }
export function useTerminalAppearanceInjection() {}
`)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '../../state/useSessionState' && context.parentURL?.endsWith('/SessionPublisher.tsx')) {
return { url: sessionFixtureUrl, shortCircuit: true };
}
if (specifier === '../../state/useThemeRuntime' && context.parentURL?.endsWith('/TerminalHost.tsx')) {
return { url: themeFixtureUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
const { setSession } = await import(sessionFixtureUrl);
const { SessionPublisher } = await import('./publishers/SessionPublisher');
const { TerminalHost } = await import('./hosts/TerminalHost');
test('workspace actions reach the terminal shell through SessionPublisher and TerminalHost', async (t) => {
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
t.after(async () => {
await act(async () => renderer?.unmount());
registerAppHandlers(null);
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
const calls: unknown[][] = [];
const moveWorkspacePane = (...args: unknown[]) => calls.push(['move', ...args]);
const setWorkspaceAnchorChat = (...args: unknown[]) => calls.push(['anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane, setWorkspaceAnchorChat });
registerAppHandlers({});
const renderTree = () => React.createElement(
SessionPublisher,
{ persistSessionRestore: false },
React.createElement(TerminalHost),
);
await act(async () => { renderer = create(renderTree()); });
assert.equal(getSessionSnapshotActions()?.moveWorkspacePane, moveWorkspacePane);
assert.equal(getSessionSnapshotActions()?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const terminal = getAppShellPropsSnapshot().domains?.terminal;
assert.equal(terminal?.moveWorkspacePane, moveWorkspacePane);
assert.equal(terminal?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const hint = { direction: 'horizontal', position: 'top', targetSessionId: 'pane-b' };
(terminal?.moveWorkspacePane as typeof moveWorkspacePane)('workspace', 'pane-a', hint);
(terminal?.setWorkspaceAnchorChat as typeof setWorkspaceAnchorChat)('workspace', 'chat');
assert.deepEqual(calls, [
['move', 'workspace', 'pane-a', hint],
['anchor', 'workspace', 'chat'],
]);
// A changed state-owner callback must not be trapped in a stale memoized
// action bag even when all session/workspace snapshots stay identical.
const replacementMove = (...args: unknown[]) => calls.push(['replacement-move', ...args]);
const replacementAnchor = (...args: unknown[]) => calls.push(['replacement-anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane: replacementMove, setWorkspaceAnchorChat: replacementAnchor });
await act(async () => renderer!.update(renderTree()));
assert.equal(getAppShellPropsSnapshot().domains?.terminal.moveWorkspacePane, replacementMove);
assert.equal(getAppShellPropsSnapshot().domains?.terminal.setWorkspaceAnchorChat, replacementAnchor);
});
+2
View File
@@ -170,6 +170,7 @@ export function TerminalHost() {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
moveWorkspacePane: sessionActions?.moveWorkspacePane,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
@@ -227,6 +228,7 @@ export function TerminalHost() {
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
setWorkspaceAnchorChat: sessionActions?.setWorkspaceAnchorChat,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
@@ -48,6 +48,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -58,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -133,6 +135,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -143,6 +146,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -172,6 +176,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -208,6 +213,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
splitSession,
startSessionRename,
startWorkspaceRename,
+2
View File
@@ -720,6 +720,8 @@ export const enAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.emptyWorkspace': 'No terminals in this workspace',
'terminal.layer.emptyWorkspaceHint': 'Add a local terminal or connect to a host to get started.',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.github.install": "Install from GitHub",
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
"settings.plugins.github.repository": "GitHub repository",
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
"settings.plugins.github.confirm": "Install disabled",
"settings.plugins.github.download": "Download and review",
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
"settings.plugins.github.error.details": "Technical details",
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
+19
View File
@@ -316,7 +316,26 @@ export const enVaultMessages: Messages = {
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.archive.action': 'Compress...',
'sftp.archive.title': 'Compress files',
'sftp.archive.description': 'Create an archive on the server from {count} selected items.',
'sftp.archive.name': 'Archive name',
'sftp.archive.format': 'Format',
'sftp.archive.destination': 'Save in',
'sftp.archive.create': 'Compress',
'sftp.archive.creating': 'Compressing...',
'sftp.archive.overwriteTitle': 'Replace existing archive?',
'sftp.archive.overwriteDescription': 'An archive named "{name}" already exists. Confirm to replace it with the new archive.',
'sftp.archive.overwrite': 'Replace and compress',
'sftp.archive.failed': 'Failed to create archive',
'sftp.archive.invalidSelection': 'Cannot compress this selection',
'sftp.archive.invalidName': 'Enter a valid file name without a directory path.',
'sftp.archive.invalidExtension': 'The file extension must match the selected format.',
'sftp.archive.connectionChanged': 'The connection has changed. Select the files again.',
'sftp.archive.unavailable': 'Archive creation is unavailable for this connection.',
'sftp.archive.success': 'Created {fileName}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.context.duplicate': 'Duplicate',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
+2
View File
@@ -720,6 +720,8 @@ export const esAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Agregar terminal',
'terminal.layer.emptyWorkspace': 'No hay terminales en este espacio de trabajo',
'terminal.layer.emptyWorkspaceHint': 'Agrega un terminal local o conecta un host para comenzar.',
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.github.install": "Instalar desde GitHub",
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
"settings.plugins.github.repository": "Repositorio de GitHub",
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
"settings.plugins.github.confirm": "Instalar desactivado",
"settings.plugins.github.download": "Descargar y revisar",
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
"settings.plugins.github.error.details": "Detalles técnicos",
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
+19
View File
@@ -316,7 +316,26 @@ export const esVaultMessages: Messages = {
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.archive.action': 'Comprimir...',
'sftp.archive.title': 'Comprimir archivos',
'sftp.archive.description': 'Crea un archivo comprimido en el servidor con los {count} elementos seleccionados.',
'sftp.archive.name': 'Nombre del archivo comprimido',
'sftp.archive.format': 'Formato',
'sftp.archive.destination': 'Guardar en',
'sftp.archive.create': 'Comprimir',
'sftp.archive.creating': 'Comprimiendo...',
'sftp.archive.overwriteTitle': '¿Reemplazar el archivo comprimido existente?',
'sftp.archive.overwriteDescription': 'Ya existe un archivo comprimido llamado "{name}". Confirma para reemplazarlo por el nuevo.',
'sftp.archive.overwrite': 'Reemplazar y comprimir',
'sftp.archive.failed': 'No se pudo crear el archivo comprimido',
'sftp.archive.invalidSelection': 'No se puede comprimir esta selección',
'sftp.archive.invalidName': 'Ingresa un nombre de archivo válido sin una ruta de directorio.',
'sftp.archive.invalidExtension': 'La extensión del archivo debe coincidir con el formato seleccionado.',
'sftp.archive.connectionChanged': 'La conexión cambió. Vuelve a seleccionar los archivos.',
'sftp.archive.unavailable': 'No se pueden crear archivos comprimidos en esta conexión.',
'sftp.archive.success': 'Se creó {fileName}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.context.duplicate': 'Duplicar',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
import en from './en';
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
assert.ok(keys.length > 30);
for (const { id } of SUPPORTED_UI_LOCALES) {
await loadLocaleMessages(id);
const messages = getLoadedLocaleMessages(id)!;
for (const key of keys) {
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
}
}
});
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
assert.equal(expired.key, 'settings.plugins.github.error.expired');
assert.equal(expired.details, null);
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
const validator = 'manifest version must be a semantic version';
assert.equal(describePluginGitHubInstallError(validator).details, validator);
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
});
+2
View File
@@ -659,6 +659,8 @@ export const ruAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Добавить терминал',
'terminal.layer.emptyWorkspace': 'В этом рабочем пространстве пока нет терминалов',
'terminal.layer.emptyWorkspaceHint': 'Добавьте локальный терминал или подключитесь к хосту, чтобы начать.',
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
"settings.plugins.github.install": "Установить из GitHub",
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
"settings.plugins.github.repository": "Репозиторий GitHub",
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
"settings.plugins.github.confirm": "Установить выключенным",
"settings.plugins.github.download": "Загрузить и проверить",
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
"settings.plugins.github.error.details": "Технические подробности",
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
+19
View File
@@ -360,7 +360,26 @@ export const ruVaultMessages: Messages = {
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.archive.action': 'Сжать...',
'sftp.archive.title': 'Сжать файлы',
'sftp.archive.description': 'Создать архив на сервере из выбранных объектов: {count}.',
'sftp.archive.name': 'Имя архива',
'sftp.archive.format': 'Формат',
'sftp.archive.destination': 'Сохранить в',
'sftp.archive.create': 'Сжать',
'sftp.archive.creating': 'Сжатие...',
'sftp.archive.overwriteTitle': 'Заменить существующий архив?',
'sftp.archive.overwriteDescription': 'Архив с именем «{name}» уже существует. Подтвердите его замену новым архивом.',
'sftp.archive.overwrite': 'Заменить и сжать',
'sftp.archive.failed': 'Не удалось создать архив',
'sftp.archive.invalidSelection': 'Невозможно сжать выбранные объекты',
'sftp.archive.invalidName': 'Введите допустимое имя файла без пути к каталогу.',
'sftp.archive.invalidExtension': 'Расширение файла должно соответствовать выбранному формату.',
'sftp.archive.connectionChanged': 'Подключение изменилось. Выберите файлы заново.',
'sftp.archive.unavailable': 'Создание архивов недоступно для этого подключения.',
'sftp.archive.success': 'Создан архив {fileName}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.context.duplicate': 'Дублировать',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
+2
View File
@@ -720,6 +720,8 @@ export const zhCNAiMessages: Messages = {
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.emptyWorkspace': '工作区中还没有终端',
'terminal.layer.emptyWorkspaceHint': '添加本地终端或连接主机,开始使用这个工作区。',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
+57
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.github.install": "从 GitHub 安装",
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
"settings.plugins.github.repository": "GitHub 仓库",
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
"settings.plugins.github.confirm": "安装并保持禁用",
"settings.plugins.github.download": "下载并审查",
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
"settings.plugins.github.error.details": "技术详情",
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
@@ -1015,7 +1053,26 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.archive.action': '压缩…',
'sftp.archive.title': '压缩文件',
'sftp.archive.description': '将 {count} 个选中项目打包到服务器上的压缩包。',
'sftp.archive.name': '压缩包名称',
'sftp.archive.format': '格式',
'sftp.archive.destination': '保存目录',
'sftp.archive.create': '压缩',
'sftp.archive.creating': '正在压缩…',
'sftp.archive.overwriteTitle': '覆盖现有压缩包?',
'sftp.archive.overwriteDescription': '“{name}”已存在。确认后将用新的压缩包替换它。',
'sftp.archive.overwrite': '覆盖并压缩',
'sftp.archive.failed': '压缩失败',
'sftp.archive.invalidSelection': '无法压缩此选区',
'sftp.archive.invalidName': '请输入有效的文件名,不要包含目录路径。',
'sftp.archive.invalidExtension': '文件扩展名必须与所选格式一致。',
'sftp.archive.connectionChanged': '连接已切换,请重新选择文件。',
'sftp.archive.unavailable': '当前连接不支持创建压缩包。',
'sftp.archive.success': '已创建 {fileName}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.context.duplicate': '创建副本',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
+2
View File
@@ -710,6 +710,8 @@ export const zhTWAiMessages: Messages = {
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '新增終端',
'terminal.layer.emptyWorkspace': '工作區中還沒有終端',
'terminal.layer.emptyWorkspaceHint': '新增本機終端或連線至主機,開始使用這個工作區。',
'terminal.layer.switchToSplitView': '切換到分割檢視',
'terminal.layer.sftp': '檔案傳輸',
'terminal.layer.scripts': '指令碼',
+57
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.github.install": "從 GitHub 安裝",
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
"settings.plugins.github.repository": "GitHub 儲存庫",
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
"settings.plugins.github.confirm": "安裝並保持停用",
"settings.plugins.github.download": "下載並審查",
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
"settings.plugins.github.error.details": "技術詳細資料",
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
@@ -1017,7 +1055,26 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.archive.action': '壓縮…',
'sftp.archive.title': '壓縮檔案',
'sftp.archive.description': '將 {count} 個選取項目打包成伺服器上的壓縮檔。',
'sftp.archive.name': '壓縮檔名稱',
'sftp.archive.format': '格式',
'sftp.archive.destination': '儲存目錄',
'sftp.archive.create': '壓縮',
'sftp.archive.creating': '正在壓縮…',
'sftp.archive.overwriteTitle': '覆寫現有壓縮檔?',
'sftp.archive.overwriteDescription': '「{name}」已存在。確認後將以新的壓縮檔取代它。',
'sftp.archive.overwrite': '覆寫並壓縮',
'sftp.archive.failed': '壓縮失敗',
'sftp.archive.invalidSelection': '無法壓縮此選取範圍',
'sftp.archive.invalidName': '請輸入有效的檔案名稱,不要包含目錄路徑。',
'sftp.archive.invalidExtension': '副檔名必須與所選格式一致。',
'sftp.archive.connectionChanged': '連線已切換,請重新選取檔案。',
'sftp.archive.unavailable': '目前連線不支援建立壓縮檔。',
'sftp.archive.success': '已建立 {fileName}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.context.duplicate': '建立副本',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
'sftp.copyCurrentPath.success': '已複製目前路徑',
+1 -1
View File
@@ -165,7 +165,7 @@ function isSuccessfulApplyResult(result: unknown): boolean {
* the DB before collection. Ordinary last-known collect snapshots are never
* re-applied, so local settings edits made after the last collect are kept.
*/
export async function collectPluginSyncSidecarsFromHost(options?: {
export async function collectPluginSyncSidecarsFromHost(options: {
/**
* When true, never fall back to last-known cache. Returns null if the host is
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
@@ -75,6 +75,95 @@ test('resolves the focused workspace session for global keybindings', () => {
});
});
test('uses the anchor chat owner when focus is missing (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
anchorChatId: 'chat-2',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 2 },
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
],
})['terminal.sessionId'],
// anchorChatId's chat is terminal-scoped to session-3, which wins even
// though session-1's chat is older and session-1 is the first member.
'session-3',
);
});
test('falls back to the most-recently-active member when there is no anchor (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 9 },
],
})['terminal.sessionId'],
'session-3',
);
});
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
assert.equal(
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
@@ -89,3 +178,18 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
'netcatty.activeTabId': 'vault',
});
});
test('empty workspace does not target the detached owner of its anchored chat', () => {
const context = resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: sessions.map((session) => ({ ...session, workspaceId: undefined })),
workspaces: [{ ...workspaces[0], focusedSessionId: undefined, anchorChatId: 'chat-1', root: {
id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [],
} }],
aiSessions: [{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 }],
});
assert.equal(context['terminal.sessionId'], undefined);
assert.equal(context['host.id'], undefined);
assert.equal(context['workspace.id'], 'workspace-1');
});
@@ -1,4 +1,6 @@
import { collectSessionIds } from '../../domain/workspace';
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
import type { TerminalSession, Workspace } from '../../types';
export type PluginContributionContextValue = string | boolean | number | null;
@@ -44,14 +46,17 @@ export function resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
aiSessions,
}: {
activeTabId: string;
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
/** AI chat snapshot: resolves the workspace anchor and recency fallback (#4098 phase 2). */
aiSessions?: readonly AIChatRecencyEntry[];
}): PluginContributionContext {
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
const activeSessionId = workspace
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
: sessions.some((candidate) => candidate.id === activeTabId)
? activeTabId
: undefined;
@@ -72,3 +77,32 @@ export function resolveActivePluginKeybindingContext({
'netcatty.activeTabId': activeTabId,
};
}
/**
* Issue #4098 phase 2: the keybinding context's workspace session resolves by
* id, never positionally — focused member first, then the terminal member that
* owns the anchored chat, then the most-recently-active member by chat recency.
*/
function resolveWorkspaceActiveTerminalId(
workspace: Workspace,
aiSessions: readonly AIChatRecencyEntry[] | undefined,
): string | undefined {
if (workspace.focusedSessionId && collectSessionIds(workspace.root).includes(workspace.focusedSessionId)) {
return workspace.focusedSessionId;
}
const memberTerminalIds = collectSessionIds(workspace.root);
if (memberTerminalIds.length === 1) {
// A single-member workspace has exactly one candidate — not a positional
// pick among many.
return memberTerminalIds[0];
}
const anchorChat = workspace.anchorChatId
? aiSessions?.find((session) => session.id === workspace.anchorChatId && session.scope?.type === 'terminal')
: undefined;
const anchorOwner = anchorChat?.scope.targetId;
return (anchorOwner && memberTerminalIds.includes(anchorOwner) ? anchorOwner : undefined)
?? resolveMostRecentlyActiveMemberTerminalId({ memberTerminalIds, aiSessions })
?? undefined;
}
@@ -45,6 +45,7 @@ export type SessionSnapshotActions = Pick<
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'setWorkspaceAnchorChat'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
@@ -56,6 +57,7 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'moveWorkspacePane'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
+96 -121
View File
@@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
import {
applyCloseSessionToSessions,
closeSessionsState,
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
resolveActiveTabAfterCloseSession,
} from "./sessionWorkspaceDetach";
@@ -14,11 +14,19 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
id,
hostId: id,
hostLabel: id,
username: "user",
hostname: id,
status: "connected",
workspaceId,
});
const workspace = (sessionIds: string[]): Workspace => ({
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
id,
scope: { type: "terminal", targetId },
updatedAt,
});
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
id: "ws-1",
title: "Workspace",
focusedSessionId: sessionIds[0],
@@ -36,9 +44,17 @@ const workspace = (sessionIds: string[]): Workspace => ({
})),
sizes: sessionIds.map(() => 1),
},
...overrides,
});
test("detach dissolves the original workspace when one session remains", () => {
const rootPaneSessionIds = (workspace: Workspace): string[] => (
workspace.root.type === "pane"
? [workspace.root.sessionId]
: workspace.root.children.map((child) => child.type === "pane" ? child.sessionId : "")
.filter((id): id is string => Boolean(id))
);
test("detaching to one member keeps the workspace alive (#4098 phase 2)", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
@@ -49,149 +65,126 @@ test("detach dissolves the original workspace when one session remains", () => {
assert.equal(result.activeTabId, "s1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", undefined],
["s2", undefined],
// The survivor keeps its workspace binding — no orphan promotion.
["s2", "ws-1"],
]);
assert.equal(result.workspaces.length, 0);
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
});
test("detach preserves the other sessions in a multi-pane workspace", () => {
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2"), session("s3")],
workspaces: [workspace(["s1", "s2", "s3"])],
sessionId: "s2",
sessions: [session("s1")],
workspaces: [workspace(["s1"])],
sessionId: "s1",
});
assert.equal(result.changed, true);
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", "ws-1"],
["s2", undefined],
["s3", "ws-1"],
]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.equal(result.workspaces[0].root.type, "split");
assert.deepEqual(
result.workspaces[0].root.type === "split"
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
: [],
["s1", "s3"],
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
[],
);
assert.equal(result.workspaces[0].focusedSessionId, undefined);
});
test("dissolved workspace replacement preserves its tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["log-1", "s1", "s2", "session-3"],
);
test("closing one member keeps the workspace and its surviving member bound (#4098 phase 2)", () => {
const result = closeSessionsState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionIds: ["s1"],
currentActiveTabId: "ws-1",
tabOrder: ["ws-1"],
});
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [["s2", "ws-1"]]);
// The workspace tab is still live, so the active tab does not move.
assert.equal(result.activeTabId, undefined);
});
test("dissolved workspace replacement removes duplicate replacement ids", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement is idempotent", () => {
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
once,
);
});
test("single remaining session preserves dissolved workspace tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
["log-1", "s2", "session-3"],
);
});
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.equal(result.lastRemainingSessionId, "s2");
assert.deepEqual(result.workspaces, []);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(
["log-1", result.dissolvedWorkspaceId!, "session-3"],
result.dissolvedWorkspaceId,
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
),
["log-1", "s2", "session-3"],
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("closing a non-focused session keeps the focused session", () => {
test("reordering focus order does not change the survivor retarget", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
[workspace(["s1", "s2", "s3"], { focusSessionOrder: ["s3", "s2", "s1"] })],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("closing a non-focused member keeps the focused member", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
test("with no AI recency signal focus stays on a surviving terminal", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1"), session("s2")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
["s2", undefined],
]);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
preferredTabId: "s-outside",
}),
"s2",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
});
test("closing the last workspace session does not invent a surviving terminal", () => {
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
const result = closeSessionsState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionIds: ["s1", "s2"],
currentActiveTabId: "ws-1",
tabOrder: ["ws-1"],
});
assert.deepEqual(result.sessions, []);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.equal(result.workspaces[0].root.type, "split");
assert.deepEqual(
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
[],
);
// The workspace tab survives, so the active tab is left alone.
assert.equal(result.activeTabId, undefined);
});
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1")],
"s1",
layoutResult,
);
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
assert.deepEqual(nextSessions, []);
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
assert.equal(layoutResult.lastRemainingSessionId, undefined);
// The workspace tab survives, so an active workspace tab needs no fallback.
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
@@ -200,25 +193,7 @@ test("closing the last workspace session does not invent a surviving terminal",
layoutResult,
remainingSessions: nextSessions,
}),
"vault",
);
});
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
const visibleSession: TerminalSession = { ...session("s1", undefined) };
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
const remainingSessions = [hiddenSession];
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: visibleSession.id,
closedSessionId: "s1",
workspaceId: undefined,
layoutResult,
remainingSessions,
}),
"vault",
null,
);
});
+74 -126
View File
@@ -1,20 +1,17 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
export type DetachSessionFromWorkspaceStateResult = {
changed: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
activeTabId?: string;
dissolvedWorkspaceId?: string;
replacementTabIds?: string[];
};
export type CloseSessionWorkspaceLayoutResult = {
workspaces: Workspace[];
removedWorkspaceId?: string;
dissolvedWorkspaceId?: string;
lastRemainingSessionId?: string;
};
export type CloseSessionsStateResult = {
@@ -28,82 +25,65 @@ type DetachSessionFromWorkspaceStateOptions = {
sessions: TerminalSession[];
workspaces: Workspace[];
sessionId: string;
/**
* AI chat store snapshot used to rank surviving members by chat recency.
* Without a recency signal, terminal display still needs a live member.
*/
aiSessions?: readonly AIChatRecencyEntry[];
};
export function replaceDissolvedWorkspaceTabOrder(
tabOrder: readonly string[],
workspaceId: string | undefined,
replacementTabIds: readonly string[] | undefined,
): string[] {
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
tabId && list.indexOf(tabId) === index
));
if (uniqueReplacementIds.length === 0) return [...tabOrder];
if (!tabOrder.includes(workspaceId)) {
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
return hasAllReplacementIds ? [...tabOrder] : [
...tabOrder,
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
];
/**
* Resolve the member that should receive focus (or an anchor retarget) after a
* member loss. Prefers the AI-chat recency winner; falls back to the still-
* valid preferred (focused) member because that is explicit user intent, and
* falls back to a surviving pane so focus mode never hides all terminals.
*/
const resolveSurvivingFocusMemberId = (
remainingSessionIds: readonly string[],
previousFocusedSessionId: string | undefined,
aiSessions: readonly AIChatRecencyEntry[] | undefined,
): string | undefined => {
if (previousFocusedSessionId && remainingSessionIds.includes(previousFocusedSessionId)) {
return previousFocusedSessionId;
}
const replacementIdSet = new Set(uniqueReplacementIds);
let inserted = false;
const nextOrder: string[] = [];
for (const tabId of tabOrder) {
if (tabId === workspaceId) {
if (!inserted) {
nextOrder.push(...uniqueReplacementIds);
inserted = true;
}
continue;
}
if (!replacementIdSet.has(tabId)) {
nextOrder.push(tabId);
}
}
return nextOrder;
}
return resolveMostRecentlyActiveMemberTerminalId({
memberTerminalIds: remainingSessionIds,
aiSessions,
}) ?? remainingSessionIds[0];
};
export function closeSessionWorkspaceLayoutState(
workspaces: readonly Workspace[],
workspaceId: string | undefined,
sessionId: string,
aiSessions?: readonly AIChatRecencyEntry[],
): CloseSessionWorkspaceLayoutResult {
if (!workspaceId) return { workspaces: [...workspaces] };
let removedWorkspaceId: string | undefined;
let dissolvedWorkspaceId: string | undefined;
let lastRemainingSessionId: string | undefined;
const nextWorkspaces = workspaces
.map((workspace) => {
if (workspace.id !== workspaceId) return workspace;
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
if (!prunedRoot) {
removedWorkspaceId = workspace.id;
return null;
// Issue #4098 phase 2: the last member closed, but the workspace is a
// first-class entity — it survives with an empty root so its id (and
// the AI chats anchored to it) survive restart and member churn.
return {
...workspace,
root: createEmptyWorkspaceRoot(),
focusedSessionId: undefined,
focusSessionOrder: [],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = workspace.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
// Closing the focused session must hand focus to a remaining one, or a
// focus-mode workspace is left with a dangling focusedSessionId: no pane
// qualifies as visible and the continuously rendered hidden panes all
// paint on top of each other (#3046).
const nextFocusedSessionId =
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
? remainingSessionIds[0]
: workspace.focusedSessionId;
// Closing the focused session hands focus to the most-recently-active
// surviving member when AI chat recency is available.
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
remainingSessionIds,
workspace.focusedSessionId,
aiSessions,
);
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
@@ -114,36 +94,23 @@ export function closeSessionWorkspaceLayoutState(
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
};
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
});
return {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
};
}
/**
* Apply a close-session action to the session list using the layout result.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
* remaining members keep their workspace binding.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
return sessions.filter((session) => session.id !== sessionId);
}
export function isPreferredTabAvailableAfterClose(
@@ -161,12 +128,6 @@ export function isPreferredTabAvailableAfterClose(
} {
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
if (!preferredTabId || preferredTabId === closedSessionId) return false;
if (
preferredTabId === layoutResult.dissolvedWorkspaceId
|| preferredTabId === layoutResult.removedWorkspaceId
) {
return false;
}
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
return remainingSessions.some((session) => (
@@ -179,7 +140,6 @@ export function isPreferredTabAvailableAfterClose(
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
preferredTabId,
@@ -202,18 +162,14 @@ export function resolveActiveTabAfterCloseSession({
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
? preferredInput.preferredTabId
: undefined;
const fallback = layoutResult.lastRemainingSessionId
?? preferredFallback
const fallback = preferredFallback
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
// Workspaces survive member loss (#4098 phase 2), so only a closed session
// tab that was active needs a replacement.
if (currentActiveTabId === closedSessionId) {
return fallback;
}
@@ -227,6 +183,7 @@ export function closeSessionsState({
currentActiveTabId,
preferredTabId,
tabOrder,
aiSessions,
}: {
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
@@ -234,6 +191,7 @@ export function closeSessionsState({
currentActiveTabId: string | null;
preferredTabId?: string | null;
tabOrder: readonly string[];
aiSessions?: readonly AIChatRecencyEntry[];
}): CloseSessionsStateResult {
let nextSessions = [...sessions];
let nextWorkspaces = [...workspaces];
@@ -250,17 +208,10 @@ export function closeSessionsState({
nextWorkspaces,
workspaceId,
sessionId,
aiSessions,
);
nextWorkspaces = layoutResult.workspaces;
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
nextTabOrder,
layoutResult.dissolvedWorkspaceId,
[layoutResult.lastRemainingSessionId],
);
}
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
currentActiveTabId: nextActiveTabId,
@@ -288,6 +239,7 @@ export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
aiSessions,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
@@ -301,40 +253,36 @@ export function detachSessionFromWorkspaceState({
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
let nextSessions = sessions.map((candidate) => (
const nextSessions = sessions.map((candidate) => (
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
));
if (!prunedRoot) {
// Last member dragged out: the workspace entity survives with an empty
// root (#4098 phase 2) so its id and anchored AI chats stay stable.
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
workspaces: workspaces.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: createEmptyWorkspaceRoot(),
focusedSessionId: undefined,
focusSessionOrder: [],
}
: workspace
)),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
nextSessions = nextSessions.map((candidate) => (
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
));
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId, ...remainingSessionIds],
};
}
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
? targetWorkspace.focusedSessionId
: remainingSessionIds[0];
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
remainingSessionIds,
targetWorkspace.focusedSessionId,
aiSessions,
);
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SftpPane } from "./types";
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { createSftpArchiveForPane } from "./createSftpArchive.ts";
function setup() {
let pane = {
id: "pane-a", connection: { id: "connection-a", isLocal: false }, filenameEncoding: "gb18030",
} as SftpPane;
const calls: unknown[][] = [];
const refreshes: string[] = [];
const params = {
getPane: () => pane,
target: { paneId: "pane-a", connectionId: "connection-a" },
options: { directory: "/srv", paths: ["中文", "logs"], archiveName: "backup.zip", format: "zip" as const },
getSftpIdForConnection: (id: string) => id === "connection-a" ? "sftp-a" : undefined,
bridge: {
createSftpArchive: async (...args: unknown[]): Promise<SftpArchiveCreationResult> => {
calls.push(args);
return { success: true };
},
},
refresh: async (id: string) => { refreshes.push(id); },
t: (key: string) => key,
};
return { params, calls, refreshes, setPane: (value: SftpPane) => { pane = value; } };
}
test("archive creation passes selected paths and encoding to the captured remote session", async () => {
const { params, calls, refreshes } = setup();
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(calls, [["sftp-a", params.options, "gb18030"]]);
assert.deepEqual(refreshes, ["pane-a"]);
});
test("a switched or disconnected pane cannot compress files on another host", async () => {
for (const change of [
{ id: "pane-b" },
{ connection: { id: "connection-b", isLocal: false } },
{ connection: null },
]) {
const { params, calls, setPane } = setup();
setPane({ ...params.getPane(), ...change } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /connectionChanged/);
assert.equal(calls.length, 0);
}
});
test("archive creation requires a remote session and a supported bridge", async () => {
const { params, calls, setPane } = setup();
await assert.rejects(createSftpArchiveForPane({ ...params, bridge: undefined }), /unavailable/);
await assert.rejects(createSftpArchiveForPane({ ...params, getSftpIdForConnection: () => undefined }), /unavailable/);
setPane({ ...params.getPane(), connection: { id: "connection-a", isLocal: true } } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /unavailable/);
assert.equal(calls.length, 0);
});
test("existing archives are returned for confirmation without retry or refresh", async () => {
const { params, refreshes } = setup();
const conflict = { success: false, code: "EEXIST", error: "Already exists" };
params.bridge.createSftpArchive = async () => conflict;
assert.equal(await createSftpArchiveForPane(params), conflict);
assert.deepEqual(refreshes, []);
});
test("finishing after a tab switch does not refresh the new connection", async () => {
const { params, refreshes, setPane } = setup();
params.bridge.createSftpArchive = async () => {
setPane({ ...params.getPane(), id: "pane-b" } as SftpPane);
return { success: true };
};
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(refreshes, []);
});
test("a failed operation is not refreshed or converted to success", async () => {
const { params, refreshes } = setup();
params.bridge.createSftpArchive = async () => { throw new Error("zip is not installed"); };
await assert.rejects(createSftpArchiveForPane(params), /zip is not installed/);
assert.deepEqual(refreshes, []);
});
test("a listing error after publication does not invite a second archive creation", async () => {
const { params, calls } = setup();
params.refresh = async () => { throw new Error("Disconnected after publication"); };
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.equal(calls.length, 1);
});
@@ -0,0 +1,46 @@
import type {
SftpArchiveCreationOptions,
SftpArchiveCreationResult,
SftpArchiveCreationTarget,
} from "../../../domain/sftpArchiveCreation";
import type { SftpPane } from "./types";
import { logger } from "../../../lib/logger";
interface CreateSftpArchiveParams {
getPane: () => Pick<SftpPane, "id" | "connection" | "filenameEncoding">;
target: SftpArchiveCreationTarget;
options: SftpArchiveCreationOptions;
bridge: Pick<NetcattyBridge, "createSftpArchive"> | undefined;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
refresh: (paneId: string) => Promise<void>;
t: (key: string) => string;
}
/** Bind the selected files to the pane/connection that opened the dialog. */
export async function createSftpArchiveForPane({
getPane, target, options, bridge, getSftpIdForConnection, refresh, t,
}: CreateSftpArchiveParams): Promise<SftpArchiveCreationResult> {
const pane = getPane();
if (pane.id !== target.paneId || pane.connection?.id !== target.connectionId) {
throw new Error(t("sftp.archive.connectionChanged"));
}
const sftpId = getSftpIdForConnection?.(target.connectionId);
if (pane.connection.isLocal || !sftpId || !bridge?.createSftpArchive) {
throw new Error(t("sftp.archive.unavailable"));
}
const result = await bridge.createSftpArchive(sftpId, options, pane.filenameEncoding);
if (!result.success) return result;
// A tab switch during compression must not refresh an unrelated connection.
const current = getPane();
if (current.id === target.paneId && current.connection?.id === target.connectionId) {
try {
await refresh(target.paneId);
} catch (error) {
// The archive has already been published. A listing failure must not
// invite the user to repeat a successful operation (or overwrite it).
logger.warn("[SFTP] Archive created but directory refresh failed:", error);
}
}
return result;
}
@@ -0,0 +1,131 @@
/** @test issue #4132 — a one-click duplicate copies next to the source without a conflict prompt */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate transfers resolve same-name conflicts to (copy) without prompting", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-entries-"));
const ownerId = `duplicate-entries-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
// A duplicate has to cover both files and folders: the conflict type key
// differs between the two and the seeded defaults must match both.
const fileNames = ["report.txt"];
const folderNames = ["photos"];
for (const name of fileNames) {
await fs.writeFile(path.join(dir, name), "original data");
}
for (const name of folderNames) {
await fs.mkdir(path.join(dir, name));
await fs.writeFile(path.join(dir, name, "nested.txt"), "nested data");
}
const pane = createEmptyPane("duplicate-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const waitForSettledRoots = async (expected: number) => {
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (roots.length === expected && roots.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const sources = [...fileNames, ...folderNames].map(name => ({ name, isDirectory: folderNames.includes(name) }));
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
// The duplicate option must resolve conflicts by itself; a prompt here
// would defeat the one-click action the issue asks for.
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length);
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.equal(roots.length, sources.length, JSON.stringify(roots.map(row => row.fileName)));
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
for (const name of fileNames) {
assert.equal(await fs.readFile(path.join(dir, name), "utf8"), "original data");
}
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy)", "nested.txt"), "utf8"), "nested data");
// A second duplicate must pick the next free name instead of colliding
// with the copy created by the first run.
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length * 2);
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy 2)", "nested.txt"), "utf8"), "nested data");
// The tree's ".." navigation row must never be duplicated: it would copy
// an ancestor into itself.
const tasksBefore = sftpTransferCenterStore.getOwnerTasks(ownerId).length;
let navigationResults: unknown;
await act(async () => {
navigationResults = await ops.startTransfer(
[{ name: "..", isDirectory: true }],
"left",
"left",
{ sourcePath: dir, targetPath: dir, duplicate: true },
);
});
assert.deepEqual(navigationResults, []);
assert.equal(sftpTransferCenterStore.getOwnerTasks(ownerId).length, tasksBefore);
assert.deepEqual(ops.conflicts, []);
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,109 @@
/** @test issue #4132 — a duplicate must not reuse a destination an earlier copy still holds */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate names around a destination an in-flight copy has not created yet", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-inflight-"));
const ownerId = `duplicate-inflight-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
let releaseFirst: (() => void) | undefined;
const firstCopyGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
let streamStarts = 0;
await fs.writeFile(path.join(dir, "report.txt"), "original data");
const pane = createEmptyPane("duplicate-inflight-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
streamStarts += 1;
// Hold the first copy before it writes its destination: the second
// duplicate then sees no "(copy)" on disk even though one is reserved.
if (streamStarts === 1) await firstCopyGate;
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const sources = [{ name: "report.txt", isDirectory: false }];
const options = { sourcePath: dir, targetPath: dir, duplicate: true };
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
// First duplicate starts copying and blocks before writing "(copy)".
let firstRun: ReturnType<typeof ops.startTransfer> | undefined;
await act(async () => {
firstRun = ops.startTransfer(sources, "left", "left", options);
const deadline = Date.now() + 3000;
while (streamStarts === 0 && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
});
assert.equal(streamStarts, 1, "the first copy should be in flight");
// Second duplicate runs while "(copy)" is still absent from the disk.
await act(async () => { await ops.startTransfer(sources, "left", "left", options); });
assert.equal(ops.conflicts.length, 0, "duplicate must not prompt for a reserved name");
releaseFirst?.();
await act(async () => { await firstRun; });
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.deepEqual(roots.map(row => row.fileName).sort(), ["report (copy 2).txt", "report (copy).txt"]);
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
} finally {
releaseFirst?.();
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -61,10 +61,11 @@ export function useSftpTransferConflictOps() {
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
isTargetTaken?: (targetPath: string) => boolean,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
@@ -73,6 +74,9 @@ export function useSftpTransferConflictOps() {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
// A queued/paused duplicate may not have created its destination yet,
// so the filesystem alone would hand out the same name twice.
if (isTargetTaken?.(targetPath)) continue;
// Unsupported LSTAT must propagate — do not treat as a free name.
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
@@ -0,0 +1,161 @@
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 type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { useSftpArchiveDialog } from "./useSftpArchiveDialog";
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; });
type Params = Parameters<typeof useSftpArchiveDialog>[0];
async function mount(overrides: Partial<Params> = {}) {
let params: Params = {
paneId: "pane-1", connectionId: "conn-1", isLocal: false, isActive: true,
t: (key) => key, onCreateArchive: async () => ({ success: true }), onCreated: () => {},
...overrides,
};
let current!: ReturnType<typeof useSftpArchiveDialog>;
function Probe() {
current = useSftpArchiveDialog(params);
return null;
}
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
return {
get value() { return current; },
async update(changes: Partial<Params>) {
params = { ...params, ...changes };
await act(async () => { renderer.update(React.createElement(Probe)); });
},
async unmount() { await act(async () => { renderer.unmount(); }); },
};
}
test("archive dialog snapshots nested selections, format, and connection identity", async (t) => {
const requests: unknown[] = [];
const reloaded: string[] = [];
const harness = await mount({
onCreateArchive: async (options, target) => { requests.push({ options, target }); return { success: true }; },
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/site/config/app.json"]); });
assert.deepEqual(harness.value.state?.paths, ["assets", "config/app.json"]);
assert.equal(harness.value.state?.directory, "/srv/site");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); harness.value.setFormat("zip"); });
assert.equal(harness.value.state?.archiveName, "backup.zip");
await act(async () => { await harness.value.submit(); });
assert.deepEqual(requests, [{
options: { directory: "/srv/site", paths: ["assets", "config/app.json"], archiveName: "backup.zip", format: "zip", overwrite: false },
target: { paneId: "pane-1", connectionId: "conn-1" },
}]);
assert.deepEqual(reloaded, ["/srv/site"]);
assert.equal(harness.value.state, null);
});
test("archive dialog requires a separate overwrite decision and revokes it on edits", async (t) => {
const overwriteFlags: Array<boolean | undefined> = [];
const harness = await mount({ onCreateArchive: async (options) => {
overwriteFlags.push(options.overwrite);
return options.overwrite ? { success: true } : { success: false, code: "EEXIST" };
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [], "overwrite is unavailable before a collision");
await act(async () => { await harness.value.submit(); });
assert.equal(harness.value.state?.confirmOverwrite, true);
await act(async () => { await harness.value.submit(); });
assert.deepEqual(overwriteFlags, [false], "Enter must not confirm overwrite");
await act(async () => { harness.value.setArchiveName("other.tar.gz"); });
assert.equal(harness.value.state?.confirmOverwrite, false);
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false]);
await act(async () => { await harness.value.submit(); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false, false, true]);
assert.equal(harness.value.state, null);
});
test("pending archive creation blocks duplicate submits, edits, and dismissal", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
let requests = 0;
const harness = await mount({ onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => {
pending = harness.value.submit();
void harness.value.submit();
harness.value.close();
harness.value.setArchiveName("changed.tar.gz");
harness.value.open(["/different"]);
});
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, true);
assert.equal(harness.value.state?.archiveName, "site.tar.gz");
await act(async () => { finish({ success: true }); await pending; });
assert.equal(harness.value.state, null);
});
test("switching connections dismisses the old dialog and ignores its late result", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
const reloaded: string[] = [];
let requests = 0;
const harness = await mount({
onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
},
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => { pending = harness.value.submit(); });
await harness.update({ connectionId: "conn-2" });
assert.equal(harness.value.state, null);
await act(async () => { harness.value.open(["/srv/new-site"]); });
await act(async () => { finish({ success: false, code: "EEXIST" }); await pending; });
assert.equal(requests, 1);
assert.equal(harness.value.state?.archiveName, "new-site.tar.gz");
assert.equal(harness.value.state?.confirmOverwrite, false);
assert.deepEqual(reloaded, []);
});
test("tab switches invalidate the old form even when its connection stays mounted", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; return { success: true }; } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
const staleSubmit = harness.value.submit;
await harness.update({ isActive: false });
await act(async () => { await staleSubmit(); });
assert.equal(harness.value.state, null);
assert.equal(requests, 0);
await harness.update({ isActive: true, isLocal: true });
await act(async () => { harness.value.open(["/srv/site"]); });
assert.equal(harness.value.state, null);
});
test("invalid names do not invoke the backend; failures keep a retryable form", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; throw new Error("zip is not installed"); } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); harness.value.setArchiveName("../backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 0);
assert.equal(harness.value.state?.error, "sftp.archive.invalidName");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, false);
assert.equal(harness.value.state?.error, "zip is not installed");
});
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
prepareSftpArchiveSelection,
validateSftpArchiveName,
withSftpArchiveExtension,
type SftpArchiveCreationOptions,
type SftpArchiveCreationResult,
type SftpArchiveCreationTarget,
type SftpArchiveFormat,
} from "../../../domain/sftpArchiveCreation";
export type SftpArchiveTarget = SftpArchiveCreationTarget;
export interface SftpArchiveDialogState {
target: SftpArchiveTarget;
directory: string;
paths: string[];
archiveName: string;
format: SftpArchiveFormat;
busy: boolean;
error: string | null;
confirmOverwrite: boolean;
}
interface UseSftpArchiveDialogParams {
paneId: string;
connectionId?: string;
isLocal: boolean;
isActive: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
onCreateArchive?: (
options: SftpArchiveCreationOptions,
target: SftpArchiveTarget,
) => Promise<SftpArchiveCreationResult>;
onCreated: (directory: string) => void;
}
export function useSftpArchiveDialog(params: UseSftpArchiveDialogParams) {
const [state, setState] = useState<SftpArchiveDialogState | null>(null);
const stateRef = useRef(state);
const paramsRef = useRef(params);
paramsRef.current = params;
const generation = useRef(0);
const update = useCallback((next: SftpArchiveDialogState | null) => {
stateRef.current = next;
setState(next);
}, []);
const matchesTarget = useCallback((target: SftpArchiveTarget) => {
const current = paramsRef.current;
return current.isActive && !current.isLocal
&& current.paneId === target.paneId
&& current.connectionId === target.connectionId;
}, []);
// A dialog belongs to the connection and pane on which it was opened. A tab
// switch may leave its component mounted, so visibility is part of identity.
useEffect(() => {
if (stateRef.current && !matchesTarget(stateRef.current.target)) {
generation.current += 1;
update(null);
}
}, [params.paneId, params.connectionId, params.isLocal, params.isActive, matchesTarget, update]);
useEffect(() => () => { generation.current += 1; }, []);
const open = useCallback((sourcePaths: string[]) => {
const current = paramsRef.current;
if (!current.connectionId || current.isLocal || !current.isActive
|| !current.onCreateArchive || stateRef.current?.busy) return;
generation.current += 1;
const target = { paneId: current.paneId, connectionId: current.connectionId };
try {
const selection = prepareSftpArchiveSelection(sourcePaths);
update({
target,
directory: selection.directory,
paths: selection.paths,
archiveName: withSftpArchiveExtension(selection.defaultName, "tar.gz"),
format: "tar.gz",
busy: false,
error: null,
confirmOverwrite: false,
});
} catch {
update({
target, directory: "", paths: [], archiveName: "", format: "tar.gz",
busy: false, error: current.t("sftp.archive.invalidSelection"), confirmOverwrite: false,
});
}
}, [update]);
const close = useCallback(() => {
if (stateRef.current?.busy) return;
generation.current += 1;
update(null);
}, [update]);
const setArchiveName = useCallback((archiveName: string) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({ ...current, archiveName, error: null, confirmOverwrite: false });
}, [update]);
const setFormat = useCallback((format: SftpArchiveFormat) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({
...current, format,
archiveName: withSftpArchiveExtension(current.archiveName, format),
error: null, confirmOverwrite: false,
});
}, [update]);
const submit = useCallback(async (overwrite = false) => {
const current = stateRef.current;
const { onCreateArchive, t } = paramsRef.current;
if (!current || current.busy || !onCreateArchive || !matchesTarget(current.target)
|| current.paths.length === 0 || (overwrite && !current.confirmOverwrite)) return;
// Enter in the name field must not implicitly confirm replacement.
if (current.confirmOverwrite && !overwrite) return;
const validationError = validateSftpArchiveName(current.archiveName, current.format);
if (validationError) {
update({ ...current, error: t(validationError) });
return;
}
const requestGeneration = generation.current;
update({ ...current, busy: true, error: null });
try {
const result = await onCreateArchive({
directory: current.directory,
paths: current.paths,
archiveName: current.archiveName,
format: current.format,
overwrite,
}, current.target);
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
if (!result.success) {
update({
...current, busy: false,
confirmOverwrite: result.code === "EEXIST",
error: result.code === "EEXIST" ? null : result.error || t("sftp.archive.failed"),
});
return;
}
update(null);
paramsRef.current.onCreated(current.directory);
} catch (error) {
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
update({
...current, busy: false, confirmOverwrite: false,
error: error instanceof Error ? error.message : String(error),
});
}
}, [matchesTarget, update]);
return {
state: state && matchesTarget(state.target) ? state : null,
open, close, setArchiveName, setFormat, submit,
};
}
+42 -3
View File
@@ -1326,6 +1326,13 @@ export const useSftpTransfers = ({
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
/**
* Create copies next to the originals instead of prompting for the
* same-name conflict. Each source name is resolved to the next free
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
* Used by the SFTP context menu's Duplicate action.
*/
duplicate?: boolean;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
@@ -1429,6 +1436,10 @@ export const useSftpTransfers = ({
}
for (const file of sourceFiles) {
// "." / ".." are navigation rows, not real entries. Duplicating one
// would resolve to an ancestor (e.g. "/") and could walk the copy into
// its own output.
if (options?.duplicate && (file.name === "." || file.name === "..")) continue;
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
? "upload"
@@ -1461,8 +1472,36 @@ export const useSftpTransfers = ({
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
const nextSourcePath = joinPath(sourcePath, file.name);
const nextTargetPath = joinPath(targetPath, file.name);
const targetIsLocal = targetPane.connection!.isLocal;
// A duplicate targets the folder the item already lives in, so the
// same-name conflict is guaranteed. Resolve the "(copy)" name up front
// instead of routing through the conflict dialog: the one-click action
// must not prompt, and resolving it inside processTransfer would
// re-enter the same task while it is still marked in flight.
const duplicateTarget = options?.duplicate
? await getDuplicateTarget(
{ fileName: file.name, isDirectory: file.isDirectory, targetPath: joinPath(targetPath, file.name) },
targetPane,
targetIsLocal ? null : (sftpSessionsRef.current.get(targetPane.connection!.id) ?? null),
targetIsLocal ? "auto" : (targetPane.filenameEncoding || "auto"),
// A queued or paused copy has not created its destination on disk
// yet, so the next duplicate would pick the same name and surface
// a conflict dialog. Reserve active destinations while naming.
(candidatePath) => Boolean(findActivePathConflict(
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
{
id: "",
targetPath: candidatePath,
targetConnectionId: targetIsLocal ? "local" : targetPane.connection!.id,
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
isDirectory: file.isDirectory,
},
)),
)
: null;
const nextFileName = duplicateTarget?.fileName ?? file.name;
const nextTargetPath = duplicateTarget?.targetPath ?? joinPath(targetPath, file.name);
const pathTaken = findActivePathConflict(
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
{
@@ -1481,7 +1520,7 @@ export const useSftpTransfers = ({
const attentionTask: TransferTask = {
id: crypto.randomUUID(),
batchId,
fileName: file.name,
fileName: nextFileName,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -1522,7 +1561,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: taskId,
batchId,
fileName: file.name,
fileName: nextFileName,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -36,6 +36,12 @@ export interface UseSftpTransfersResult {
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
/**
* Create copies next to the originals instead of prompting for the
* same-name conflict. Each source name is resolved to the next free
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
*/
duplicate?: boolean;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
@@ -0,0 +1,18 @@
/** Install isolated browser storage for hook tests, including Node without Web Storage. */
export function installTestLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: key => values.get(String(key)) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => { values.delete(String(key)); },
setItem: (key, value) => { values.set(String(key), String(value)); },
};
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else Reflect.deleteProperty(globalThis, 'localStorage');
};
}
+2
View File
@@ -127,6 +127,8 @@ export interface SendToCattyContext {
}
export interface SendToExternalContext {
scopeType?: 'terminal' | 'workspace' | 'global';
scopeTargetId?: string;
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
+2
View File
@@ -1597,6 +1597,7 @@ export function useAIState() {
previousMemberTerminalIds: readonly string[];
currentMemberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
activeTargetIds?: ReadonlySet<string>;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
@@ -1612,6 +1613,7 @@ export function useAIState() {
previousMemberTerminalIds: input.previousMemberTerminalIds,
currentMemberTerminalIds: input.currentMemberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
activeTargetIds: input.activeTargetIds,
});
if (!result.changed) return;
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const token = useRef<string | null>(null);
const active = useRef(false);
const mounted = useRef(true);
const reset = useCallback(() => {
const previous = token.current;
token.current = null;
setPreview(null);
setError(null);
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
};
}, []);
const run = async (operation: () => Promise<void>) => {
if (active.current) return;
active.current = true;
setBusy(true);
setError(null);
try { await operation(); }
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
finally { active.current = false; if (mounted.current) setBusy(false); }
};
return {
preview, busy, error, reset,
prepare: (input: string) => run(async () => {
const { origin } = parsePluginGitHubRepository(input);
const bridge = netcattyBridge.require();
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
reset();
const result = await bridge.prepareGitHubPlugin(origin);
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
token.current = result.token;
setPreview(result);
}),
install: () => run(async () => {
const bridge = netcattyBridge.require();
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
const selected = token.current;
// The host consumes the selection even on failure; retry starts with a new review.
token.current = null;
setPreview(null);
await bridge.installGitHubPlugin(selected);
await onInstalled();
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
}),
};
}
@@ -4,6 +4,9 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -4,6 +4,9 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -86,6 +89,15 @@ test("global broadcast availability follows visible orphan session count", async
await act(async () => {
current().removeSessionFromWorkspace(second.id);
});
// The one-member workspace stays intact; only the detached terminal is
// an orphan, so global broadcast remains unavailable until another opens.
assert.equal(current().workspaces.length, 1);
assert.equal(current().sessions.find(session => session.id === first.id)?.workspaceId, current().workspaces[0].id);
assert.deepEqual(current().orphanSessions.map(session => session.id), [second.id]);
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().createLocalTerminal({ shellName: "04" }); });
assert.equal(current().orphanSessions.length, 2);
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
} finally {
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Workspace } from '../../domain/models';
import { collectSessionIds } from '../../domain/workspace';
import { moveWorkspacePaneInWorkspaces } from './useSessionState';
// Issue #4037: moving a member pane must rearrange only the tree shape.
// Session ids, workspace id, anchorChatId (AI binding), focus fields and tab
// order all live outside the touched surface, and these tests pin that.
const workspace = (id = 'ws-1'): Workspace => ({
id,
title: 'Workspace',
focusedSessionId: 's1',
focusSessionOrder: ['s1', 's2', 's3'],
anchorChatId: 'chat-anchor-1',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 's1' },
{ id: 'pane-2', type: 'pane', sessionId: 's2' },
{ id: 'pane-3', type: 'pane', sessionId: 's3' },
],
sizes: [1, 1, 1],
},
});
test('moveWorkspacePaneInWorkspaces rearranges the tree and preserves every identity field', () => {
const ws = workspace();
const next = moveWorkspacePaneInWorkspaces([ws], 'ws-1', 's3', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(next.length, 1);
const moved = next[0];
assert.equal(moved.id, ws.id, 'workspace id (AI binding key) unchanged');
assert.equal(moved.title, ws.title);
assert.equal(moved.anchorChatId, 'chat-anchor-1', 'anchorChatId unchanged');
assert.equal(moved.focusedSessionId, 's1', 'focus stays on the same session id');
assert.deepEqual(moved.focusSessionOrder, ['s1', 's2', 's3'], 'tab/focus order unchanged');
assert.deepEqual(collectSessionIds(moved.root), ['s3', 's1', 's2']);
assert.notEqual(moved.root, ws.root, 'the tree shape actually changed');
});
test('moveWorkspacePaneInWorkspaces leaves other workspaces and no-op cases untouched', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
// Same-reference no-op: pane dropped onto itself.
const selfDrop = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 's2', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(selfDrop[0], ws1, 'no-op workspace keeps its reference');
assert.equal(selfDrop[1], ws2);
// Unknown session / foreign workspace id: nothing moves.
const ghost = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 'ghost', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(ghost[0], ws1);
assert.equal(ghost[1], ws2);
const foreign = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-other', 's1', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(foreign[0], ws1);
assert.equal(foreign[1], ws2);
});
test('moveWorkspacePaneInWorkspaces only rewrites the targeted workspace entry', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
const next = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-2', 's2', {
direction: 'vertical',
position: 'right',
targetSessionId: 's3',
});
assert.equal(next[0], ws1, 'sibling workspaces keep their references');
assert.notEqual(next[1], ws2);
assert.deepEqual(collectSessionIds(next[1].root), ['s1', 's3', 's2']);
});
+68 -8
View File
@@ -19,6 +19,7 @@ createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
movePaneInWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
@@ -42,8 +43,9 @@ import {
import {
closeSessionsState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import { getAISessionsSnapshot } from './aiSessionsStore';
import { getPersistedAiSessionsSnapshot } from './aiStateSnapshots';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
@@ -122,6 +124,26 @@ export function appendWorkspaceRootPaneIfMissing(
});
}
/**
* Issue #4037: rearrange an existing member pane within its own workspace.
* Only `workspaces[].root` shape changes — session ids, tab order, focus
* fields and every AI binding (anchorChatId / activeSessionIdMap scopes) are
* untouched, because the moved session keeps its id and its workspaceId.
*/
export function moveWorkspacePaneInWorkspaces(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (!collectSessionIds(ws.root).includes(sessionId)) return ws;
const nextRoot = movePaneInWorkspace(ws.root, sessionId, hint);
return nextRoot === ws.root ? ws : { ...ws, root: nextRoot };
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
@@ -615,6 +637,11 @@ export const useSessionState = ({
currentActiveTabId: activeTabStore.getActiveTabId(),
preferredTabId: activeTabStore.getPreviousActiveTabId(),
tabOrder: tabOrderRef.current,
// AI chat recency decides which surviving member receives focus/anchor
// after member loss (#4098 phase 2); hot store first, persisted fallback.
aiSessions: getAISessionsSnapshot().sessions.length > 0
? getAISessionsSnapshot().sessions
: getPersistedAiSessionsSnapshot(),
});
setWorkspaces(result.workspaces);
@@ -822,6 +849,20 @@ export const useSessionState = ({
});
}, [setActiveTabId]);
// Issue #4037: drop an existing member pane back into its own workspace to
// rearrange it. Deliberately touches nothing but the pane tree: no session
// fields, no tab order, no focus changes (focusedSessionId keeps pointing
// at the same session id, so focus visually follows the moved pane), and
// no AI state — the anchorChatId lives on the workspace, which keeps its id.
const moveWorkspacePane = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setWorkspaces(prev => moveWorkspacePaneInWorkspaces(prev, workspaceId, sessionId, hint));
}, []);
// Merge two tabs into one workspace. When one of them already belongs to a
// workspace, that workspace (and its id) is reused instead of dissolving
// and recreating: the id is what workspace-scoped AI chats bind to, so
@@ -1106,6 +1147,25 @@ export const useSessionState = ({
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
}, []);
// Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to.
// Written by the AI side panel when a chat becomes active in the workspace
// scope; consumed by the non-positional fallbacks and persisted to the
// session restore snapshot.
const setWorkspaceAnchorChat = useCallback((workspaceId: string, chatId: string | null) => {
setWorkspaces(prev => {
const index = prev.findIndex(ws => ws.id === workspaceId);
if (index < 0) return prev;
if (prev[index].anchorChatId === (chatId ?? undefined)) return prev;
const next = prev.slice();
next[index] = chatId
? { ...prev[index], anchorChatId: chatId }
: Object.fromEntries(
Object.entries(prev[index]).filter(([key]) => key !== 'anchorChatId'),
) as Workspace;
return next;
});
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
@@ -1435,18 +1495,16 @@ export const useSessionState = ({
sessions: sessionsRef.current,
workspaces: workspacesRef.current,
sessionId,
aiSessions: getAISessionsSnapshot().sessions.length > 0
? getAISessionsSnapshot().sessions
: getPersistedAiSessionsSnapshot(),
});
if (!result.changed) return;
setWorkspaces(result.workspaces);
setSessions(result.sessions);
setTabOrder((prevTabOrder) => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
if (!tabInsertionTarget) return prevTabOrder;
const allTabIds = [
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
@@ -1455,7 +1513,7 @@ export const useSessionState = ({
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
prevTabOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
@@ -1515,6 +1573,7 @@ export const useSessionState = ({
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
@@ -1523,6 +1582,7 @@ export const useSessionState = ({
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
@@ -4,6 +4,10 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
import { collectSessionIds } from "../../domain/workspace.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -121,6 +125,15 @@ test("merged tab titles follow the workspace lifecycle until the user renames it
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
await act(async () => { current().closeSession(firstSessionId); });
assert.equal(workspace(workspaceId).title, "Pinned");
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), [secondSessionId]);
assert.equal(current().sessions.find(session => session.id === secondSessionId)?.workspaceId, workspaceId);
await act(async () => { current().closeSession(secondSessionId); });
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), []);
let leftBaseSessionId = "";
let leftJoiningSessionId = "";
await act(async () => {
+48 -9
View File
@@ -50,6 +50,7 @@ import {
tryBeginSendForKey,
} from './ai/draftSendGate';
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import { latestAIActiveSessionMapSnapshot, latestAISessionsSnapshot } from '../application/state/aiStateSnapshots';
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
@@ -316,6 +317,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
focusedSessionId,
onSetWorkspaceAnchorChat,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
@@ -444,9 +446,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
preferredTerminalId: focusedSessionId,
visibleSessionIds: visibleHistorySessionIds,
});
// Issue #4098 phase 2: this workspace's anchored chat — used by the panel
// view resolver before any positional fallback could apply.
const workspaceAnchorChatId = scopeType === 'workspace'
? workspaces.find((workspace) => workspace.id === scopeTargetId)?.anchorChatId
: undefined;
// getScopedHistorySessions orders by scope match first, recency second.
const preferredHistorySessionId = historySessions[0]?.id ?? null;
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
@@ -641,6 +650,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}, { initialDelayMs: 250 });
}, [isVisible, terminalSessions, activeSessionId]);
// Workspace maintenance can retarget an inherited chat earlier in this same
// passive-effect flush. Before clearing a selection, recheck its membership
// against the synchronous snapshot instead of this render's old history.
const isSessionVisibleInCurrentScope = useCallback((sessionId: string) => (
getScopedHistorySessions(
latestAISessionsSnapshot ?? sessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
latestAIActiveSessionMapSnapshot ?? activeSessionIdMap,
).some((session) => session.id === sessionId)
), [sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds, activeSessionIdMap]);
useEffect(() => {
if (!isVisible) return;
// Predicate must match normalizePanelView's list (scoped history), not the
@@ -648,16 +672,23 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (!shouldForceDraftViewSync(
explicitPanelView,
normalizedPanelView,
(sessionId) => historySessions.some((session) => session.id === sessionId),
isSessionVisibleInCurrentScope,
)) {
return;
}
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, isSessionVisibleInCurrentScope, showDraftView]);
useEffect(() => {
if (!activeSession) return;
// Issue #4098 phase 2: a chat becoming active in the workspace scope pins
// the workspace's anchorChatId so member loss / restart resolve by id,
// never by position.
if (scopeType === 'workspace' && scopeTargetId && isVisible) {
onSetWorkspaceAnchorChat?.(scopeTargetId, activeSession.id);
}
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
@@ -665,18 +696,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeSession,
activeSessionIdMap,
scopeKey,
scopeType,
scopeTargetId,
isVisible,
onSetWorkspaceAnchorChat,
setActiveSessionId,
]);
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
// Keep ownership while an explicit session is still displayable in this
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
// Keep a selection that maintenance just made displayable, but always
// honor an explicit New Chat draft. Out-of-scope / missing ids still clear.
const selectedSessionId = explicitPanelView?.mode === 'session'
? explicitPanelView.sessionId : persistedSessionId;
if (
explicitPanelView?.mode === 'session'
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
explicitPanelView?.mode !== 'draft'
&& selectedSessionId
&& isSessionVisibleInCurrentScope(selectedSessionId)
) {
return;
}
@@ -686,7 +723,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
isVisible,
normalizedPanelView.mode,
explicitPanelView,
historySessions,
isSessionVisibleInCurrentScope,
persistedSessionId,
setActiveSessionId,
]);
@@ -1788,6 +1825,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
try {
const existingExternalSessionId = currentSession?.externalSessionId;
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
scopeType,
scopeTargetId,
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
+4 -2
View File
@@ -109,8 +109,10 @@ export interface AIChatSidePanelProps {
scopeLabel?: string;
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
focusedSessionId?: string;
/** Live workspaces, used to resolve workspace titles for `/chats`. */
workspaces?: Array<{ id: string; title: string }>;
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
/** Live workspaces (with their anchor chat), used for scope titles and anchor resolution. */
workspaces?: Array<{ id: string; title: string; anchorChatId?: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
+19 -4
View File
@@ -49,11 +49,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
showAlgorithmOverrides,
setShowAlgorithmOverrides,
chainedHosts,
hostChainInherited = false,
setActiveSubPanel,
clearHostChain,
proxySummaryType,
proxySummaryLabel,
proxySummaryTooltip,
proxyInherited = false,
clearProxyConfig,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
@@ -641,9 +643,16 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.jumpHosts")}
action={
chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
<div className="flex items-center gap-1">
{hostChainInherited && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("vault.groups.details.inherited")}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
</div>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
@@ -664,8 +673,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
{hostChainInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</div>
{!hostChainInherited && (
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
@@ -674,6 +685,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
clearHostChain();
}}
/>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
@@ -735,7 +747,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<X size={14} />
</Button>
</div>
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId || proxyInherited ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
@@ -750,6 +762,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
{proxyInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
@@ -758,6 +771,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</Tooltip>
</TooltipProvider>
</button>
{!proxyInherited && (
<Button
type="button"
variant="ghost"
@@ -768,6 +782,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
>
<X size={14} />
</Button>
)}
</div>
) : (
<Button
+67
View File
@@ -1,4 +1,5 @@
import type { GroupConfig } from "../domain/models";
import { applyGroupDefaults } from "../domain/groupConfig";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
@@ -157,3 +158,69 @@ export const prepareProxyConfigForSave = (params: {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
export const EMPTY_CUSTOM_PROXY_DRAFT: ProxyConfig = { type: "http", host: "", port: 8080 };
export const resolveDisplayProxyProfileId = (
form: Pick<Host, "directProxy" | "proxyProfileId" | "proxyConfig">,
inheritedProfileId?: string,
): string | undefined => {
if (form.directProxy || form.proxyConfig !== undefined) return form.proxyProfileId;
return form.proxyProfileId ?? inheritedProfileId;
};
export const nextFormAfterSelectProxyProfile = (
prev: Host,
profileId: string | undefined,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) {
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& groupDefaults
? applyGroupDefaults(prev, groupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: prev.proxyConfig ?? inheritedConfig ?? EMPTY_CUSTOM_PROXY_DRAFT,
} as Host;
}
return { ...rest, proxyProfileId: profileId } as Host;
};
export const resolveFormHostChainIds = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string[] => {
if (host.hostChain !== undefined) return host.hostChain.hostIds || [];
if (!groupDefaults) return [];
return applyGroupDefaults(host, groupDefaults).hostChain?.hostIds || [];
};
export const nextFormAfterClearHostChain = (
prev: Host,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const groupWouldRestore = Boolean(
groupDefaults
&& applyGroupDefaults({ ...prev, hostChain: undefined }, groupDefaults).hostChain?.hostIds?.length,
);
if (prev.hostChain === undefined || groupWouldRestore) {
return { ...prev, hostChain: { hostIds: [] } };
}
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
};
export const nextFormAfterRemoveHostFromChain = (
prev: Host,
index: number,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const ids = resolveFormHostChainIds(prev, groupDefaults).filter((_, i) => i !== index);
if (ids.length > 0) return { ...prev, hostChain: { hostIds: ids } };
return nextFormAfterClearHostChain(prev, groupDefaults);
};
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { applyGroupDefaults } from "../domain/groupConfig.ts";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -549,3 +554,198 @@ test("HostDetailsPanel expands notes when the host already has notes", () => {
"expected notes to render after connection settings",
);
});
const networkSshHost: Host = {
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
group: "network",
};
const jumpHost: Host = {
id: "jump-1",
label: "Bastion",
hostname: "bastion.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt: 1,
};
const hostOwnedJump: Host = {
...jumpHost,
id: "jump-host",
label: "Edge",
hostname: "edge.example.com",
};
const renderHostDetailsWithGroup = (
initialData: Host,
groupConfig: Record<string, unknown>,
extraHosts: Host[] = [],
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: extraHosts,
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", ...groupConfig }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
test("HostDetailsPanel displays inherited group HTTP proxy", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /group-proxy\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host proxy override wins over the group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
proxyConfig: { type: "http", host: "host-proxy.example.com", port: 9090 },
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /host-proxy\.example\.com/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel direct proxy hides inherited group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
directProxy: true,
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /Direct \(no proxy\)/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
});
test("HostDetailsPanel displays inherited group jump hosts", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost]);
assert.match(markup, /bastion\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host jump-host override wins over the group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: ["jump-host"] },
}, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost, hostOwnedJump]);
assert.match(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("selecting Custom while inheriting a saved proxy keeps a custom draft instead of the profile", () => {
const group = { proxyProfileId: "office-proxy" };
const next = nextFormAfterSelectProxyProfile(networkSshHost, undefined, group);
assert.equal(next.proxyProfileId, undefined);
assert.equal(next.directProxy, undefined);
assert.deepEqual(next.proxyConfig, { type: "http", host: "", port: 8080 });
assert.equal(resolveDisplayProxyProfileId(next, "office-proxy"), undefined);
assert.equal(applyGroupDefaults(next, group).proxyProfileId, undefined);
});
test("removing the last hop of a multi-hop inherited chain keeps an empty override", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const afterFirst = nextFormAfterRemoveHostFromChain(networkSshHost, 0, group);
assert.deepEqual(afterFirst.hostChain, { hostIds: ["jump-host"] });
const afterLast = nextFormAfterRemoveHostFromChain(afterFirst, 0, group);
assert.deepEqual(afterLast.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(afterLast, group).hostChain, { hostIds: [] });
});
test("clearing an inherited-only chain keeps an empty override instead of restoring group hops", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const cleared = nextFormAfterClearHostChain(networkSshHost, group);
assert.deepEqual(cleared.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(cleared, group).hostChain, { hostIds: [] });
});
test("HostDetailsPanel custom proxy draft hides the inherited saved proxy", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...networkSshHost,
proxyConfig: { type: "http", host: "", port: 8080 },
},
availableKeys: [],
identities: [],
proxyProfiles: [{
id: "office-proxy",
label: "Office Proxy",
config: { type: "http", host: "office-proxy.example.com", port: 8080 },
createdAt: 1,
}],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", proxyProfileId: "office-proxy" }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.doesNotMatch(markup, /Office Proxy/);
assert.doesNotMatch(markup, /office-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel empty jump-host override hides the inherited group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: [] },
}, {
hostChain: { hostIds: ["jump-1", "jump-host"] },
}, [jumpHost, hostOwnedJump]);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
+52 -31
View File
@@ -55,6 +55,11 @@ import {
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveFormHostChainIds,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
@@ -424,24 +429,34 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const hasHostProxyOverride = form.directProxy === true
|| form.proxyProfileId !== undefined
|| form.proxyConfig !== undefined;
const displayProxyProfileId = resolveDisplayProxyProfileId(form, effectiveAuthHost.proxyProfileId);
const displayProxyConfig = form.directProxy
? undefined
: (form.proxyConfig ?? effectiveAuthHost.proxyConfig);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
() => proxyProfiles.find((profile) => profile.id === displayProxyProfileId),
[displayProxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const hasMissingProxyProfile = Boolean(displayProxyProfileId && !selectedProxyProfile);
const proxyInherited = !hasHostProxyOverride && Boolean(
displayProxyProfileId || displayProxyConfig?.host || displayProxyConfig?.command,
) && Boolean(effectiveGroupDefaults?.proxyProfileId || effectiveGroupDefaults?.proxyConfig);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
: formatProxyConfigType(selectedProxyProfile?.config || displayProxyConfig) || "HTTP";
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: formatProxyConfigEndpoint(form.proxyConfig);
: formatProxyConfigEndpoint(displayProxyConfig);
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
: `${formatProxyConfigType(displayProxyConfig)} ${formatProxyConfigEndpoint(displayProxyConfig)}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -458,13 +473,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& effectiveGroupDefaults
? applyGroupDefaults(prev, effectiveGroupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
proxyConfig: updateProxyConfigField(prev.proxyConfig ?? inheritedConfig, field, value),
} as Host;
});
},
[],
[effectiveGroupDefaults],
);
const clearProxyConfig = useCallback(() => {
@@ -475,12 +496,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
setForm((prev) => nextFormAfterSelectProxyProfile(prev, profileId, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
@@ -493,24 +510,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setForm((prev) => ({
...prev,
hostChain: {
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
hostIds: [...resolveFormHostChainIds(prev, effectiveGroupDefaults), hostId],
},
}));
};
const removeHostFromChain = (index: number) => {
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
setForm((prev) => nextFormAfterRemoveHostFromChain(prev, index, effectiveGroupDefaults));
};
const clearHostChain = useCallback(() => {
setForm((prev) => {
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
});
}, []);
setForm((prev) => nextFormAfterClearHostChain(prev, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
@@ -773,8 +784,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setActiveSubPanel("none");
};
const displayHostChainIds = useMemo(
() => form.hostChain !== undefined
? (form.hostChain.hostIds || [])
: (effectiveAuthHost.hostChain?.hostIds || []),
[effectiveAuthHost.hostChain?.hostIds, form.hostChain],
);
const hostChainInherited = form.hostChain === undefined && displayHostChainIds.length > 0;
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
const chainedIds = new Set(displayHostChainIds);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
@@ -782,14 +801,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
}, [allHosts, form.id, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds, form.id]);
const chainedHosts = useMemo(() => {
const ids = form.hostChain?.hostIds || [];
return ids
return displayHostChainIds
.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[];
}, [allHosts, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds]);
const groupOptions: ComboboxOption[] = useMemo(() => {
return groups.map((g) => ({
@@ -905,11 +923,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
if (activeSubPanel === "proxy") {
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyConfig={displayProxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
selectedProxyProfileId={displayProxyProfileId}
directProxy={form.directProxy}
allowClear={hasHostProxyOverride}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
@@ -1237,11 +1256,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
chainedHosts={chainedHosts}
hostChainInherited={hostChainInherited}
setActiveSubPanel={setActiveSubPanel}
clearHostChain={clearHostChain}
proxySummaryType={proxySummaryType}
proxySummaryLabel={proxySummaryLabel}
proxySummaryTooltip={proxySummaryTooltip}
proxyInherited={proxyInherited}
clearProxyConfig={clearProxyConfig}
/>
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { Identity, Host } from "../types";
async function withWizard(
run: (fixture: Awaited<ReturnType<typeof mountWizard>>) => Promise<void>,
identities: Identity[] = [],
) {
const fixture = await mountWizard(identities);
try {
await run(fixture);
} finally {
await fixture.cleanup();
}
}
async function mountWizard(identities: Identity[]) {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("IS_REACT_ACT_ENVIRONMENT", true);
const { act, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { default: QuickConnectWizard } = await import("./QuickConnectWizard");
const target = { hostname: "example.com" };
const connected: Host[] = [];
const saved: Host[] = [];
let closeCount = 0;
function Fixture() {
const [open, setOpen] = useState(true);
return (
<I18nProvider locale="en">
{open && <QuickConnectWizard
open target={target} keys={[]} identities={identities}
onConnect={(host) => connected.push(host)}
onSaveHost={(host) => saved.push(host)}
onClose={() => { closeCount++; setOpen(false); }}
/>}
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
await act(async () => { root.render(<Fixture />); });
const button = (name: string) => {
const element = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.getAttribute("aria-label") === name || candidate.textContent?.trim() === name);
assert.ok(element, `Button ${name} is present`);
return element;
};
const input = (selector: string) => {
const element = document.querySelector<HTMLInputElement>(selector);
assert.ok(element, `Input ${selector} is present`);
return element;
};
const overlay = () => {
const element = document.querySelector<HTMLElement>(".fixed.inset-0");
assert.ok(element, "Quick Connect is still open");
return element;
};
const click = async (element: HTMLElement) => {
await act(async () => { element.click(); });
};
const type = async (selector: string, value: string) => {
await act(async () => {
const element = input(selector);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
assert.equal(input(selector).value, value);
};
const key = async (element: HTMLElement, key: string) => {
await act(async () => { element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true })); });
};
const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); };
const outsideClick = async () => {
await act(async () => {
overlay().dispatchEvent(new dom.window.MouseEvent("mousedown", { bubbles: true }));
overlay().click();
});
assert.equal(closeCount, 0, "Background clicks must not close Quick Connect");
overlay();
};
return {
button, input, overlay, click, type, key, settle, outsideClick, connected, saved,
closeCount: () => closeCount,
async cleanup() {
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);
}
},
};
}
test("background clicks preserve each Quick Connect step and typed connection details", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.outsideClick();
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.outsideClick();
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Continue"));
await f.type("#quick-password", "partly configured password");
await f.outsideClick();
await f.key(f.input("#quick-password"), "Escape");
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Back"));
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Back"));
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.click(f.button("Continue"));
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Continue"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.connected[0].password, "partly configured password");
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
});
});
test("the portaled credential picker retains the wizard and selected identity", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.click(f.input('[role="combobox"]'));
await f.settle();
const option = document.querySelector<HTMLElement>('[role="option"]');
assert.ok(option);
assert.equal(f.overlay().contains(option), false, "Options are portaled outside the wizard DOM");
await f.click(option);
assert.equal(f.closeCount(), 0);
assert.equal(f.input('[role="combobox"]').value, "Production identity");
assert.equal(f.input('input[type="number"]').value, "2222");
await f.outsideClick();
await f.click(f.button("Connect with preset"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].identityId, "identity-alice");
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.closeCount(), 1);
}, [{ id: "identity-alice", label: "Production identity", username: "alice", authMethod: "password", password: "preset secret", created: 1 }]);
});
test("the portaled save menu preserves credentials until explicit save and connect", async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.key(f.input("#quick-username"), "Enter");
await f.type("#quick-password", "secret");
const trigger = f.button("Continue & Save");
await f.click(trigger);
await f.settle();
const menuItem = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(menuItem);
assert.equal(f.overlay().contains(menuItem), false, "Save menu is portaled outside the wizard DOM");
await f.click(menuItem.parentElement!);
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "secret");
await f.key(document.body, "Escape");
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.closeCount(), 0);
await f.click(trigger);
await f.settle();
await f.outsideClick();
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.input("#quick-password").value, "secret");
await f.click(trigger);
await f.settle();
const save = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(save);
await f.click(save);
assert.equal(f.saved.length, 1);
assert.equal(f.connected.length, 1);
assert.equal(f.saved[0], f.connected[0]);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].password, "secret");
assert.equal(f.closeCount(), 1);
});
});
test("explicit Close dismisses the protocol step", async () => {
await withWizard(async (f) => {
await f.click(f.button("Close"));
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
for (const step of ["username", "auth"]) {
test(`the header X explicitly closes the ${step} step`, async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
if (step === "auth") {
await f.click(f.button("Continue"));
await f.type("#quick-password", "secret");
}
const close = f.button("Close");
assert.ok(close.querySelector("svg.lucide-x"), "A visible X is provided on steps with Back");
await f.click(close);
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
}
+13 -2
View File
@@ -8,6 +8,7 @@ import {
Plug,
Plus,
User,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
@@ -675,7 +676,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const wizard = (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
<div
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
@@ -686,7 +686,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 border-b border-border/50">
<div className="px-6 py-4 border-b border-border/50 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<Plug size={18} className="shrink-0 text-foreground" />
<div className="min-w-0">
@@ -696,6 +696,17 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
) : null}
</div>
</div>
{step !== "protocol" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t("common.close")}
onClick={onClose}
>
<X size={16} />
</Button>
)}
</div>
+1 -1
View File
@@ -759,7 +759,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
data-section="pane-magnification-hint"
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
</div>
)}
</div>
+4
View File
@@ -245,12 +245,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onMoveWorkspacePane,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onSetWorkspaceAnchorChat,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
@@ -2712,6 +2714,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
systemMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onMoveWorkspacePane,
onConnectToHost,
onCreateLocalTerminal,
onCreateWorkspaceFromSessions,
@@ -2726,6 +2729,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onSetWorkspaceAnchorChat,
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
+29 -2
View File
@@ -15,13 +15,13 @@ import {
shouldForceDraftViewSync,
} from "./aiPanelViewState.ts";
function createSession(id: string): AISession {
function createSession(id: string, updatedAt = 1): AISession {
return {
id,
title: `Session ${id}`,
messages: [],
createdAt: 1,
updatedAt: 1,
updatedAt,
agentId: "catty",
scope: {
type: "terminal",
@@ -133,6 +133,33 @@ test("missing explicit panel view resumes the most recent matching history when
);
});
test("auto-resume prefers the anchored chat over recency and position (#4098 phase 2)", () => {
const sessions = [createSession("session-2", 9), createSession("session-1", 4)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "session-1"),
{ mode: "session", sessionId: "session-1" },
);
});
test("auto-resume without an anchor picks the most recently updated chat, not sessions[0]", () => {
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
{ mode: "session", sessionId: "session-1" },
);
});
test("a stale anchor that left the scope falls through to recency", () => {
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "deleted-chat"),
{ mode: "session", sessionId: "session-1" },
);
});
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
+35 -2
View File
@@ -39,6 +39,8 @@ export function resolveDisplayedPanelView(
sessions: AISession[],
persistedSessionId?: string | null,
scopeType: "terminal" | "workspace" = "workspace",
anchorChatId?: string | null,
preferredHistorySessionId?: string | null,
): AIPanelView {
if (panelView) {
return normalizePanelView(panelView, sessions);
@@ -66,13 +68,44 @@ export function resolveDisplayedPanelView(
return DEFAULT_PANEL_VIEW;
}
if (sessions[0]) {
return { mode: "session", sessionId: sessions[0].id };
// Prefer the workspace anchor, then the candidate selected by scoped
// history's match-rank/recency ordering. Callers without a ranked candidate
// retain the recency fallback below.
if (anchorChatId && sessions.some((s) => s.id === anchorChatId)) {
return { mode: "session", sessionId: anchorChatId };
}
// Scoped history already ranks exact/member matches before other scopes,
// then sorts each rank by recency. Preserve its selected candidate instead
// of letting a newer chat from another workspace overtake that match.
const mostRecent = preferredHistorySessionId === undefined
? resolveMostRecentlyUpdatedSession(sessions)
: sessions.find((session) => session.id === preferredHistorySessionId);
if (mostRecent) {
return { mode: "session", sessionId: mostRecent.id };
}
return DEFAULT_PANEL_VIEW;
}
/**
* The most recently updated session by `updatedAt`, or null when the list is
* empty. Ties keep the earlier entry so the result is deterministic.
*/
export function resolveMostRecentlyUpdatedSession(
sessions: AISession[],
): AISession | null {
let best: AISession | null = null;
let bestUpdatedAt = 0;
for (const session of sessions) {
const updatedAt = session.updatedAt ?? 0;
if (best === null || updatedAt > bestUpdatedAt) {
best = session;
bestUpdatedAt = updatedAt;
}
}
return bestUpdatedAt > 0 ? best : null;
}
export function normalizePanelView(
panelView: AIPanelView,
sessions: AISession[],
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { AISession } from "../../infrastructure/ai/types.ts";
import { resolveDisplayedPanelView } from "./aiPanelViewState.ts";
import {
_getScopedHistoryCacheSizeForTests,
getScopedHistorySessions,
@@ -23,6 +24,49 @@ function createSession(
};
}
test("workspace auto-resume keeps scope rank ahead of recency", () => {
const sessions = [
createSession("exact-older", { type: "workspace", targetId: "workspace-current" }, 1),
createSession("member-newer", { type: "terminal", targetId: "member" }, 2),
createSession("other-newest", { type: "workspace", targetId: "workspace-other" }, 20),
];
const history = getScopedHistorySessions(
sessions, "workspace", "workspace-current", [], new Set(), new Set(["member"]),
);
assert.deepEqual(history.map((session) => session.id), ["member-newer", "exact-older", "other-newest"]);
const preferredId = history[0]?.id ?? null;
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, preferredId),
{ mode: "session", sessionId: "member-newer" });
// The ranking only controls implicit fallback, never an explicit selection,
// saved selection, anchor, or deliberate new-chat draft.
for (const [view, persisted, anchor] of [
[{ mode: "session", sessionId: "other-newest" }, undefined, undefined],
[undefined, "other-newest", undefined],
[undefined, undefined, "other-newest"],
] as const) {
assert.deepEqual(resolveDisplayedPanelView(view, false, history, persisted, "workspace", anchor, preferredId),
{ mode: "session", sessionId: "other-newest" });
}
assert.deepEqual(resolveDisplayedPanelView({ mode: "draft" }, true, history, undefined, "workspace", undefined, preferredId),
{ mode: "draft" });
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "terminal", undefined, preferredId),
{ mode: "draft" });
});
test("workspace auto-resume prefers an exact match and retains history fallback when none exists", () => {
const exact = createSession("exact", { type: "workspace", targetId: "workspace-current" }, 1);
const others = [
createSession("other-old", { type: "workspace", targetId: "workspace-other" }, 5),
createSession("other-new", { type: "workspace", targetId: "workspace-other" }, 10),
];
for (const [sessions, expected] of [[others, "other-new"], [[...others, exact], "exact"], [[], undefined]] as const) {
const history = getScopedHistorySessions([...sessions], "workspace", "workspace-current", [], new Set());
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, history[0]?.id ?? null),
expected ? { mode: "session", sessionId: expected } : { mode: "draft" });
}
});
test("workspace history remains visible after the original workspace target is gone", () => {
const staleWorkspaceSession = createSession(
"workspace-stale",
+3 -1
View File
@@ -34,6 +34,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
allowClear?: boolean;
layout?: AsidePanelLayout;
}
@@ -51,6 +52,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
onClearProxy,
onBack,
onCancel,
allowClear = true,
layout = 'overlay',
resizable,
persistWidthStorageKey,
@@ -346,7 +348,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</>
)}
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
{allowClear && (proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>
+2 -2
View File
@@ -16,8 +16,8 @@ export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }:
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
const source = plugin?.source;
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = sourceKey
const sourceLabel = source?.kind === 'github' ? 'GitHub' : t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = source?.kind === 'github' ? [source.origin, source.tag, source.commit].filter(Boolean).join(' · ') : sourceKey
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
: sourceLabel;
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
for (const locale of ['en', 'es', 'ru', 'zh-CN', 'zh-TW']) test(`real dialog (${locale}) reviews permissions and requires fresh trust after failed installation for same-package retries and different repositories`, async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('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');
const { loadLocaleMessages, getLoadedLocaleMessages } = await import('../../../application/i18n/messages');
await loadLocaleMessages(locale);
const messages = getLoadedLocaleMessages(locale)!;
const t = (key: string) => messages[key]!;
const { PluginGitHubInstall } = await import('./PluginGitHubInstall');
const root = createRoot(document.getElementById('root')!);
const requests: string[] = [];
const installed: string[] = [];
const discarded: string[] = [];
let refreshed = 0;
let resolveDownload: ((value: NetcattyGitHubPluginPreview) => void) | undefined;
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
prepareGitHubPlugin: async (input: string) => { requests.push(input); if (requests.length === 1) throw new Error('GitHub API rate limit reached. Try again later'); return new Promise<NetcattyGitHubPluginPreview>(resolve => { resolveDownload = resolve; }); },
installGitHubPlugin: async (token: string) => { installed.push(token); if (installed.length <= 2) throw new Error('GitHub package preview expired. Download and review it again'); return {} as NetcattyInstalledPlugin; },
discardGitHubPlugin: async (token: string) => { discarded.push(token); },
} as NetcattyBridge });
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find(el => el.textContent === text)!;
const click = async (el: HTMLElement) => { assert.ok(el); await act(async () => { el.click(); await settle(); }); };
const type = async (text: string) => {
await act(async () => {
const input = document.querySelector<HTMLInputElement>('input:not([type=checkbox])')!;
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(input, text);
input.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
});
};
const preview = { token: 'reviewed-token', source: { id: 'github:fixture', kind: 'github', name: 'owner/repo', origin: 'https://github.com/owner/repo', tag: 'v1.0.0', commit: 'a'.repeat(40) }, assetName: 'plugin.ncpkg', size: 400,
archiveSha256: 'b'.repeat(64), manifest: { manifestVersion: 1, id: 'com.example.fixture', name: 'fixture', publisher: 'example', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' }, permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: 'Read only fixture' }] } } } as NetcattyGitHubPluginPreview;
try {
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
const trigger = button(t('settings.plugins.github.install'));
await click(trigger);
assert.equal(document.activeElement?.tagName, 'INPUT');
assert.ok(button(t('settings.plugins.github.download')).disabled);
await type('https://evil.com/owner/repo');
await click(button(t('settings.plugins.github.download')));
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
assert.equal(requests.length, 0);
if (locale !== 'en') {
// The existing error follows provider state without repeating the host call.
await act(async () => root.render(<I18nProvider locale="en"><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
assert.match(document.querySelector('[role=alert]')!.textContent!, /repository only/);
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
assert.equal(requests.length, 0);
}
await type('github.com/Owner/Repo.git/');
await click(button(t('settings.plugins.github.download')));
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.rateLimit'));
assert.deepEqual(requests, ['https://github.com/owner/repo']);
await click(button(t('settings.plugins.github.download')));
assert.ok(button(t('settings.plugins.github.download')).disabled);
assert.equal(document.querySelector('form')?.getAttribute('aria-busy'), 'true');
assert.equal(document.querySelector<HTMLInputElement>('input')?.disabled, true);
await act(async () => { resolveDownload!(preview); await settle(); });
const review = document.querySelector('[data-plugin-github-preview]')!;
assert.match(review.textContent!, /https:\/\/github.com\/owner\/repo/);
assert.match(review.textContent!, /v1\.0\.0.*aaaaaaaa/);
assert.match(review.textContent!, /https:\/\/example.com/);
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
await click(document.querySelector('input[type=checkbox]')!);
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
await click(button(t('settings.plugins.github.confirm')));
assert.deepEqual(installed, ['reviewed-token']);
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
assert.equal(refreshed, 0);
// A retry of the very same archive/repository is a fresh preview too.
await click(button(t('settings.plugins.github.download')));
await act(async () => { resolveDownload!({ ...preview, token: 'retry-token' }); await settle(); });
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
await click(button(t('settings.plugins.github.confirm')));
assert.deepEqual(installed, ['reviewed-token']);
await click(document.querySelector('input[type=checkbox]')!);
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
await click(button(t('settings.plugins.github.confirm')));
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
await type('another/plugin');
await click(button(t('settings.plugins.github.download')));
await act(async () => { resolveDownload!({ ...preview, token: 'different-token', source: { ...preview.source, origin: 'https://github.com/another/plugin', name: 'another/plugin' } }); await settle(); });
assert.equal(requests.at(-1), 'https://github.com/another/plugin');
assert.match(document.querySelector('[data-plugin-github-preview]')!.textContent!, /https:\/\/github.com\/another\/plugin/);
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
await click(button(t('settings.plugins.github.confirm')));
assert.deepEqual(installed, ['reviewed-token', 'retry-token']);
await click(document.querySelector('input[type=checkbox]')!);
await click(document.querySelector('input[type=checkbox]')!);
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
await click(document.querySelector('input[type=checkbox]')!);
await click(button(t('settings.plugins.github.confirm')));
assert.deepEqual(installed, ['reviewed-token', 'retry-token', 'different-token']);
assert.equal(refreshed, 1);
assert.equal(document.querySelector('[role=dialog]'), null);
await click(trigger);
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, '');
await type('owner/repo');
await click(button(t('settings.plugins.github.download')));
await act(async () => { resolveDownload!({ ...preview, token: 'cancel-token' }); await settle(); });
await click(button(t('common.back')));
assert.deepEqual(discarded, ['cancel-token']);
await act(async () => { document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, trigger);
} finally {
await act(async () => root.unmount());
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
dom.window.close();
}
});
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { Github } from 'lucide-react';
import { usePluginGitHubInstall } from '../../../application/state/usePluginGitHubInstall';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
import { PluginPackageDetails } from './PluginPackageManager';
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
export function PluginGitHubInstall({ disabled, onInstalled }: { disabled: boolean; onInstalled: () => Promise<unknown> }) {
const { t, resolvedLocale } = useI18n();
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [accepted, setAccepted] = useState(false);
const flow = usePluginGitHubInstall(async () => { await onInstalled(); setOpen(false); });
const preview = flow.preview;
const error = flow.error ? describePluginGitHubInstallError(flow.error) : null;
return <Dialog open={open} onOpenChange={(next) => {
if (flow.busy) return;
setOpen(next); flow.reset(); setAccepted(false); setInput('');
}}>
<DialogTrigger asChild><Button variant="outline" size="sm" disabled={disabled}><Github size={14} className="mr-2" />{t('settings.plugins.github.install')}</Button></DialogTrigger>
<DialogContent hideCloseButton={flow.busy} className="app-no-drag max-h-[85dvh] overflow-y-auto sm:max-w-xl" data-plugin-github-install="">
<DialogHeader>
<DialogTitle>{t('settings.plugins.github.install')}</DialogTitle>
<DialogDescription>{t('settings.plugins.github.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-4" aria-busy={flow.busy} onSubmit={(event) => { event.preventDefault(); if (!flow.busy && input.trim() && !preview) void flow.prepare(input); }}>
<label className="block space-y-2 text-sm"><span>{t('settings.plugins.github.repository')}</span>
<Input autoFocus value={input} disabled={flow.busy || Boolean(preview)} maxLength={512} placeholder="https://github.com/owner/repo" onChange={(event) => setInput(event.target.value)} />
</label>
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.formats')}</p>
{flow.busy && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.github.busy')}</p>}
{error && <div role="alert" className="break-words text-sm text-destructive">
<p>{t(error.key, error.values)}</p>
{error.details && <details className="mt-2 text-xs"><summary>{t('settings.plugins.github.error.details')}</summary><p className="whitespace-pre-wrap">{error.details}</p></details>}
</div>}
{preview && <div className="space-y-3" data-plugin-github-preview="">
<p className="break-words text-sm font-medium">{preview.manifest.name} · {preview.manifest.version}</p>
<div className="break-all rounded-md bg-muted/40 p-3 text-xs">
<p>{preview.source.origin}</p><p>{preview.source.tag} · {preview.source.commit}</p>
<p>{preview.assetName} · {preview.size.toLocaleString(resolvedLocale)} B</p>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.review')}</p>
<PluginPackageDetails plugin={{ id: preview.manifest.id, enabled: false, activeVersion: preview.manifest.version, manifest: preview.manifest,
archiveSha256: preview.archiveSha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={accepted} disabled={flow.busy} onChange={(event) => setAccepted(event.target.checked)} className="mt-1" />{t('settings.plugins.github.accept')}</label>
</div>}
<DialogFooter>
<Button type="button" variant="outline" disabled={flow.busy} onClick={() => { if (preview) { flow.reset(); setAccepted(false); } else setOpen(false); }}>{t(preview ? 'common.back' : 'common.cancel')}</Button>
{preview ? <Button type="button" disabled={flow.busy || !accepted} onClick={() => { setAccepted(false); void flow.install(); }}>{t('settings.plugins.github.confirm')}</Button>
: <Button type="submit" disabled={flow.busy || !input.trim()}>{t('settings.plugins.github.download')}</Button>}
</DialogFooter>
</form>
</DialogContent>
</Dialog>;
}
@@ -30,6 +30,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
{plugin.source?.kind === 'github' && <><dt>GitHub</dt><dd className="break-all">{plugin.source.origin}<br />{plugin.source.tag}<br /><span className="font-mono">{plugin.source.commit}</span></dd></>}
</dl>
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<div className="space-y-2">
@@ -1,4 +1,5 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { PluginGitHubInstall } from './PluginGitHubInstall';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
@@ -35,6 +36,7 @@ function PluginManagementContent({ manager }: {
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
<PluginGitHubInstall disabled={!manager.available || manager.busy} onInstalled={async () => { setPluginView('installed'); await manager.refresh(); }} />
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
</div>
@@ -70,11 +70,13 @@ test('rejected privacy draft stays open and accepted draft commits once and retu
accepted = true;
await input(1, 'INTERNAL_[A-Z0-9]+');
await click('Apply');
// Radix restores focus on a later frame; flush it before asserting.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
assert.deepEqual(calls[1], { redaction: saved });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.ok(document.activeElement === trigger, "focus returns to the dialog opener");
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
await click('Configure privacy');
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
+114
View File
@@ -0,0 +1,114 @@
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 { createEmptyPane } from "../../application/state/sftp/types";
import type { SftpFileEntry } from "../../types";
import { ContextMenuContent } from "../ui/context-menu";
import { TooltipProvider } from "../ui/tooltip";
import { installDomEnvironment } from "../test-support/renderReactDom";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { useSftpPaneTreeContextMenu } from "./useSftpPaneTreeContextMenu";
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; });
type MenuItem = React.ReactElement<{ children?: React.ReactNode; onClick: () => void }>;
function archiveAction(children: React.ReactNode) {
return React.Children.toArray(children).find((child): child is MenuItem =>
React.isValidElement<{ children?: React.ReactNode }>(child)
&& React.Children.toArray(child.props.children).includes("sftp.archive.action"),
);
}
const folder: SftpFileEntry = { name: "assets", type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "" };
const file: SftpFileEntry = { name: "config.json", type: "file", size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" };
test("list archive menu includes files and folders and respects the clicked selection", async (t) => {
const dom = installDomEnvironment();
const noop = () => {};
const captured: string[][] = [];
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key,
pane: {
...createEmptyPane("pane-archive"),
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/srv/site" },
selectedFiles: new Set([folder.name, file.name]), files: [folder, file],
},
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: null }, handleFileListScroll: noop,
shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [folder, file],
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,
onCreateArchive: (paths) => { captured.push(paths); }, rowHeight: 28, visibleRows: [],
};
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(render()); });
t.after(async () => {
await act(async () => { renderer.unmount(); });
dom.cleanup();
environment.IS_REACT_ACT_ENVIRONMENT = true;
});
const actions = () => renderer.root.findAllByType(ContextMenuContent)
.map((menu) => archiveAction(menu.props.children)).filter((item): item is MenuItem => !!item);
assert.equal(actions().length, 2, "both a folder and a file offer compression");
actions()[0].props.onClick();
assert.deepEqual(captured, [["/srv/site/assets", "/srv/site/config.json"]]);
props = { ...props, pane: { ...props.pane, selectedFiles: new Set([folder.name]) } };
await act(async () => { renderer.update(render()); });
actions()[1].props.onClick();
assert.deepEqual(captured[1], ["/srv/site/config.json"], "right-clicking an unselected item does not include the previous selection");
props = { ...props, isLocal: true, pane: { ...props.pane, connection: { ...props.pane.connection!, isLocal: true } } };
await act(async () => { renderer.update(render()); });
assert.equal(actions().length, 0, "remote archive commands are not exposed on a local pane");
});
test("tree archive menu passes full paths for nested multiselection and hides local and parent entries", async (t) => {
const captured: string[][] = [];
const selected = new Set(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/config.json"]);
const emptyRef = { current: undefined };
let props: Parameters<typeof useSftpPaneTreeContextMenu>[0] = {
contextTarget: { entry: folder, entryPath: "/srv/site/assets" },
pane: { connection: { id: "conn", isLocal: false } },
tRef: { current: (key: string) => key },
onOpenFileWithSystemDefaultRef: emptyRef, onOpenFileWithRef: emptyRef,
onEditFileRef: emptyRef, onDownloadFileRef: emptyRef,
onExtractArchiveRef: emptyRef, onEditPermissionsRef: emptyRef, onDuplicateEntriesRef: emptyRef,
onCreateArchiveRef: { current: (paths: string[]) => { captured.push(paths); } },
getActionPaths: (path: string) => selected.has(path) ? [...selected] : [path],
};
let menu: React.ReactNode;
function Probe() {
menu = useSftpPaneTreeContextMenu(props);
return null;
}
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
t.after(async () => { await act(async () => { renderer.unmount(); }); });
const action = () => archiveAction((menu as React.ReactElement<{ children: React.ReactNode }>).props.children);
assert.ok(action());
action()!.props.onClick();
assert.deepEqual(captured[0], [...selected]);
props = { ...props, contextTarget: { entry: file, entryPath: "/srv/unselected.json" } };
await act(async () => { renderer.update(React.createElement(Probe)); });
action()!.props.onClick();
assert.deepEqual(captured[1], ["/srv/unselected.json"]);
props = { ...props, pane: { connection: { id: "local", isLocal: true } } };
await act(async () => { renderer.update(React.createElement(Probe)); });
assert.equal(action(), undefined);
props = { ...props, pane: { connection: { id: "conn", isLocal: false } }, contextTarget: { entry: { ...folder, name: ".." }, entryPath: "/srv" } };
await act(async () => { renderer.update(React.createElement(Probe)); });
assert.equal(action(), undefined);
});
+4
View File
@@ -8,6 +8,7 @@
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import type { SftpConnectedHostEntry } from "../../domain/sftpConnectedHosts";
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../domain/sftpArchiveCreation";
import type { SftpConnectOptions } from "../../application/state/sftp/useSftpConnections";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
@@ -53,6 +54,8 @@ export interface SftpPaneCallbacks {
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
/** Create copies next to the selected entries (same pane, same folder). */
onDuplicateEntries: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
// File operations
@@ -63,6 +66,7 @@ export interface SftpPaneCallbacks {
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onCreateArchive?: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
// External file upload from <input type="file" multiple> picker (FileList).
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "../test-support/renderReactDom";
test("archive dialog focuses its name and requires explicit overwrite activation", async (t) => {
const env = installDomEnvironment();
const globals = {
MutationObserver: globalThis.MutationObserver,
NodeFilter: globalThis.NodeFilter,
HTMLInputElement: globalThis.HTMLInputElement,
DocumentFragment: globalThis.DocumentFragment,
};
for (const key of Object.keys(globals) as Array<keyof typeof globals>) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: env.window[key] });
}
const { SftpCreateArchiveDialog } = await import("./SftpCreateArchiveDialog");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 20));
for (const [key, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
env.cleanup();
});
let state: SftpArchiveDialogState = {
target: { paneId: "pane-1", connectionId: "conn-1" },
directory: "/srv", paths: ["site"], archiveName: "site.tar.gz", format: "tar.gz",
busy: false, error: null, confirmOverwrite: false,
};
const submissions: Array<boolean | undefined> = [];
let closes = 0;
const render = () => renderer.render(React.createElement(SftpCreateArchiveDialog, {
state,
t: (key) => key,
onClose: () => { closes += 1; },
onNameChange: () => {},
onFormatChange: () => {},
onSubmit: async (overwrite) => { submissions.push(overwrite); },
}));
await render();
await flushEffects();
const input = env.document.querySelector("input");
assert.ok(input);
assert.equal(env.document.activeElement, input);
assert.equal(input.value, "site.tar.gz");
assert.ok(env.document.querySelector("label[for='" + input.id + "']"));
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
assert.deepEqual(submissions, [], "confirming an IME candidate must not create an archive");
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
assert.deepEqual(submissions, [undefined]);
state = { ...state, confirmOverwrite: true };
await render();
assert.match(env.document.querySelector('[role="alert"]')?.textContent ?? "", /sftp.archive.overwriteDescription/);
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
assert.deepEqual(submissions, [undefined], "Enter in the name field does not replace an existing archive");
const overwrite = [...env.document.querySelectorAll("button")].find((button) => button.textContent === "sftp.archive.overwrite");
assert.ok(overwrite);
await dispatchDomEvent(overwrite, new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(submissions, [undefined, true]);
state = { ...state, busy: true };
await render();
assert.equal(input.disabled, true);
assert.ok([...env.document.querySelectorAll("button")].filter((button) => button.textContent === "common.cancel" || button.textContent === "sftp.archive.creating").every((button) => button.disabled));
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
assert.equal(closes, 0);
});
+111
View File
@@ -0,0 +1,111 @@
import React, { useId, useRef } from "react";
import { Loader2 } from "lucide-react";
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
import type { SftpArchiveFormat } from "../../domain/sftpArchiveCreation";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
interface SftpCreateArchiveDialogProps {
state: SftpArchiveDialogState | null;
t: (key: string, params?: Record<string, unknown>) => string;
onClose: () => void;
onNameChange: (name: string) => void;
onFormatChange: (format: SftpArchiveFormat) => void;
onSubmit: (overwrite?: boolean) => Promise<void>;
}
export function SftpCreateArchiveDialog({
state, t, onClose, onNameChange, onFormatChange, onSubmit,
}: SftpCreateArchiveDialogProps) {
const id = useId();
const nameRef = useRef<HTMLInputElement>(null);
return (
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent
className="sm:max-w-md"
hideCloseButton={state?.busy}
aria-describedby={`${id}-description`}
onOpenAutoFocus={(event) => {
event.preventDefault();
nameRef.current?.focus();
nameRef.current?.select();
}}
onEscapeKeyDown={(event) => { if (state?.busy) event.preventDefault(); }}
onInteractOutside={(event) => { if (state?.busy) event.preventDefault(); }}
>
<DialogHeader>
<DialogTitle>{t("sftp.archive.title")}</DialogTitle>
<DialogDescription>
<span id={`${id}-description`}>{t("sftp.archive.description", { count: state?.paths.length ?? 0 })}</span>
</DialogDescription>
</DialogHeader>
{state && (
<>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("sftp.archive.name")}</Label>
<Input
ref={nameRef}
id={`${id}-name`}
value={state.archiveName}
disabled={state.busy}
aria-invalid={!!state.error}
aria-describedby={state.error ? `${id}-error` : undefined}
onChange={(event) => onNameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing && !state.confirmOverwrite) {
event.preventDefault();
void onSubmit();
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-format`}>{t("sftp.archive.format")}</Label>
<Select
value={state.format}
disabled={state.busy}
onValueChange={(value) => onFormatChange(value as SftpArchiveFormat)}
>
<SelectTrigger id={`${id}-format`}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tar.gz">TAR.GZ</SelectItem>
<SelectItem value="zip">ZIP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>{t("sftp.archive.destination")}</div>
<div className="break-all font-mono">{state.directory}</div>
</div>
{state.error && <p id={`${id}-error`} role="alert" className="text-xs text-destructive">{state.error}</p>}
{state.confirmOverwrite && (
<div role="alert" className="space-y-1 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p className="font-medium">{t("sftp.archive.overwriteTitle")}</p>
<p className="break-words text-muted-foreground">
{t("sftp.archive.overwriteDescription", { name: state.archiveName })}
</p>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" disabled={state.busy} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant={state.confirmOverwrite ? "destructive" : "default"}
disabled={state.busy || !state.archiveName.trim() || state.paths.length === 0}
onClick={() => void onSubmit(state.confirmOverwrite)}
>
{state.busy && <Loader2 size={14} className="mr-2 animate-spin" />}
{t(state.busy ? "sftp.archive.creating" : state.confirmOverwrite ? "sftp.archive.overwrite" : "sftp.archive.create")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
+46 -18
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -61,6 +61,7 @@ interface SftpPaneFileListProps {
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void;
@@ -68,6 +69,7 @@ interface SftpPaneFileListProps {
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onExtractArchive?: (entry: SftpFileEntry) => void;
onCreateArchive?: (sourcePaths: string[]) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
@@ -151,6 +153,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onDuplicateEntries,
onMoveEntriesToPath,
onOpenFileWithSystemDefault,
onOpenFileWith,
@@ -158,6 +161,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onCreateArchive,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
@@ -288,6 +292,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
void onUploadExternalFileList(files, targetPath);
}, [onUploadExternalFileList]);
const getSelectedTransferSources = useCallback((entry: SftpFileEntry): SftpTransferSource[] => {
const currentSelected = selectedFilesRef.current;
const names = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
return names.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
}, [filesByName, pane.connection?.id, pane.connection?.currentPath]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
@@ -376,29 +397,32 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
{t("sftp.context.extract")}
</ContextMenuItem>
)}
{!isLocal && onCreateArchive && (
<ContextMenuItem onClick={() => {
const selected = selectedFilesRef.current;
const names = selected.has(entry.name) ? Array.from(selected) : [entry.name];
onCreateArchive(names.filter((name) => name !== "..").map((name) =>
joinPath(pane.connection.currentPath, name),
));
}}>
<Archive size={14} className="mr-2" />{t("sftp.archive.action")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
onCopyToOtherPane(fileData);
}}
onClick={() => onCopyToOtherPane(getSelectedTransferSources(entry))}
>
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
{onDuplicateEntries && (
<ContextMenuItem
onClick={() => onDuplicateEntries(getSelectedTransferSources(entry))}
>
<CopyPlus size={14} className="mr-2" />{" "}
{t("sftp.context.duplicate")}
</ContextMenuItem>
)}
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
@@ -497,11 +521,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
handleRowSelect,
dragOverEntry,
isPaneFocused,
isLocal,
onCopyToOtherPane,
onDuplicateEntries,
getSelectedTransferSources,
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onCreateArchive,
onDragEnd,
onEditFile,
onEditPermissions,
+129 -5
View File
@@ -5,14 +5,19 @@ import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import { SftpPaneTreeView, getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import type { SftpFileEntry } from "../../types";
import { createEmptyPane } from "../../application/state/sftp/types";
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
import { installDomEnvironment, createDomRenderer } from "../test-support/renderReactDom";
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
name,
type,
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
});
test("tree activation enters directories and keeps file opening separate", () => {
@@ -21,7 +26,7 @@ test("tree activation enters directories and keeps file opening separate", () =>
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
test("tree double clicks open entries while the chevron only toggles expansion", 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));
@@ -38,7 +43,11 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
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"),
onOpenEntry: (opened, path) => {
assert.equal(opened, item);
assert.equal(path, `/root/${item.name}`);
calls.push("open");
}, onContextMenu: () => calls.push("menu"),
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
})));
calls.length = 0;
@@ -51,14 +60,18 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
try {
await render(entry("docs", "directory"));
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
assert.deepEqual(calls, ["select", "select", "toggle"]);
assert.deepEqual(calls, ["select", "select", "open"]);
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")]) {
for (const item of [
entry("..", "directory"), entry("notes.txt", "file"),
{ ...entry("linked-docs", "symlink"), linkTarget: "directory" as const },
{ ...entry("linked-file", "symlink"), linkTarget: "file" as const },
]) {
await render(item);
await doubleClick(container.querySelector('[data-section]')!);
assert.deepEqual(calls, ["select", "select", "open"]);
@@ -73,3 +86,114 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
});
}
});
test("tree view routes double clicks to navigation and preserves selection, expansion and Enter", async () => {
const dom = installDomEnvironment();
const previousObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class { observe() {} disconnect() {} },
});
dom.window.requestAnimationFrame = callback => dom.window.setTimeout(() => callback(0), 0);
dom.window.cancelAnimationFrame = id => dom.window.clearTimeout(id);
const renderer = await createDomRenderer(dom.document);
const noop = () => {};
const calls: string[] = [];
const paneId = "tree-activation";
const directoryLink: SftpFileEntry = { ...entry("linked-docs", "symlink"), linkTarget: "directory" };
const fileLink: SftpFileEntry = { ...entry("linked-file", "symlink"), linkTarget: "file" };
const files = [entry("docs", "directory"), entry("notes.txt", "file"), directoryLink, fileLink];
let props: React.ComponentProps<typeof SftpPaneTreeView> = {
pane: { ...createEmptyPane(paneId), files,
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/root" } },
side: "left", onPrepareSelection: noop,
onLoadChildren: async path => { calls.push(`load:${path}`); return [entry("nested", "directory")]; },
onMoveEntriesToPath: async () => {},
onNavigateUp: () => calls.push("up"),
onNavigateTo: path => calls.push(`navigate:${path}`),
onOpenEntry: (item, path) => { assert.equal(item, files.find(file => file.name === item.name)); calls.push(`open:${path}`); },
onRefresh: noop, onDragStart: noop, onDragEnd: noop, openRenameDialog: noop, openDeleteConfirm: noop,
onCopyToOtherPane: noop, onReceiveFromOtherPane: noop, draggedFiles: null,
openNewFolderDialog: noop, openNewFileDialog: noop,
sorting: { sortField: "name", sortOrder: "asc", directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
reloadRequest: { token: 0 },
};
const row = (name: string) => {
const element = renderer.container.querySelector(`[data-entry-name="${name}"]`);
assert.ok(element, `missing row ${name}`);
return element;
};
const click = async (target: Element, type = "click", detail = 1, ctrlKey = false) => {
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail, ctrlKey })); });
};
const doubleClick = async (target: Element) => {
await click(target);
await click(target, "click", 2);
await click(target, "dblclick", 2);
};
const enter = async () => {
await act(async () => { dom.document.activeElement!.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); });
};
try {
await renderer.render(React.createElement(SftpPaneTreeView, props));
await click(row("docs"));
assert.deepEqual(calls, []);
assert.equal(row("docs").getAttribute("data-selected"), "true");
await click(row("notes.txt"), "click", 1, true);
assert.equal(row("docs").getAttribute("data-selected"), "true");
assert.equal(row("notes.txt").getAttribute("data-selected"), "true");
await doubleClick(row("docs").querySelector("span.truncate")!);
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "false");
assert.equal(row("docs").getAttribute("data-selected"), "true");
assert.equal(row("notes.txt").getAttribute("data-selected"), "false");
await click(row("docs").querySelector(".lucide-chevron-right")!);
assert.deepEqual(calls.splice(0), ["load:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
row("nested");
await doubleClick(row("docs").querySelector(".lucide-chevron-right")!);
assert.deepEqual(calls, []);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
assert.equal(row("docs").getAttribute("data-selected"), "true");
await doubleClick(row("nested"));
assert.deepEqual(calls.splice(0), ["navigate:/root/docs/nested"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
await doubleClick(row("docs"));
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
await enter();
assert.equal(row("docs").getAttribute("data-expanded"), "false");
await enter();
assert.equal(row("docs").getAttribute("data-expanded"), "true");
assert.deepEqual(calls, []);
for (const [name, action] of [["notes.txt", "open:/root/notes.txt"], ["linked-docs", "navigate:/root/linked-docs"], ["linked-file", "open:/root/linked-file"], ["..", "up"]]) {
await doubleClick(row(name));
assert.deepEqual(calls.splice(0), [action]);
}
await click(row("notes.txt"));
await enter();
assert.deepEqual(calls.splice(0), ["open:/root/notes.txt"]);
// The application commits navigation by updating the pane root. Keep its
// existing reset behavior for cached children, expansion and selection.
props = { ...props, pane: { ...props.pane, files: [entry("inside", "file")],
connection: { ...props.pane.connection!, currentPath: "/root/docs" } } };
await renderer.render(React.createElement(SftpPaneTreeView, props));
row("inside");
assert.equal(renderer.container.querySelector('[data-entry-name="nested"]'), null);
assert.equal(sftpTreeSelectionStore.getPaneState(paneId).selectedPaths.size, 0);
} finally {
await renderer.unmount();
sftpTreeSelectionStore.clearPane(paneId);
if (previousObserver) Object.defineProperty(globalThis, "ResizeObserver", previousObserver);
else Reflect.deleteProperty(globalThis, "ResizeObserver");
dom.cleanup();
}
});
+8 -7
View File
@@ -67,15 +67,16 @@ export const TreeNode = React.memo<TreeNodeProps>(({
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => {
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
if (isDir) void onToggleExpand(entry, entryPath);
else onOpenEntry(entry, entryPath);
}}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onContextMenu={e => {
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
if (isParentEntry) {
// The shared menu still holds the previous real entry. Do not let
// parent navigation reopen actions for that stale target.
e.preventDefault();
e.stopPropagation();
return;
}
onContextMenu(entry, entryPath, e);
}}
draggable={!isParentEntry}
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
+8
View File
@@ -50,12 +50,14 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
openRenameDialog,
openDeleteConfirm,
onCopyToOtherPane,
onDuplicateEntries,
onReceiveFromOtherPane,
onOpenFileWithSystemDefault,
onOpenFileWith,
onEditFile,
onDownloadFile,
onExtractArchive,
onCreateArchive,
onEditPermissions,
draggedFiles,
openNewFolderDialog,
@@ -203,6 +205,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onDragEndRef.current = onDragEnd;
const onCopyToOtherPaneRef = useRef(onCopyToOtherPane);
onCopyToOtherPaneRef.current = onCopyToOtherPane;
const onDuplicateEntriesRef = useRef(onDuplicateEntries);
onDuplicateEntriesRef.current = onDuplicateEntries;
const onReceiveFromOtherPaneRef = useRef(onReceiveFromOtherPane);
onReceiveFromOtherPaneRef.current = onReceiveFromOtherPane;
const onOpenFileWithSystemDefaultRef = useRef(onOpenFileWithSystemDefault);
@@ -215,6 +219,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onDownloadFileRef.current = onDownloadFile;
const onExtractArchiveRef = useRef(onExtractArchive);
onExtractArchiveRef.current = onExtractArchive;
const onCreateArchiveRef = useRef(onCreateArchive);
onCreateArchiveRef.current = onCreateArchive;
const onEditPermissionsRef = useRef(onEditPermissions);
onEditPermissionsRef.current = onEditPermissions;
const openRenameDialogRef = useRef(openRenameDialog);
@@ -938,12 +944,14 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
setShowMoveToDialog,
tRef,
onCopyToOtherPaneRef,
onDuplicateEntriesRef,
onNavigateToRef,
onOpenFileWithSystemDefaultRef,
onOpenFileWithRef,
onEditFileRef,
onDownloadFileRef,
onExtractArchiveRef,
onCreateArchiveRef,
onEditPermissionsRef,
openDeleteConfirmRef,
openRenameDialogRef,
@@ -18,12 +18,14 @@ export interface SftpPaneTreeViewProps {
openRenameDialog: (entryPath: string) => void;
openDeleteConfirm: (targets: string[]) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onCreateArchive?: (sourcePaths: string[]) => void;
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
openNewFolderDialog: (targetPath: string) => void;
+25
View File
@@ -5,6 +5,8 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
import { SftpCreateArchiveDialog } from "./SftpCreateArchiveDialog";
import { useSftpArchiveDialog } from "../../application/state/sftp/useSftpArchiveDialog";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
@@ -153,6 +155,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
}, [requestTreeReload]);
const archiveDialog = useSftpArchiveDialog({
paneId: pane.id,
connectionId: pane.connection?.id,
isLocal: !!pane.connection?.isLocal,
isActive: !!isActive,
t,
onCreateArchive: callbacks.onCreateArchive,
onCreated: (directory) => requestNestedTreeReload([directory]),
});
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
@@ -636,12 +648,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onDuplicateEntries={callbacks.onDuplicateEntries}
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
@@ -687,6 +701,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onDuplicateEntries={callbacks.onDuplicateEntries}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
@@ -694,6 +709,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
@@ -706,6 +722,15 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
/>
</div>
<SftpCreateArchiveDialog
state={archiveDialog.state}
t={t}
onClose={archiveDialog.close}
onNameChange={archiveDialog.setArchiveName}
onFormatChange={archiveDialog.setFormat}
onSubmit={archiveDialog.submit}
/>
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
@@ -0,0 +1,53 @@
/** @test issue #4132 — the tree's ".." row must not be offered as a Duplicate target */
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { installDomEnvironment, createDomRenderer } from "../test-support/renderReactDom";
import { isSftpTreeParentEntry } from "./useSftpPaneTreeContextMenu.tsx";
test("only the synthetic parent row is treated as a navigation entry", () => {
assert.equal(isSftpTreeParentEntry({ name: ".." }), true);
assert.equal(isSftpTreeParentEntry({ name: "." }), false);
assert.equal(isSftpTreeParentEntry({ name: "docs" }), false);
assert.equal(isSftpTreeParentEntry({ name: "..hidden" }), false);
});
test("parent navigation cannot reopen the previous entry's shared context menu", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const openedTargets: string[] = [];
let contextTarget = "";
const noop = () => {};
const renderRow = async (name: string) => {
const props: React.ComponentProps<typeof TreeNode> = {
entry: { name, type: "directory", size: 0, sizeFormatted: "--", lastModified: 0, lastModifiedFormatted: "--" },
entryPath: `/root/${name}`, depth: 0, columnTemplate: "1fr",
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
isSelected: false, isExpanded: false, isLoading: false, isDragOver: false,
onToggleExpand: noop, onNodeClick: noop, onOpenEntry: noop,
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
onContextMenu: item => { contextTarget = item.name; },
};
await renderer.render(React.createElement("div", {
onContextMenu: () => { openedTargets.push(contextTarget); },
}, React.createElement(TreeNode, props)));
const event = new dom.window.MouseEvent("contextmenu", { bubbles: true, cancelable: true });
await act(async () => {
renderer.container.querySelector('[data-entry-name]')!.dispatchEvent(event);
});
return event;
};
try {
await renderRow("photos");
assert.deepEqual(openedTargets, ["photos"]);
const parentEvent = await renderRow("..");
assert.deepEqual(openedTargets, ["photos"], "parent must not reopen the stale photos menu");
assert.equal(parentEvent.defaultPrevented, true);
await renderRow("docs");
assert.deepEqual(openedTargets, ["photos", "docs"], "ordinary entry menus still work");
} finally {
await renderer.unmount();
dom.cleanup();
}
});
@@ -19,6 +19,8 @@ import { toEditorTabId, activeTabStore } from "../../../application/state/active
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
import { createSftpArchiveForPane } from "../../../application/state/sftp/createSftpArchive";
import type { SftpArchiveCreationOptions, SftpArchiveCreationTarget } from "../../../domain/sftpArchiveCreation";
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
@@ -659,6 +661,33 @@ export const useSftpViewFileOps = ({
[handleExtractArchiveForSide],
);
const handleCreateArchiveForSide = useCallback(
async (side: "left" | "right", options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => {
const result = await createSftpArchiveForPane({
getPane: () => side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane,
target,
options,
bridge: netcattyBridge.get(),
getSftpIdForConnection,
refresh: (tabId) => sftpRef.current.refresh(side, { tabId }),
t,
});
if (result.success) {
toast.success(t("sftp.archive.success", { fileName: options.archiveName }), "SFTP");
}
return result;
},
[getSftpIdForConnection, sftpRef, t],
);
const onCreateArchiveLeft = useCallback(
(options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => handleCreateArchiveForSide("left", options, target),
[handleCreateArchiveForSide],
);
const onCreateArchiveRight = useCallback(
(options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => handleCreateArchiveForSide("right", options, target),
[handleCreateArchiveForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
@@ -875,6 +904,8 @@ export const useSftpViewFileOps = ({
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onCreateArchiveLeft,
onCreateArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
@@ -4,6 +4,7 @@ import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../t
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../../domain/sftpArchiveCreation";
export interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -87,6 +88,8 @@ export interface UseSftpViewFileOpsResult {
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onCreateArchiveLeft: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
onCreateArchiveRight: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
@@ -69,6 +69,8 @@ interface UseSftpViewPaneActionsResult {
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
onDuplicateEntriesLeft: (files: SftpTransferSource[]) => void;
onDuplicateEntriesRight: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
}
@@ -145,6 +147,43 @@ export const useSftpViewPaneActions = ({
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const startDuplicateTransfer = useCallback(
(files: SftpTransferSource[], side: "left" | "right") => {
const groups = new Map<string, SftpTransferSource[]>();
for (const file of files) {
// ".." is the tree's parent-navigation row, not a real entry.
if (!file.sourcePath || file.name === "..") continue;
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath}`;
const group = groups.get(key) ?? [];
group.push(file);
groups.set(key, group);
}
for (const group of groups.values()) {
const [{ sourceConnectionId, sourcePath }] = group;
if (!sourcePath) continue;
// Same pane, same folder: every task conflicts with its source name
// and the duplicate option resolves it to "<name> (copy)".
void sftpRef.current.startTransfer(group, side, side, {
sourceConnectionId,
sourcePath,
targetPath: sourcePath,
duplicate: true,
});
}
},
[sftpRef],
);
const onDuplicateEntriesLeft = useCallback(
(files: SftpTransferSource[]) => startDuplicateTransfer(files, "left"),
[startDuplicateTransfer],
);
const onDuplicateEntriesRight = useCallback(
(files: SftpTransferSource[]) => startDuplicateTransfer(files, "right"),
[startDuplicateTransfer],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
@@ -400,6 +439,8 @@ export const useSftpViewPaneActions = ({
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onDuplicateEntriesLeft,
onDuplicateEntriesRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
@@ -151,6 +151,7 @@ export const useSftpViewPaneCallbacks = ({
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onDuplicateEntries: paneActions.onDuplicateEntriesLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
@@ -160,6 +161,7 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onCreateArchive: fileOps.onCreateArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
@@ -194,6 +196,7 @@ export const useSftpViewPaneCallbacks = ({
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onDuplicateEntries: paneActions.onDuplicateEntriesRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
@@ -203,6 +206,7 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onCreateArchive: fileOps.onCreateArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
+37 -3
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
import { getParentPath } from '../../application/state/sftp/utils';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
@@ -10,13 +10,16 @@ import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUp
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SftpPaneTreeContextMenuProps = Record<string, any>;
/** ".." is the tree's synthetic parent-navigation row, never a real entry. */
export const isSftpTreeParentEntry = (entry: { name: string }): boolean => entry.name === '..';
export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps) {
const {
contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources,
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onDuplicateEntriesRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onCreateArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
openNewFolderDialogRef, openNewFileDialogRef,
} = props;
@@ -27,6 +30,10 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
const { entry, entryPath } = target;
const isDir = isNavigableDirectory(entry);
const isLocal = pane.connection?.isLocal;
// The synthetic ".." row navigates to the parent directory; it is not a
// real entry, so duplicating it would target an ancestor (e.g. "/") and
// could recurse into its own output.
const isParentEntry = isSftpTreeParentEntry(entry);
const handleOpen = () => {
if (isDir) void toggleExpand(entry, entryPath);
@@ -47,6 +54,21 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
onCopyToOtherPaneRef.current(files);
};
const handleDuplicate = () => {
if (!onDuplicateEntriesRef.current || isParentEntry) return;
const paths = getActionPaths(entryPath);
const files = toTransferSources(paths);
if (files.length === 0) {
files.push({
name: entry.name,
isDirectory: isDir,
sourceConnectionId: pane.connection?.id,
sourcePath: getParentPath(entryPath),
});
}
onDuplicateEntriesRef.current(files);
};
const handleDelete = () => {
openDeleteConfirmRef.current(getActionPaths(entryPath));
};
@@ -88,10 +110,20 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
</ContextMenuItem>
)}
{!isLocal && entry.name !== '..' && onCreateArchiveRef?.current && (
<ContextMenuItem onClick={() => onCreateArchiveRef.current?.(getActionPaths(entryPath))}>
<Archive size={14} className="mr-2" />{tRef.current('sftp.archive.action')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyToOtherPane}>
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
</ContextMenuItem>
{onDuplicateEntriesRef.current && !isParentEntry && (
<ContextMenuItem onClick={handleDuplicate}>
<CopyPlus size={14} className="mr-2" />{tRef.current('sftp.context.duplicate')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => navigator.clipboard.writeText(entryPath)}>
<ClipboardCopy size={14} className="mr-2" />{tRef.current('sftp.context.copyPath')}
</ContextMenuItem>
@@ -180,8 +212,10 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
folderUploadEnabled,
onUploadExternalFolder,
onCopyToOtherPaneRef,
onDuplicateEntriesRef,
onDownloadFileRef,
onExtractArchiveRef,
onCreateArchiveRef,
onEditFileRef,
onEditPermissionsRef,
onNavigateToRef,
@@ -0,0 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("overview CPU cores list renders every reported core and can scroll", () => {
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /cpuPerCore\.slice\(/);
assert.match(
source,
/className="grid grid-cols-2 gap-x-3 gap-y-1\.5 max-h-\[\d+px\] overflow-y-auto pr-1 overscroll-contain"/,
);
});
@@ -358,8 +358,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
</span>
</div>
{stats.cpuPerCore.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain">
{stats.cpuPerCore.map((core, index) => (
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
))}
</div>
@@ -142,6 +142,31 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
const hoverClearTimerRef = useRef<number | null>(null);
const cancelHoverClear = () => {
if (hoverClearTimerRef.current === null) return;
window.clearTimeout(hoverClearTimerRef.current);
hoverClearTimerRef.current = null;
};
// Rows and the detail panel are separate boxes with a small gap. Keep the
// hovered row alive briefly so moving the pointer into the detail panel does
// not unmount the panel before it can be read, selected, or scrolled.
const scheduleHoverClear = () => {
cancelHoverClear();
hoverClearTimerRef.current = window.setTimeout(() => {
hoverClearTimerRef.current = null;
setHoveredIndex(-1);
}, 400);
};
useEffect(() => () => {
if (hoverClearTimerRef.current !== null) {
window.clearTimeout(hoverClearTimerRef.current);
hoverClearTimerRef.current = null;
}
}, []);
useEffect(() => {
if (selectedRef.current && listRef.current) {
@@ -154,6 +179,10 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
// Reset hover when suggestions change
useEffect(() => {
if (hoverClearTimerRef.current !== null) {
window.clearTimeout(hoverClearTimerRef.current);
hoverClearTimerRef.current = null;
}
setHoveredIndex(-1);
}, [suggestions]);
@@ -368,8 +397,40 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
fontFamily: "inherit",
fontSize: "13px",
color: textColor,
overscrollBehavior: "contain" as const,
};
// Keep terminal focus when clicking a panel's padding, but leave the native
// scrollbar alone so its thumb can still be dragged. `preventDefault()` on
// the scrollbar mousedown made the right-side detail panel impossible to
// scroll by dragging.
const handlePanelMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
const element = event.currentTarget;
const rect = element.getBoundingClientRect();
const scrollbarWidth = Math.max(16, element.offsetWidth - element.clientWidth);
const overVerticalScrollbar = event.clientX >= rect.right - scrollbarWidth;
if (!overVerticalScrollbar) event.preventDefault();
};
const handlePanelWheel = (event: React.WheelEvent<HTMLDivElement>) => {
// Keep wheel scrolling inside the popup instead of letting it reach the
// terminal's history/font-size handlers.
event.stopPropagation();
};
// Keep every inline description starting at the same x. The option column is
// sized from the longest suggestion in this popup, capped so descriptions
// still get useful width in a narrow terminal pane.
const longestCommandLength = suggestions.reduce(
(max, suggestion) => Math.max(max, suggestion.displayText.length),
0,
);
const commandColumnWidth = Math.max(
52,
Math.min(150, longestCommandLength * 7 + 12, availableWidth * 0.42),
);
return (
<div
ref={wrapperRef}
@@ -407,16 +468,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the list's padding or
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
// scrollbar; scrollbar dragging is left to the browser.
onMouseDown={handlePanelMouseDown}
onWheel={handlePanelWheel}
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const sourceInfo = SOURCE_LABELS[suggestion.source];
const hasInlineDescription = suggestion.source !== "snippet"
&& Boolean(suggestion.description);
return (
<div
@@ -434,8 +495,11 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseEnter={() => {
cancelHoverClear();
setHoveredIndex(index);
}}
onMouseLeave={scheduleHoverClear}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -468,10 +532,15 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
</span>
)}
{/* Command text */}
{/* Command text. A shared fixed column keeps every description
aligned on the same left edge instead of following each
variable-width option label. */}
<span
title={hasInlineDescription ? suggestion.displayText : undefined}
style={{
flex: 1,
flex: hasInlineDescription ? "0 0 auto" : "1 1 auto",
width: hasInlineDescription ? `${commandColumnWidth}px` : undefined,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
@@ -484,16 +553,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
{/* Inline description (truncated). Snippets show only their label
in the row — the full command lives in the detail preview. */}
{suggestion.source !== "snippet" && suggestion.description && (
{hasInlineDescription && (
<span
style={{
flex: "1 1 auto",
minWidth: 0,
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
@@ -549,11 +618,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the panel's padding or
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
// scrollbar; scrollbar dragging is left to the browser.
onMouseDown={handlePanelMouseDown}
onWheel={handlePanelWheel}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
@@ -615,15 +682,23 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
overflowY: "auto",
alignSelf: renderUpward ? "flex-end" : "flex-start",
visibility: detailVisible ? "visible" : "hidden",
// The detail text can be selected and copied; use text cursor and
// native selection instead of forcing terminal focus.
userSelect: "text",
cursor: "text",
// visibility:hidden already skips hit testing, but keep the
// hidden reserve explicitly non-interactive so the mounted
// panel can never swallow clicks meant for the terminal.
pointerEvents: detailVisible ? "auto" : "none",
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onMouseDown={(event) => {
// Do not preventDefault: that would block selecting the long
// description or dragging the panel's scrollbar.
event.stopPropagation();
}}
onMouseEnter={cancelHoverClear}
onMouseLeave={scheduleHoverClear}
onWheel={handlePanelWheel}
>
{detailVisible && detailItem && (
<>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -154,6 +154,7 @@ export const COMMON_FIG_SPECS = [
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
"ln", "stat", "wc", "cut", "uniq", "tee", "xargs",
] as const;
/**
@@ -0,0 +1,155 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "../..");
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
const DICT_DIR = path.join(HERE, "autocomplete");
const COMMANDS = ["zip", "unzip", "make", "nano"] as const;
const specs = Object.fromEntries(
await Promise.all(
COMMANDS.map(async (name) => {
const mod = await import(pathToFileURL(path.join(SPEC_DIR, `${name}.js`)).href);
return [name, mod.default?.default ?? mod.default] as const;
}),
),
) as Record<(typeof COMMANDS)[number], FigSpec>;
const simplified = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
) as Record<string, string>;
const traditional = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
) as Record<string, string>;
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => [...COMMANDS],
loadFigSpec: async (commandName: string) =>
specs[commandName as (typeof COMMANDS)[number]] ?? null,
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
const { localizeSpecSuggestionDescriptions } = await import(
"./autocomplete/autocompleteDescriptionI18n.ts"
);
function collectDescriptions(node: unknown, out = new Set<string>(), seen = new WeakSet<object>()): Set<string> {
if (!node || typeof node !== "object" || seen.has(node)) return out;
seen.add(node);
const record = node as { description?: unknown };
if (typeof record.description === "string" && record.description.trim()) {
out.add(record.description);
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) collectDescriptions(item, out, seen);
} else if (value && typeof value === "object") {
collectDescriptions(value, out, seen);
}
}
return out;
}
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
function suggestionNamed(
suggestions: Awaited<ReturnType<typeof getCompletions>>,
displayText: string,
) {
return suggestions.find((suggestion) => suggestion.displayText === displayText);
}
test("batch specs have complete zh-CN and zh-TW description coverage", async () => {
assert.ok(batchDescriptions.length >= 32);
for (const description of batchDescriptions) {
assert.equal(typeof simplified[description], "string", `missing zh-CN: ${description}`);
assert.ok(simplified[description].length > 0, `empty zh-CN: ${description}`);
assert.equal(typeof traditional[description], "string", `missing zh-TW: ${description}`);
assert.ok(traditional[description].length > 0, `empty zh-TW: ${description}`);
}
const localized = await localizeSpecSuggestionDescriptions(
batchDescriptions.map((description) => ({ text: description, description })),
"zh-CN",
);
for (const [index, suggestion] of localized.entries()) {
assert.equal(suggestion.text, batchDescriptions[index]);
assert.notEqual(suggestion.description, batchDescriptions[index]);
}
});
test("batch zh-TW spot checks cover corrected semantics", () => {
assert.equal(
traditional["Archive a directory and its contents with the highest level [9] of compression"],
"使用最高壓縮級別 9",
);
assert.equal(
traditional["Extract a zip file created in windows, containing files with non-ascii (chinese) filenames"],
"上游選項疑似錯誤,請核對當前 unzip 版本支援的引數",
);
assert.equal(
traditional["Turn on value of next option"],
"啟用指定的 NeXT 相容選項(Apple 擴充套件)",
);
});
test("zip, unzip, and make options localize without changing insertion text", async () => {
const cases = [
{ input: "zip -", option: "-r", zhCn: "递归打包并压缩目录及其内容" },
{ input: "zip -", option: "-9", zhCn: "使用最高压缩级别 9" },
{ input: "unzip -", option: "-l", zhCn: "列出 zip 文件内容而不解压" },
{
input: "unzip -",
option: "-0",
zhCn: "上游选项疑似错误,请核对当前 unzip 版本支持的参数",
},
{ input: "make -", option: "-N", zhCn: "启用指定的 NeXT 兼容选项(Apple 扩展)" },
{ input: "make --", option: "--warn-undefined-variables", zhCn: "引用未定义变量时发出警告" },
];
for (const { input, option, zhCn } of cases) {
const suggestions = await getCompletions(input, {
locale: "zh-CN",
skipPathCompletion: true,
maxResults: 50,
});
const suggestion = suggestionNamed(suggestions, option);
assert.equal(suggestion?.text, `${input.split(" ")[0]} ${option}`);
assert.equal(suggestion?.description, zhCn);
}
});
test("batch command descriptions localize and English remains the fallback", async () => {
const nano = specs.nano.description;
assert.ok(nano);
const [zhCn] = await localizeSpecSuggestionDescriptions([{ description: nano }], "zh-CN");
const [zhTw] = await localizeSpecSuggestionDescriptions([{ description: nano }], "zh-TW");
const [en] = await localizeSpecSuggestionDescriptions([{ description: nano }], "en");
assert.equal(zhCn.description, "Nano 的 ANOther 编辑器,一个增强的免费 Pico 克隆");
assert.equal(zhTw.description, "Nano 的 ANOther 編輯器,一個增強的免費 Pico 克隆");
assert.equal(en.description, nano);
assert.equal(specs.nano.description, nano);
});
@@ -0,0 +1,265 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "../..");
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
const DICT_DIR = path.join(HERE, "autocomplete");
const COMMANDS = ["python", "python3", "node", "vim", "sort", "awk", "sed"] as const;
const DEFERRED_NODE_KEY =
"Follows symlinks to directories when examining source code and templates for translation strings";
const specs = Object.fromEntries(
await Promise.all(
COMMANDS.map(async (name) => {
const localSpec = path.join(ROOT, "electron/specs", `${name}.js`);
const specFile = fs.existsSync(localSpec) ? localSpec : path.join(SPEC_DIR, `${name}.js`);
const mod = await import(pathToFileURL(specFile).href);
return [name, mod.default?.default ?? mod.default] as const;
}),
),
) as Record<(typeof COMMANDS)[number], FigSpec>;
const simplified = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
) as Record<string, string>;
const traditional = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
) as Record<string, string>;
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => [...COMMANDS],
loadFigSpec: async (commandName: string) =>
(specs as Record<string, FigSpec>)[commandName] ?? null,
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
function collectDescriptions(
node: unknown,
out = new Set<string>(),
seen = new WeakSet<object>(),
): Set<string> {
if (!node || typeof node !== "object" || seen.has(node)) return out;
seen.add(node);
const record = node as { description?: unknown };
if (typeof record.description === "string" && record.description.trim()) {
out.add(record.description);
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) collectDescriptions(item, out, seen);
} else if (value && typeof value === "object") {
collectDescriptions(value, out, seen);
}
}
return out;
}
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
function suggestionNamed(
suggestions: Awaited<ReturnType<typeof getCompletions>>,
displayText: string,
) {
return suggestions.find((suggestion) => suggestion.displayText === displayText);
}
test("batch specs cover every description except the documented node shared-key conflict", () => {
const missing = batchDescriptions.filter(
(description) => !(typeof simplified[description] === "string" && simplified[description].length > 0),
);
assert.deepEqual(missing, [DEFERRED_NODE_KEY]);
assert.equal(typeof simplified[DEFERRED_NODE_KEY], "undefined");
assert.equal(typeof traditional[DEFERRED_NODE_KEY], "undefined");
assert.ok(batchDescriptions.length >= 178);
});
test("batch zh-TW spot checks cover generated terminology", () => {
assert.equal(
traditional["Don’t display the copyright and version messages even in interactive mode"],
"上游選項疑似錯誤:互動模式下不顯示版權和版本資訊請用 -q",
);
assert.equal(traditional["Watch input files"], "監視輸入檔案");
assert.equal(
traditional["'nocompatible', Vim defaults, no plugins, no viminfo"],
"'nocompatible'、Vim 預設值、不載入外掛、不使用 viminfo",
);
assert.equal(
traditional["Set the maximum number of execution threads. Default number equals to the number of CPUs"],
"設定並行排序的執行緒數",
);
assert.equal(
traditional["Prints the pattern space, then, regardless, replaces the pattern space with the next line of input. If there is no more input then sed exits without processing any more commands"],
"未停用自動列印時,先列印模式空間;隨後用下一行輸入替換模式空間。若無更多輸入則退出。",
);
assert.equal(
traditional["Run in POSIX compatibility mode (gawk)"],
"以傳統相容模式執行,行為等同 Brian Kernighan 的 awk(gawk)",
);
assert.equal(
traditional["Interprets regular expressions as extended (modern) regular expressions rather than basic regular expressions"],
"將正規表示式解釋為擴充套件(現代)正規表示式,而不是基本正規表示式",
);
});
test("python, node, vim, sort, awk, and sed options localize without changing insertion text", async () => {
const cases = [
{
input: "python -",
option: "-B",
key: "If given, Python won’t try to write .pyc files on the import of source modules",
zhCn: "指定后,Python 在导入源模块时不会写入 .pyc 文件",
},
{
input: "python -",
option: "-I",
key: "Run Python in isolated mode. This also implies -E and -s. In isolated mode sys.path contains neither the script’s directory nor the user’s site-packages directory",
zhCn: "以隔离模式运行 Python,同时隐含 -E 和 -s。隔离模式下 sys.path 不包含脚本目录和用户 site-packages 目录",
},
{
input: "python -",
option: "-g",
key: "Don’t display the copyright and version messages even in interactive mode",
zhCn: "上游选项疑似错误:交互模式下不显示版权和版本信息请用 -q",
},
{
input: "python3 -",
option: "-m",
key: "Search sys.path for the named module and execute its contents as the __main__ module",
zhCn: "在 sys.path 中搜索指定模块,并将其内容作为 __main__ 模块执行",
},
{
input: "node -",
option: "--watch",
key: "Watch input files",
zhCn: "监视输入文件",
},
{
input: "node -",
option: "--env-file",
key: "Specify a file containing environment variables",
zhCn: "指定包含环境变量的文件",
},
{
input: "node -",
option: "--inspect",
key: "Activate inspector on host:port (default: 127.0.0.1:9229)",
zhCn: "在 host:port 上启用检查器(默认:127.0.0.1:9229)",
},
{
input: "node -",
option: "--preserve-symlinks",
key: DEFERRED_NODE_KEY,
zhCn: DEFERRED_NODE_KEY,
},
{
input: "vim -",
option: "-R",
key: "Readonly mode (like 'view')",
zhCn: "只读模式(类似 'view')",
},
{
input: "vim -",
option: "--clean",
key: "'nocompatible', Vim defaults, no plugins, no viminfo",
zhCn: "'nocompatible'、Vim 默认值、不加载插件、不使用 viminfo",
},
{
input: "sort -",
option: "--parallel",
key: "Set the maximum number of execution threads. Default number equals to the number of CPUs",
zhCn: "设置并行排序的线程数",
},
{
input: "sort -",
option: "--radixsort",
key: "Try to use radix sort, if the sort specifications allow",
zhCn: "若排序规范允许,尝试使用基数排序",
},
{
input: "awk -",
option: "-c",
key: "Run in POSIX compatibility mode (gawk)",
zhCn: "以传统兼容模式运行,行为等同 Brian Kernighan 的 awk(gawk)",
},
{
input: "awk -",
option: "-n",
key: "Disable automatic input record splitting (gawk)",
zhCn: "识别输入数据中的八进制和十六进制值,请谨慎使用(gawk)",
},
{
input: "awk -",
option: "-t",
key: "Enable type checking (gawk)",
zhCn: "对无法移植到原始 UNIX awk 的构造发出警告(gawk)",
},
{
input: "sed -",
option: "-E",
key: "Interprets regular expressions as extended (modern) regular expressions rather than basic regular expressions",
zhCn: "将正则表达式解释为扩展(现代)正则表达式,而不是基本正则表达式",
},
{
input: "sed -",
option: "-r",
key: "Same as `-E` for compatibility with GNU sed",
zhCn: "与 `-E` 相同,用于兼容 GNU sed",
},
{
input: "sed ",
option: "n",
key: "Prints the pattern space, then, regardless, replaces the pattern space with the next line of input. If there is no more input then sed exits without processing any more commands",
zhCn: "未禁用自动打印时,先打印模式空间;随后用下一行输入替换模式空间。若无更多输入则退出。",
},
];
for (const { input, option, key, zhCn } of cases) {
const command = input.split(" ")[0];
const localized = await getCompletions(input, {
locale: "zh-CN",
skipPathCompletion: true,
maxResults: 100,
});
const suggestion = suggestionNamed(localized, option);
assert.equal(suggestion?.text, `${command} ${option}`);
assert.equal(suggestion?.description, zhCn);
for (const locale of ["en", "ru", undefined]) {
const fallback = await getCompletions(input, {
locale,
skipPathCompletion: true,
maxResults: 100,
});
const fallbackSuggestion = suggestionNamed(fallback, option);
assert.equal(fallbackSuggestion?.text, `${command} ${option}`);
assert.equal(fallbackSuggestion?.description, key);
}
}
assert.equal(specs.python.description, "Run the python interpreter");
assert.equal(specs.sort.description, "Sort or merge records (lines) of text and binary files");
});
@@ -0,0 +1,207 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "../..");
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
const DICT_DIR = path.join(HERE, "autocomplete");
const COMMANDS = ["ln", "stat", "wc", "cut", "uniq", "tee", "xargs"] as const;
const specs = Object.fromEntries(
await Promise.all(
COMMANDS.map(async (name) => {
const mod = await import(pathToFileURL(path.join(SPEC_DIR, `${name}.js`)).href);
return [name, mod.default?.default ?? mod.default] as const;
}),
),
) as Record<(typeof COMMANDS)[number], FigSpec>;
const simplified = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
) as Record<string, string>;
const traditional = JSON.parse(
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
) as Record<string, string>;
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => [...COMMANDS],
loadFigSpec: async (commandName: string) =>
(specs as Record<string, FigSpec>)[commandName] ?? null,
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
function collectDescriptions(
node: unknown,
out = new Set<string>(),
seen = new WeakSet<object>(),
): Set<string> {
if (!node || typeof node !== "object" || seen.has(node)) return out;
seen.add(node);
const record = node as { description?: unknown };
if (typeof record.description === "string" && record.description.trim()) {
out.add(record.description);
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) collectDescriptions(item, out, seen);
} else if (value && typeof value === "object") {
collectDescriptions(value, out, seen);
}
}
return out;
}
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
function suggestionNamed(
suggestions: Awaited<ReturnType<typeof getCompletions>>,
displayText: string,
) {
return suggestions.find((suggestion) => suggestion.displayText === displayText);
}
test("batch 3 specs have complete zh-CN and zh-TW description coverage", () => {
const missing = batchDescriptions.filter(
(description) => !(typeof simplified[description] === "string" && simplified[description].length > 0),
);
const missingTraditional = batchDescriptions.filter(
(description) => !(typeof traditional[description] === "string" && traditional[description].length > 0),
);
assert.deepEqual(missing, []);
assert.deepEqual(missingTraditional, []);
assert.ok(batchDescriptions.length >= 70);
});
test("batch 3 spot checks correct verified semantics and upstream wording", () => {
assert.equal(
simplified["World, line, character, and byte count"],
"统计单词数、行数、字符数和字节数",
);
assert.equal(
traditional["World, line, character, and byte count"],
"統計單詞數、行數、字元數和位元組數",
);
assert.equal(
simplified["Terminal if the arguments will not fit in the maximum line length"],
"若参数超出最大行长度则终止",
);
assert.equal(
traditional["Terminal if the arguments will not fit in the maximum line length"],
"若引數超出最大行長度則終止",
);
assert.equal(
simplified["Report or omit repeated line"],
"报告或省略重复行",
);
assert.equal(
traditional["Report or omit repeated line"],
"報告或省略重複行",
);
assert.equal(
simplified["Use delim as the field delimiter character instead of the tab character"],
"使用 delim 作为字段分隔符,而不是制表符",
);
assert.equal(
traditional["Use delim as the field delimiter character instead of the tab character"],
"使用 delim 作為欄位分隔符,而不是製表符",
);
});
test("ln, stat, wc, cut, uniq, tee and xargs descriptions localize without changing insertion text", async () => {
const cases = [
{
input: "ln -",
option: "-s",
key: "Create a symbolic link",
zhCn: "创建符号链接",
},
{
input: "stat -",
option: "-f",
key: "Display information using the specified format; similar to printf(3) formats in that they start with %, are then followed by a sequence of formatting characters, and end in a character that selects the field of the struct stat which is to be formatted",
zhCn: "按指定格式显示信息;类似 printf(3):以 % 开头,后接格式化字符,并以选择 struct stat 待格式化字段的字符结尾",
},
{
input: "wc -",
option: "-l",
key: "Output the number of lines to the standard input",
zhCn: "输出行数",
},
{
input: "cut -",
option: "-d",
key: "Use delim as the field delimiter character instead of the tab character",
zhCn: "使用 delim 作为字段分隔符,而不是制表符",
},
{
input: "uniq -",
option: "-c",
key: "Prefix lines by the number of occurrences",
zhCn: "在每行前加上出现次数",
},
{
input: "tee -",
option: "-a",
key: "Append the output to the files rather than overwriting them",
zhCn: "将输出追加到文件而不是覆盖",
},
{
input: "xargs -",
option: "-0",
key: "Use NUL (0x00) as a separator, instead of whitespace",
zhCn: "使用 NUL(0x00)而非空白作为分隔符",
},
];
for (const { input, option, key, zhCn } of cases) {
const command = input.split(" ")[0];
const localized = await getCompletions(input, {
locale: "zh-CN",
skipPathCompletion: true,
maxResults: 100,
});
const suggestion = suggestionNamed(localized, option);
assert.equal(suggestion?.text, `${command} ${option}`);
assert.equal(suggestion?.description, zhCn);
for (const locale of ["en", "ru", undefined]) {
const fallback = await getCompletions(input, {
locale,
skipPathCompletion: true,
maxResults: 100,
});
const fallbackSuggestion = suggestionNamed(fallback, option);
assert.equal(fallbackSuggestion?.text, `${command} ${option}`);
assert.equal(fallbackSuggestion?.description, key);
}
}
const traditionalResults = await getCompletions("uniq -", {
locale: "zh-TW",
skipPathCompletion: true,
maxResults: 100,
});
assert.equal(suggestionNamed(traditionalResults, "-c")?.description, "在每行前加上出現次數");
});
@@ -6,6 +6,9 @@ test("preload common specs includes dnf alongside yum and apt", () => {
assert.ok(COMMON_FIG_SPECS.includes("apt"));
assert.ok(COMMON_FIG_SPECS.includes("yum"));
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
for (const command of ["ln", "stat", "wc", "cut", "uniq", "tee", "xargs"]) {
assert.ok(COMMON_FIG_SPECS.includes(command), `${command} should be preloaded`);
}
});
test("normalizeCommandName strips path and extension", () => {
+8 -1
View File
@@ -1037,7 +1037,14 @@ test("PTY builders chunk the exact shared script into lines below the canonical
}
});
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", {
timeout: 30000,
// macOS MAX_CANON is 1024 bytes while Linux is 4096. --noediting forces the
// kernel canonical path; real interactive shells use readline/zle and are not
// subject to this limit, so this Linux canonical-buffer regression test is
// not applicable on macOS.
skip: process.platform === "darwin" ? "macOS canonical input limit is 1024 bytes" : false,
}, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
const prompt = "__NETCATTY_TEST_PROMPT__> ";
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
+3 -1
View File
@@ -716,7 +716,9 @@ export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
));
}
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
// Read through stdin: GNU base64 accepts a file operand, but BSD/macOS
// base64 only accepts -i or stdin, which broke zsh setup on macOS.
const runner = 'c=$(base64 -d < "$1") && printf "%s\\n" "$c" | sh';
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import type { AIPanelContext } from '../../domain/buildAITerminalSessionInfo';
import type { Workspace } from '../../types';
import { installFakeIndexedDb } from '../../application/state/testSupport/fakeIndexedDb';
import {
__flushIdbWritesForTests, __resetIdbPersistenceForTests,
setLatestAISessionsSnapshot, setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot, setLatestAIPanelViewByScopeSnapshot,
} from '../../application/state/aiStateSnapshots';
import { getAISessionsSnapshot } from '../../application/state/aiSessionsStore';
// Keep the production provider, maintenance host, panels host and panel effects.
// Only terminal rendering and the chat presentation are replaced: capture the
// view actually selected by the mounted panel, including its New Chat action.
const contentUrl = `data:text/javascript,${encodeURIComponent(`
let props;
export function AIChatPanelContent(value) { props = value; return null; }
export function readPanel() { return props; }
`)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '../Terminal' && context.parentURL?.endsWith('/TerminalLayerSupport.tsx')) {
return { url: 'data:text/javascript,export default function Terminal(){return null}', shortCircuit: true };
}
if (specifier === './AIChatPanelContent' && context.parentURL?.endsWith('/AIChatSidePanel.tsx')) {
return { url: contentUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (url.endsWith('.css')) return { format: 'module', source: 'export default {};', shortCircuit: true };
return nextLoad(url, context);
},
});
test('mounted workspace chat survives last-member close without defeating detach or New Chat', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const globals = {
window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
CustomEvent: dom.window.CustomEvent, Event: dom.window.Event, navigator: dom.window.navigator,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
t.after(async () => {
await __flushIdbWritesForTests();
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const { I18nProvider } = await import('../../application/i18n/I18nProvider');
const { AIStateProvider, AIStateMaintenanceHost, AIChatPanelsHost } = await import('./TerminalLayerSupport');
await import('../AIChatSidePanel');
const { readPanel } = await import(contentUrl);
const scopeKey = 'workspace:workspace';
for (const scenario of ['close', 'close-inherited', 'close-resumed', 'detach', 'detach-resumed', 'out-of-scope'] as const) {
await t.test(scenario, async () => {
dom.window.localStorage.clear();
const idb = installFakeIndexedDb();
__resetIdbPersistenceForTests();
const chat: AISession = {
id: `chat-${scenario}`, title: 'Inherited conversation', agentId: 'catty',
scope: { type: 'terminal', targetId: scenario.endsWith('resumed') ? 'old-terminal' : 'terminal', hostIds: ['host'] },
messages: [{ id: 'message', role: 'user', content: 'Keep this question', timestamp: 1 }],
createdAt: 1, updatedAt: 2,
};
const activeMap = { [scopeKey]: chat.id, 'terminal:terminal': chat.id };
setLatestAISessionsSnapshot([chat]);
setLatestAIActiveSessionMapSnapshot(activeMap);
setLatestAIDraftsByScopeSnapshot({});
setLatestAIPanelViewByScopeSnapshot(scenario === 'close-inherited' ? {} : {
[scopeKey]: { mode: 'session', sessionId: chat.id },
});
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([chat]));
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify(activeMap));
let workspace: Workspace = {
id: 'workspace', title: 'Workspace', anchorChatId: chat.id, focusedSessionId: 'terminal',
root: { id: 'pane', type: 'pane', sessionId: 'terminal' },
};
let liveIds = new Set(['workspace', 'terminal']);
let context: AIPanelContext = {
scopeType: 'workspace', scopeTargetId: 'workspace', scopeLabel: 'Workspace', scopeHostIds: ['host'],
focusedSessionId: 'terminal', terminalSessions: [{ sessionId: 'terminal', hostId: 'host', status: 'connected' } as AIPanelContext['terminalSessions'][number]],
};
const container = dom.window.document.createElement('div');
dom.window.document.body.append(container);
const root = createRoot(container);
const render = () => React.createElement(I18nProvider, null,
React.createElement(AIStateProvider, null,
React.createElement(AIStateMaintenanceHost, { workspaces: [workspace], validAIScopeTargetIds: liveIds }),
React.createElement(AIChatPanelsHost, {
mountedTabIds: ['workspace'], activeTabId: 'workspace', activeSidePanelTab: 'ai',
liveScopeTargetIds: liveIds, contextsByTabId: new Map([['workspace', context]]),
resolveExecutorContext: () => ({}) as never, notes: [], hosts: [], snippets: [], workspaces: [workspace],
}),
),
);
try {
await act(async () => root.render(render()));
assert.equal(readPanel().activeSessionId, chat.id);
if (scenario !== 'close-inherited') assert.equal(getAISessionsSnapshot().panelViewByScope[scopeKey]?.mode, 'session');
if (scenario === 'out-of-scope') {
// No member loss/handoff: a panel displaying the wrong scope must
// still clear its explicit session instead of keeping global history.
context = { ...context, terminalSessions: [], focusedSessionId: undefined };
} else {
workspace = { ...workspace, focusedSessionId: undefined,
root: { id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [] } };
liveIds = new Set(scenario.startsWith('close') ? ['workspace'] : ['workspace', 'terminal']);
context = { ...context, terminalSessions: [], focusedSessionId: undefined };
}
await act(async () => root.render(render()));
const snapshot = getAISessionsSnapshot();
if (scenario.startsWith('close')) {
assert.equal(snapshot.sessions.find(session => session.id === chat.id)?.scope.type, 'workspace');
if (scenario !== 'close-inherited') assert.equal(snapshot.panelViewByScope[scopeKey]?.mode, 'session', 'passive normalization must not overwrite the handoff');
else assert.notEqual(snapshot.panelViewByScope[scopeKey]?.mode, 'draft');
assert.equal(snapshot.activeSessionIdMap[scopeKey], chat.id);
assert.equal(readPanel().activeSessionId, chat.id);
assert.equal(readPanel().messages[0].content, 'Keep this question');
// No provider is configured: the real send path records the follow-up
// and a local configuration error without contacting any model.
readPanel().setInputValue('Continue after closing the terminal');
await act(async () => readPanel().handleSend());
const afterSend = getAISessionsSnapshot();
assert.deepEqual(afterSend.sessions.map(session => session.id), [chat.id], 'follow-up must not create a new chat');
assert.ok(afterSend.sessions[0].messages.some(message => message.content === 'Continue after closing the terminal'));
await act(async () => readPanel().handleNewChat());
assert.equal(getAISessionsSnapshot().panelViewByScope[scopeKey]?.mode, 'draft');
assert.ok(getAISessionsSnapshot().activeSessionIdMap[scopeKey] == null);
} else {
assert.equal(snapshot.panelViewByScope[scopeKey]?.mode, 'draft');
assert.equal(snapshot.activeSessionIdMap[scopeKey], null);
assert.equal(snapshot.sessions.find(session => session.id === chat.id)?.scope.type, 'terminal');
if (scenario.startsWith('detach')) assert.equal(snapshot.activeSessionIdMap['terminal:terminal'], chat.id);
}
} finally {
await act(async () => root.unmount());
container.remove();
await __flushIdbWritesForTests();
idb.restore();
}
});
}
});
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more