mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f671eb8533 | ||
|
|
e53fa6f65b |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -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': '设置你的主机',
|
||||
|
||||
@@ -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': '設定你的主機',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -560,7 +560,6 @@ 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;
|
||||
@@ -620,13 +619,6 @@ 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,52 +500,6 @@ 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: [
|
||||
|
||||
Reference in new issue
Block a user