Compare commits

..
Author SHA1 Message Date
bincxz 4ac65164a0 fix(terminal): expire archive links after another command 2026-10-15 15:28:01 +08:00
bincxz 8512814dd3 feat(terminal): prepare tar and zip commands from archive links 2026-10-15 15:18:04 +08:00
陈大猫 cc001c9a4b fix: keep OpenCode plugin errors from stopping resumed chats (#3677)
* fix: keep OpenCode plugin errors from stopping resumed chats

* fix: preserve each OpenCode background warning
2026-10-15 15:04:36 +08:00
陈大猫 b81b27f817 fix(ai): preserve terminal replies between OSC contexts (#3678) 2026-10-15 15:03:21 +08:00
陈大猫 3a2bf6498d feat(vault): move selected hosts to a group (#3675)
Add a Move to group action for the selected hosts, reusing existing group moves and saving the selection once. Omit the hidden synthetic General destination.

Related to #3194; marquee selection and dragging a selection are outside this change.

65 focused checks, lint, build and real grid/list mouse/keyboard/cancel/reload/single-host drag checks pass. Current-head remote review and both CI jobs pass.
2026-10-15 14:52:16 +08:00
陈大猫 53a4bf29b0 fix(terminal): retain absent local shell display snapshots (#3674)
Preserve the original local shell name and icon when a saved host is edited and a terminal is copied.

Related to #3615 and #3616.

Validated with 70 focused checks, lint, production build and actual terminal copy flows. Current-head remote review and CI pass.
2026-10-15 14:44:11 +08:00
23 changed files with 525 additions and 48 deletions

No files matched your search

+1
View File
@@ -129,6 +129,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
- **Archive shortcuts** — Ctrl+click a printed tar/zip filename at an empty Linux shell prompt to prepare an extraction command; press Enter to run it. Relative filenames are available only in the latest command's output; run `ls` again after submitting another command. Existing input is kept, and shortcuts stay off for unknown systems and broadcast mode. Names with spaces must be printed in quotes; wrapped filenames are excluded.
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
+3
View File
@@ -1149,6 +1149,9 @@ Highlight the focused split pane:
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveSelected': 'Move to group ({count})',
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
+3
View File
@@ -1107,6 +1107,9 @@ Resalta el panel dividido enfocado:
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
'vault.hosts.connectSelected': 'Conectar ({count})',
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts',
+3
View File
@@ -128,6 +128,9 @@ export const ruVaultMessages: Messages = {
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
+3
View File
@@ -766,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
+3
View File
@@ -768,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
+14 -1
View File
@@ -4,7 +4,7 @@ import test from "node:test";
import type { Host } from "../../domain/models";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -171,3 +171,16 @@ test("host session factories snapshot plugin connection configuration", () => {
assert.deepEqual(workspace.pluginConnection, pluginConnection);
assert.notEqual(workspace.pluginConnection, pluginConnection);
});
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
const source = createHostTerminalSession("original-bash", original);
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
assert.equal(copied.localShell, "/bin/bash");
assert.equal(copied.localShellName, "");
assert.equal(copied.localShellIcon, "");
const named = createHostTerminalSession("named-zsh", edited);
assert.equal(named.localShellName, "Zsh");
assert.equal(named.localShellIcon, "zsh");
});
+2 -2
View File
@@ -153,8 +153,8 @@ export const createHostTerminalSession = (
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName,
localShellIcon: host.localShellIcon,
localShellName: host.localShellName ?? "",
localShellIcon: host.localShellIcon ?? "",
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
+2
View File
@@ -1358,6 +1358,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
} = useVaultGroupDragHandlers({
@@ -1602,6 +1603,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
@@ -8,6 +8,9 @@ import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
@@ -314,6 +317,7 @@ export const resetKittyKeyboardModeStateForSession = (
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
getHost?: () => Host;
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
@@ -954,6 +958,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
terminalLinkHandler.activate(event, uri);
});
term.loadAddon(webLinksAddon);
const archiveLinks = registerTerminalArchiveLinks({
term,
isEnabled: () => ctx.statusRef.current === 'connected'
&& !ctx.isBroadcastEnabledRef.current
&& supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId),
});
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
? registerPluginTerminalLinkProvider({
term,
@@ -3478,6 +3489,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
keywordHighlighter.dispose();
cursorLineHighlighter.dispose();
pluginLinkProviderHost?.dispose();
archiveLinks.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { ILink, ILinkProvider, Terminal } from '@xterm/xterm';
import { registerTerminalArchiveLinks } from './terminalArchiveLinks';
function fixture() {
let provider!: ILinkProvider;
let text = 'backup.tar.gz';
let enabled = true, ready = true, registrationDisposed = false, wrapped = false;
const pasted: string[] = [];
let submitted: (data: string) => void = () => {};
let markerLine = -1;
const term = {
buffer: { active: { getLine: (y: number) => y === 0 ? { translateToString: () => text, isWrapped: wrapped } : undefined } },
paste: (value: string) => { pasted.push(value); ready = false; },
focus: () => {},
registerMarker: () => ({ line: markerLine, isDisposed: false, dispose() { this.isDisposed = true; } }),
onData: (listener: (data: string) => void) => { submitted = listener; return { dispose() {} }; },
registerLinkProvider: (next: ILinkProvider) => { provider = next; return { dispose: () => { registrationDisposed = true; } }; },
};
const registration = registerTerminalArchiveLinks({ term: term as unknown as Terminal, isEnabled: () => enabled, isInputReady: () => ready });
const links = () => { let value: ILink[] | undefined; provider.provideLinks(1, result => { value = result; }); return value; };
return { links, pasted, registration, submit: () => { markerLine = 0; submitted('\r'); }, setText: (value: string) => { text = value; }, setReady: (value: boolean) => { ready = value; }, setEnabled: (value: boolean) => { enabled = value; }, setWrapped: () => { wrapped = true; }, isDisposed: () => registrationDisposed };
}
const ctrlClick = { button: 0, ctrlKey: true } as MouseEvent;
test('Ctrl+left click prepares exactly one command without Enter; ordinary click does nothing', () => {
const f = fixture(), link = f.links()![0];
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 13, y: 1 } });
link.activate({ button: 0, ctrlKey: false } as MouseEvent, link.text);
assert.deepEqual(f.pasted, []);
link.activate(ctrlClick, link.text);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, ["tar -xzf './backup.tar.gz'"]);
});
test('live readiness/platform checks preserve a draft even for cached links', () => {
const f = fixture(), link = f.links()![0];
f.setReady(false);
assert.equal(f.links(), undefined);
link.activate(ctrlClick, link.text);
f.setReady(true); f.setEnabled(false);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
});
test('stale output and disposed links cannot prepare commands; wrapped fragments are excluded', () => {
const f = fixture(), link = f.links()![0];
f.setText('changed.tar.gz'); link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
f.setWrapped(); assert.equal(f.links(), undefined);
f.registration.dispose(); assert.equal(f.isDisposed(), true);
link.activate(ctrlClick, link.text); assert.deepEqual(f.pasted, []);
});
test('submitting another command invalidates old relative names and cached links, but not absolute paths', () => {
const f = fixture(), link = f.links()![0];
f.submit();
assert.deepEqual(f.links(), []);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
f.setText('/tmp/backup.tar.gz');
const absolute = f.links()![0];
absolute.activate(ctrlClick, absolute.text);
assert.deepEqual(f.pasted, ["tar -xzf '/tmp/backup.tar.gz'"]);
});
@@ -0,0 +1,61 @@
import type { IDisposable, ILink, Terminal } from '@xterm/xterm';
import { findTerminalArchives } from '../../../domain/terminalArchives';
import { pluginTerminalCellRange, readPluginTerminalBufferText } from '../pluginTerminalBufferText';
/** Archive commands are prepared only at an empty shell prompt, never submitted. */
export function registerTerminalArchiveLinks(options: {
term: Terminal;
isEnabled: () => boolean;
isInputReady: () => boolean;
}): IDisposable {
let disposed = false;
const { term } = options;
// Relative names belong only to output from the latest submitted command.
// A marker follows scrollback trimming without keeping a cwd history.
let commandStart = term.registerMarker(0);
const submission = term.onData(data => {
if (!/[\r\n]/u.test(data)) return;
commandStart?.dispose();
commandStart = term.registerMarker(0);
});
const hasRelativeContext = (y: number) => commandStart !== undefined
&& !commandStart.isDisposed && y - 1 > commandStart.line;
const registration = term.registerLinkProvider({
provideLinks(y, callback) {
if (disposed || !options.isEnabled() || !options.isInputReady()) {
callback(undefined);
return;
}
const line = term.buffer.active.getLine(y - 1);
// Do not mistake a fragment of a wrapped filename for a complete archive.
if (!line || line.isWrapped || term.buffer.active.getLine(y)?.isWrapped) {
callback(undefined);
return;
}
const printed = readPluginTerminalBufferText(line, true);
if (printed.text.length > 8192) {
callback(undefined);
return;
}
const links: ILink[] = findTerminalArchives(printed.text).flatMap((match) => {
if (match.relative && !hasRelativeContext(y)) return [];
const cells = pluginTerminalCellRange(printed, match.start, match.length);
if (!cells) return [];
return [{
range: { start: { x: cells.x + 1, y }, end: { x: cells.x + cells.width, y } },
text: match.text,
decorations: { pointerCursor: true, underline: true },
activate(event) {
if (disposed || event.button !== 0 || !event.ctrlKey || !options.isEnabled() || !options.isInputReady()) return;
if (match.relative && !hasRelativeContext(y)) return;
if (term.buffer.active.getLine(y - 1)?.translateToString(true) !== printed.text) return;
term.paste(match.command);
term.focus();
},
}];
});
callback(links);
},
});
return { dispose() { disposed = true; submission.dispose(); commandStart?.dispose(); registration.dispose(); } };
}
@@ -215,6 +215,8 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
effectiveTerminalProtocol,
@@ -517,6 +519,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const runtime = createXTermRuntime({
container: containerRef.current,
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize,
+1 -1
View File
@@ -86,7 +86,7 @@ const SessionTabIcon: React.FC<{
// Local protocol → shell-specific icon if available, else OS-specific icon
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
// Use shell icon from discovery when available
const iconId = shellIcon || host?.localShellIcon;
const iconId = shellIcon ?? host?.localShellIcon;
if (iconId) {
return (
<img
+54
View File
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Combobox } from "../ui/combobox";
import React, { useCallback } from "react";
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
import { VaultGroupDeletionConfirmationChangedError } from "../../application/state/useVaultGroupDeletion";
@@ -243,6 +244,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
@@ -445,6 +447,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const [isMoveHostsOpen, setIsMoveHostsOpen] = React.useState(false);
const [moveTargetGroup, setMoveTargetGroup] = React.useState("");
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const keyListRef = React.useRef(keys);
keyListRef.current = keys;
@@ -1130,6 +1134,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
count: selectedHostIds.size,
})}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={() => {
setMoveTargetGroup("");
setIsMoveHostsOpen(true);
}}
>
<FolderTree size={12} className="mr-1" />
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
@@ -1794,6 +1811,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</DialogContent>
</Dialog>
<Dialog open={isMoveHostsOpen} onOpenChange={setIsMoveHostsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}</DialogTitle>
<DialogDescription>{t("vault.hosts.moveSelected.description")}</DialogDescription>
</DialogHeader>
<Combobox
options={[
{ value: "", label: t("vault.hosts.allHosts") },
...allGroupPaths
.filter((path: string) => path !== "General"
|| customGroups.includes("General")
|| allGroupPaths.some((group: string) => group.startsWith("General/")))
.map((path: string) => ({ value: path, label: path })),
]}
value={moveTargetGroup}
onValueChange={setMoveTargetGroup}
ariaLabel={t("hostDetails.group.placeholder")}
placeholder={t("vault.hosts.allHosts")}
clearable={false}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsMoveHostsOpen(false)}>{t("common.cancel")}</Button>
<Button
disabled={selectedHostIds.size === 0}
onClick={() => {
moveHostsToGroup(selectedHostIds, moveTargetGroup || null);
setIsMoveHostsOpen(false);
clearHostSelection();
}}
>
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ImportVaultDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, ManagedSource } from '../../types';
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
const host = (id: string, extra: Partial<Host> = {}): Host => ({
id, label: `Host ${id}`, hostname: `${id}.example`, username: 'user', port: 22,
tags: ['keep'], group: 'Old', ...extra,
});
test('bulk group move publishes all selected hosts together and preserves unselected hosts', async () => {
const original = [host('a'), host('b', { protocol: 'telnet' }), host('c')];
const writes: Host[][] = [];
await withActions(original, [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a', 'b']), 'New/Nested'));
assert.equal(writes.length, 1);
assert.deepEqual(writes[0].map(h => h.group), ['New/Nested', 'New/Nested', 'Old']);
assert.equal(writes[0][2], original[2]);
assert.deepEqual(writes[0][0].tags, ['keep']);
assert.equal(writes[0][0].hostname, original[0].hostname);
}, writes);
});
test('mixed protocols entering and leaving managed groups retain single-host move rules', async () => {
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
const writes: Host[][] = [];
await withActions(original, sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), 'Managed/Nested'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].managedSourceId, 'child');
assert.equal(writes[0][0].label, 'Hostssh');
assert.equal(writes[0][1].managedSourceId, undefined);
assert.equal(writes[0][1].label, 'Host telnet');
assert.equal(writes[0][2].managedSourceId, undefined);
assert.equal(writes[0][2].label, 'Host local');
}, writes);
await withActions(writes[0], sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), null));
assert.deepEqual(writes[1].map(h => h.group), ['', '', '']);
assert.ok(writes[1].every(h => h.managedSourceId === undefined));
}, writes);
});
test('same-group, missing and empty selections do not publish changes', async () => {
const writes: Host[][] = [];
await withActions([host('a')], [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a']), 'Old'));
act(() => actions.moveHostsToGroup(new Set(['missing']), 'New'));
act(() => actions.moveHostsToGroup(new Set(), 'New'));
assert.equal(writes.length, 0);
act(() => actions.moveHostToGroup('a', 'New'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].group, 'New');
}, writes);
});
async function withActions(
hosts: Host[], managedSources: ManagedSource[],
run: (actions: ReturnType<typeof useVaultGroupDragHandlers>) => void,
writes: Host[][],
) {
const previousWindow = globalThis.window;
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = { setTimeout: () => 1, clearTimeout: () => {} } as unknown as Window & typeof globalThis;
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useVaultGroupDragHandlers>;
function Probe() {
actions = useVaultGroupDragHandlers({ hosts, managedSources,
onUpdateHosts: next => writes.push(next), onUpdateManagedSources: () => {}, t: key => key });
return null;
}
try {
await act(async () => { renderer = create(<Probe />); });
run(actions!);
} finally {
await act(async () => renderer?.unmount());
globalThis.window = previousWindow;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
}
+28 -40
View File
@@ -66,53 +66,40 @@ export function useVaultGroupDragHandlers({
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const moveHostsToGroup = useCallback((hostIds: ReadonlySet<string>, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const movedHost = {
...hostToMove,
group: targetGroup,
};
const updatedHost = (() => {
const h = movedHost;
// Only SSH hosts can be managed (SSH config only supports SSH)
const canBeManaged = !h.protocol || h.protocol === "ssh";
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
let label = h.label;
if (targetManagedSource && canBeManaged && label) {
label = label.replace(/\s/g, '');
}
return {
...h,
label,
group: targetGroup,
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
};
})();
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
let movedCount = 0;
const updatedHosts = hosts.map(host => {
if (!hostIds.has(host.id) || (host.group || "") === targetGroup) return host;
movedCount += 1;
const canBeManaged = !host.protocol || host.protocol === "ssh";
return {
...host,
group: targetGroup,
label: targetManagedSource && canBeManaged
? host.label.replace(/\s/g, '') : host.label,
managedSourceId: targetManagedSource && canBeManaged ? targetManagedSource.id : undefined,
};
});
setDragOverDropTarget(null);
if (movedCount === 0) return;
// Publish the entire selection once; repeated single-host updates would
// each start from the same render snapshot and overwrite earlier moves.
onUpdateHosts(updatedHosts);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
toast.success(t("vault.hosts.moveMultiple.success", {
count: movedCount,
group: groupPath || t("vault.hosts.allHosts"),
}));
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
moveHostsToGroup(new Set([hostId]), groupPath);
}, [moveHostsToGroup]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
@@ -151,6 +138,7 @@ export function useVaultGroupDragHandlers({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
};
+18
View File
@@ -875,3 +875,21 @@ test("restore retains an explicit backend-default local shell snapshot", () => {
assert.deepEqual(restored.sessions[0].localShellArgs, []);
assert.equal(restored.sessions[0].localStartDir, "~");
});
test("restore retains absent local display snapshots while legacy fields remain absent", () => {
const payload = buildSessionRestorePayload({
sessions: [
{ ...session("empty-display"), protocol: "local", localShellName: "", localShellIcon: "" },
{ ...session("named-display"), protocol: "local", localShellName: "Bash", localShellIcon: "bash" },
{ ...session("legacy-display"), protocol: "local" },
],
workspaces: [], tabOrder: [], activeTabId: "empty-display", now: 123,
});
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
assert.equal(restored.sessions[0].localShellName, "");
assert.equal(restored.sessions[0].localShellIcon, "");
assert.equal(restored.sessions[1].localShellName, "Bash");
assert.equal(restored.sessions[1].localShellIcon, "bash");
assert.equal(restored.sessions[2].localShellName, undefined);
assert.equal(restored.sessions[2].localShellIcon, undefined);
});
+4 -4
View File
@@ -154,8 +154,8 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
...(serialConfig ? { serialConfig } : {}),
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
...(session.localShellName ? { localShellName: session.localShellName } : {}),
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
...(session.localShellName !== undefined ? { localShellName: session.localShellName } : {}),
...(session.localShellIcon !== undefined ? { localShellIcon: session.localShellIcon } : {}),
...(session.localStartDir ? { localStartDir: session.localStartDir } : {}),
...(session.fontSize !== undefined ? { fontSize: session.fontSize } : {}),
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
@@ -197,8 +197,8 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
...(serialConfig ? { serialConfig } : {}),
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
...(readStringAllowEmpty(value, "localShellName") !== undefined ? { localShellName: readStringAllowEmpty(value, "localShellName") } : {}),
...(readStringAllowEmpty(value, "localShellIcon") !== undefined ? { localShellIcon: readStringAllowEmpty(value, "localShellIcon") } : {}),
...(readString(value, "localStartDir") ? { localStartDir: readString(value, "localStartDir") } : {}),
...(readNumber(value, "fontSize") !== undefined ? { fontSize: readNumber(value, "fontSize") } : {}),
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { findTerminalArchives, supportsTerminalArchiveCommands } from './terminalArchives';
import type { Host } from './models';
const host: Host = { id: 'test', label: 'Test', hostname: 'host', username: 'me', tags: [], os: 'linux' };
test('archive commands require known Linux facts, not the default host icon', () => {
assert.equal(supportsTerminalArchiveCommands(host, 'darwin'), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: 'ubuntu' }, 'darwin'), true);
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride: 'linux' }, 'darwin'), true);
for (const osOverride of ['unknown', 'windows', 'macos', 'freebsd'] as const) {
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride, distro: 'ubuntu' }, 'linux'), false);
}
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: 'ubuntu', deviceType: 'network' }, 'linux'), false);
for (const protocol of ['telnet', 'serial', 'plugin:test'] as const) {
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol, osOverride: 'linux' }, 'linux'), false);
}
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'darwin'), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'win32'), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'linux'), true);
});
test('finite tar and zip formats prepare commands without Enter or source deletion', () => {
const commands = findTerminalArchives('a.tar b.tar.gz c.tgz d.tar.bz2 e.tbz2 f.tar.xz g.txz h.zip').map(x => x.command);
assert.deepEqual(commands, [
"tar -xf './a.tar'", "tar -xzf './b.tar.gz'", "tar -xzf './c.tgz'",
"tar -xjf './d.tar.bz2'", "tar -xjf './e.tbz2'", "tar -xJf './f.tar.xz'", "tar -xJf './g.txz'", "unzip './h.zip'",
]);
assert.deepEqual(findTerminalArchives('a.gz b.rar c.7z tool.exe https://host/a.zip'), []);
assert.ok(commands.every(command => !/[\r\n]/u.test(command)));
});
test('quoted filenames, Unicode and leading options remain literal shell arguments', () => {
const text = `输出 "it's $(touch NO).tar.gz" '-options.zip' /opt/文件.tar ~/backup.zip`;
const matches = findTerminalArchives(text);
assert.equal(matches.length, 4);
for (const match of matches) assert.equal(text.slice(match.start, match.start + match.length), match.text);
const expected = ["./it's $(touch NO).tar.gz", './-options.zip', '/opt/文件.tar', `${process.env.HOME}/backup.zip`];
for (let i = 0; i < matches.length; i++) {
// Substitute only the fixed extractor name/options with printf: a real POSIX
// shell must see the original filename as exactly one literal argument.
const argument = matches[i].command.replace(/^(?:tar -x[zjJ]?f|unzip) /, '');
assert.equal(execFileSync('/bin/sh', ['-c', `printf '%s' ${argument}`], { encoding: 'utf8' }), expected[i]);
}
});
test('does not guess partial tokens inside malformed quotes or include control characters', () => {
assert.deepEqual(findTerminalArchives('"unfinished.tar.gz'), []);
assert.deepEqual(findTerminalArchives('file\u001b.tar.gz'), []);
assert.deepEqual(findTerminalArchives('"http://example.com/my archive.zip"'), []);
});
+51
View File
@@ -0,0 +1,51 @@
import { resolveHostOs } from './host';
import type { Host } from './models';
export function supportsTerminalArchiveCommands(host: Host, localPlatform: string): boolean {
if (host.protocol === 'local') return localPlatform === 'linux';
if (host.protocol && !['ssh', 'mosh', 'et'].includes(host.protocol)) return false;
return host.deviceType !== 'network' && resolveHostOs(host) === 'linux';
}
const TAR_OPTIONS: Record<string, string> = {
'.tar': '-xf',
'.tar.gz': '-xzf',
'.tgz': '-xzf',
'.tar.bz2': '-xjf',
'.tbz2': '-xjf',
'.tar.xz': '-xJf',
'.txz': '-xJf',
};
function archiveCommand(file: string): string | null {
if (Array.from(file).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
|| /^[a-z][a-z0-9+.-]*:/iu.test(file)) return null;
const suffix = Object.keys(TAR_OPTIONS).find((extension) => file.endsWith(extension));
if (!suffix && !file.endsWith('.zip')) return null;
// Prefix relative paths so an archive named "-options.zip" is never an option.
const path = file.startsWith('/') || file.startsWith('~/') ? file : `./${file}`;
const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`;
const argument = path.startsWith('~/') ? `"$HOME"/${quote(path.slice(2))}` : quote(path);
return suffix ? `tar ${TAR_OPTIONS[suffix]} ${argument}` : `unzip ${argument}`;
}
export interface TerminalArchiveMatch {
start: number;
length: number;
text: string;
command: string;
relative: boolean;
}
/** One printed token, or an explicitly quoted filename containing spaces. */
export function findTerminalArchives(line: string): TerminalArchiveMatch[] {
const tokens = /(^|\s)(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([^\s"']+))(?=\s|$)/gu;
return Array.from(line.matchAll(tokens)).flatMap((match) => {
const text = match[0].slice(match[1].length);
const file = match[2] ?? match[3] ?? match[4];
const command = archiveCommand(file);
return command ? [{ start: match.index + match[1].length, length: text.length, text, command,
relative: !file.startsWith('/') && !file.startsWith('~/'),
}] : [];
});
}
@@ -560,6 +560,7 @@ async function runOpenCodeTurn({
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let backgroundWarningCount = 0;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
@@ -619,6 +620,13 @@ async function runOpenCodeTurn({
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|| "OpenCode reported a background error";
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
else emitter.status(message);
continue;
}
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
@@ -500,6 +500,52 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
assert.equal(events.some((event) => event.k === "error"), false);
});
for (const scoped of [false, true]) {
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
const { events, emitter } = collector();
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "session.error",
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
};
if (!scoped) {
yield {
type: "session.error",
properties: { error: { data: { message: "Failed to install another plugin" } } },
};
}
yield {
type: "message.part.updated",
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
await runOpenCodeTurn({
prompt: "continue",
resumeSessionId: "sess-1",
emitter,
openCodeFactory: async () => ({
client: {
global: { event: async () => ({ stream }) },
session: { promptAsync: async () => ({ data: true }) },
},
server: { close() {} },
}),
});
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
assert.equal(events.some((event) => event.k === "done"), !scoped);
const warnings = events.filter((event) => event.k === "warning");
assert.equal(warnings.length, scoped ? 0 : 2);
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
});
}
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [