mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
735c047a09
* fix(hosts): keep draft connect scripts before onConnect promote Host details queue editing pruned non-onConnect scripts immediately, so default manual automation scripts could be selected but never appeared in the queue. Keep an editable draft queue and only prune missing IDs until save promotes triggers. Closes #2705 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): promote persisted manual connect-queue scripts on save Editable queue retention left already-saved manual script IDs unpromoted when reopen/save compared only newly added IDs. Promote every queued script that still needs onConnect + host targeting. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(hosts): drop unused HostDetailsPanel snippet imports Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): do not demote global scripts during queue save sync Promote-on-save must leave targetsAllHosts onConnect scripts unchanged when they appear in a host connectScriptIds list. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hosts): cover global connect scripts during queue save sync Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): promote connect-queue scripts on new host save New hosts have no initialData, so queue promote-on-save was skipped and manual scripts stayed non-onConnect after the first save. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): harden connect-queue promote against Codex review Preserve global scope when promoting manuals, skip re-promoting scripts demoted while the host editor is open, sync peer host queues for retained targets, and keep test fixtures ASCII-only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): persist peer connect queues with edited host save Peer queue sync via onHostsChange was overwritten by onSave rebuilding from a stale hosts snapshot. Write peers+edited host together and make save paths apply functional host updates. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): keep connect queues across hydration and concurrent edits Skip queue rewrite while snippets are still empty, and do not promote entries whose non-onConnect trigger changed while the host editor stayed open. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): skip connect-queue rewrite before snippets hydrate Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): preserve concurrent target removals and merge peer deltas Drop stale queue entries when targets were unlinked while editing, and apply peer connectScriptIds updates via functional host state merges. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): hydrate connect-queue baseline and append peer deltas Capture the first hydrated snippet catalog as the editor baseline, and merge peer queue updates by appending only newly queued script IDs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): snapshot opening queue and keep peer updates host-safe Use the editor-opening connect queue as sync previous IDs, and never overwrite the edited host inside peer delta merges. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): clear deleted queues after hydrate and honor manual unlinks Treat a hydrated empty snippet catalog as ready for queue reconciliation, and drop concurrently untargeted non-onConnect queue entries on save. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hosts): clear loginScriptId when connect queue is emptied sanitizeHost reconstitutes connectScriptIds from loginScriptId when the explicit queue is absent, so clearing the last queued script must also drop the legacy field. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
|
|
import type { WorkSurfaceHostEditorTarget } from '../state/useWorkSurfaceHostEditor';
|
|
import type { EditorTabChrome } from '../state/editorTabStore';
|
|
import type { LogView } from '../state/logViewState';
|
|
import { useI18n } from '../i18n/I18nProvider';
|
|
import HostDetailsPanel from '../../components/HostDetailsPanel';
|
|
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
|
|
import { PortalContainerProvider } from '../../components/ui/portal-container';
|
|
import { resolveGroupDefaults } from '../../domain/groupConfig';
|
|
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from '@/infrastructure/config/storageKeys';
|
|
import type {
|
|
GroupConfig,
|
|
Host,
|
|
Identity,
|
|
ManagedSource,
|
|
ProxyProfile,
|
|
Snippet,
|
|
SSHKey,
|
|
TerminalSession,
|
|
Workspace,
|
|
} from '../../types';
|
|
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
|
|
|
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
|
|
|
export function resolveWorkSurfaceHostEditorKind(
|
|
target: WorkSurfaceHostEditorTarget,
|
|
): WorkSurfaceHostEditorKind {
|
|
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
|
? 'serial'
|
|
: 'standard';
|
|
}
|
|
|
|
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
|
const segments = path?.split('/').filter(Boolean) ?? [];
|
|
for (let index = 1; index <= segments.length; index += 1) {
|
|
groups.add(segments.slice(0, index).join('/'));
|
|
}
|
|
}
|
|
|
|
export function collectWorkSurfaceHostGroups(
|
|
hosts: Host[],
|
|
customGroups: string[],
|
|
groupConfigs: GroupConfig[],
|
|
): string[] {
|
|
const groups = new Set<string>();
|
|
for (const path of customGroups) addGroupAndAncestors(groups, path);
|
|
for (const config of groupConfigs) addGroupAndAncestors(groups, config.path);
|
|
for (const host of hosts) addGroupAndAncestors(groups, host.group);
|
|
return Array.from(groups).sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function collectWorkSurfaceHostTags(hosts: Host[]): string[] {
|
|
const tags = new Set<string>();
|
|
for (const host of hosts) {
|
|
for (const tag of host.tags ?? []) tags.add(tag);
|
|
}
|
|
return Array.from(tags).sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function getAppHostEditorLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
|
return {
|
|
display: surfaceVisible ? undefined : 'none',
|
|
pointerEvents: surfaceVisible ? undefined : 'none',
|
|
};
|
|
}
|
|
|
|
interface AppHostEditorLayerProps {
|
|
/** When omitted, surface visibility is derived from activeTabId in this leaf. */
|
|
surfaceVisible?: boolean;
|
|
target: WorkSurfaceHostEditorTarget | null;
|
|
editorKey: string | null;
|
|
hosts: Host[];
|
|
customGroups: string[];
|
|
groupConfigs: GroupConfig[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
proxyProfiles: ProxyProfile[];
|
|
managedSources: ManagedSource[];
|
|
snippets: Snippet[];
|
|
terminalThemeId: string;
|
|
terminalFontSize: number;
|
|
/** Required when surfaceVisible is not passed (leaf active-tab subscription). */
|
|
sessions?: TerminalSession[];
|
|
workspaces?: Workspace[];
|
|
logViews?: readonly LogView[];
|
|
orderedTabs?: readonly string[];
|
|
editorTabs?: readonly EditorTabChrome[];
|
|
onSave: (host: Host) => void;
|
|
onCancel: () => void;
|
|
onCreateGroup: (groupPath: string) => void;
|
|
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
|
onUpdateSnippets: (snippets: Snippet[]) => void;
|
|
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
|
}
|
|
|
|
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
|
surfaceVisible: surfaceVisibleProp,
|
|
target,
|
|
editorKey,
|
|
hosts,
|
|
customGroups,
|
|
groupConfigs,
|
|
keys,
|
|
identities,
|
|
proxyProfiles,
|
|
managedSources,
|
|
snippets,
|
|
terminalThemeId,
|
|
terminalFontSize,
|
|
sessions = [],
|
|
workspaces = [],
|
|
logViews = [],
|
|
orderedTabs = [],
|
|
onSave,
|
|
onCancel,
|
|
onCreateGroup,
|
|
onImportOrReuseKey,
|
|
onUpdateSnippets,
|
|
onUpdateHosts,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const derivedSurfaceVisible = useWorkSurfaceVisible({
|
|
enabled: true,
|
|
sessions,
|
|
workspaces,
|
|
logViews,
|
|
orderedTabs,
|
|
});
|
|
// Prefer explicit prop only when provided, so existing tests keep control.
|
|
const surfaceVisible = surfaceVisibleProp ?? derivedSurfaceVisible;
|
|
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
|
|
const groups = useMemo(
|
|
() => collectWorkSurfaceHostGroups(hosts, customGroups, groupConfigs),
|
|
[customGroups, groupConfigs, hosts],
|
|
);
|
|
const allTags = useMemo(() => collectWorkSurfaceHostTags(hosts), [hosts]);
|
|
const groupPath = target?.mode === 'edit'
|
|
? target.openedHost.group
|
|
: target?.defaultGroup;
|
|
const groupDefaults = useMemo(
|
|
() => (groupPath ? resolveGroupDefaults(groupPath, groupConfigs) : undefined),
|
|
[groupConfigs, groupPath],
|
|
);
|
|
// Share width persistence with Vault host details so both entry points feel consistent.
|
|
const hostPanelResizeProps = {
|
|
resizable: true as const,
|
|
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
|
|
resizeAriaLabel: t('vault.panel.resizeWidth'),
|
|
};
|
|
|
|
if (!target || !editorKey) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={setPortalContainer}
|
|
className="pointer-events-none absolute inset-0 z-40 [&>*]:pointer-events-auto"
|
|
data-section="app-host-editor-layer"
|
|
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
|
>
|
|
<PortalContainerProvider container={portalContainer}>
|
|
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
|
<SerialHostDetailsPanel
|
|
key={editorKey}
|
|
initialData={target.openedHost}
|
|
allTags={allTags}
|
|
groups={groups}
|
|
groupDefaults={groupDefaults}
|
|
onSave={onSave}
|
|
onCancel={onCancel}
|
|
layout="overlay"
|
|
className="pointer-events-auto"
|
|
{...hostPanelResizeProps}
|
|
/>
|
|
) : (
|
|
<HostDetailsPanel
|
|
key={editorKey}
|
|
initialData={target.mode === 'edit' ? target.openedHost : null}
|
|
availableKeys={keys}
|
|
identities={identities}
|
|
proxyProfiles={proxyProfiles}
|
|
groups={groups}
|
|
managedSources={managedSources}
|
|
allTags={allTags}
|
|
allHosts={hosts}
|
|
defaultGroup={target.mode === 'new' ? target.defaultGroup : undefined}
|
|
terminalThemeId={terminalThemeId}
|
|
terminalFontSize={terminalFontSize}
|
|
groupDefaults={groupDefaults}
|
|
groupConfigs={groupConfigs}
|
|
snippets={snippets}
|
|
onSnippetsChange={onUpdateSnippets}
|
|
onHostsChange={onUpdateHosts}
|
|
onImportKey={onImportOrReuseKey}
|
|
onSave={onSave}
|
|
onCancel={onCancel}
|
|
onCreateGroup={onCreateGroup}
|
|
layout="overlay"
|
|
className="pointer-events-auto"
|
|
{...hostPanelResizeProps}
|
|
/>
|
|
)}
|
|
</PortalContainerProvider>
|
|
</div>
|
|
);
|
|
};
|