mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +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>
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { upsertHostById } from '../../domain/host';
|
|
import type { Host } from '../../types';
|
|
|
|
export type WorkSurfaceHostEditorMode = 'new' | 'edit';
|
|
|
|
export type WorkSurfaceHostEditorTarget =
|
|
| { mode: 'new'; defaultGroup: string | null; requestId: number }
|
|
| { mode: 'edit'; openedHost: Host; requestId: number };
|
|
|
|
interface UseWorkSurfaceHostEditorOptions {
|
|
hosts: Host[];
|
|
onUpdateHosts: (hosts: Host[]) => void;
|
|
onSaved?: (mode: WorkSurfaceHostEditorMode) => void;
|
|
}
|
|
|
|
export function buildWorkSurfaceHostEditorKey(target: WorkSurfaceHostEditorTarget): string {
|
|
if (target.mode === 'edit') {
|
|
return `edit:${target.openedHost.id}:${target.requestId}`;
|
|
}
|
|
return `new:${target.defaultGroup ?? 'root'}:${target.requestId}`;
|
|
}
|
|
|
|
export function shouldCloseDeletedWorkSurfaceHost(
|
|
hosts: Host[],
|
|
target: WorkSurfaceHostEditorTarget | null,
|
|
): boolean {
|
|
return target?.mode === 'edit'
|
|
&& !hosts.some((host) => host.id === target.openedHost.id);
|
|
}
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function areDraftValuesEqual(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
return Array.isArray(left)
|
|
&& Array.isArray(right)
|
|
&& left.length === right.length
|
|
&& left.every((value, index) => areDraftValuesEqual(value, right[index]));
|
|
}
|
|
if (!isPlainRecord(left) || !isPlainRecord(right)) return false;
|
|
const leftKeys = Object.keys(left);
|
|
const rightKeys = Object.keys(right);
|
|
return leftKeys.length === rightKeys.length
|
|
&& leftKeys.every((key) => Object.hasOwn(right, key)
|
|
&& areDraftValuesEqual(left[key], right[key]));
|
|
}
|
|
|
|
function mergeWorkSurfaceDraftValue(
|
|
openedValue: unknown,
|
|
draftValue: unknown,
|
|
latestValue: unknown,
|
|
): unknown {
|
|
if (areDraftValuesEqual(draftValue, openedValue)) return latestValue;
|
|
if (!isPlainRecord(draftValue) || !isPlainRecord(latestValue)) {
|
|
return draftValue;
|
|
}
|
|
|
|
const openedRecord = isPlainRecord(openedValue) ? openedValue : {};
|
|
const merged: Record<string, unknown> = { ...latestValue };
|
|
const draftKeys = new Set([...Object.keys(openedRecord), ...Object.keys(draftValue)]);
|
|
for (const key of draftKeys) {
|
|
if (!Object.hasOwn(draftValue, key)) {
|
|
delete merged[key];
|
|
continue;
|
|
}
|
|
merged[key] = mergeWorkSurfaceDraftValue(
|
|
openedRecord[key],
|
|
draftValue[key],
|
|
latestValue[key],
|
|
);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function mergeWorkSurfaceHostDraft(
|
|
openedHost: Host,
|
|
draft: Host,
|
|
latestHost: Host,
|
|
): Host {
|
|
return mergeWorkSurfaceDraftValue(openedHost, draft, latestHost) as Host;
|
|
}
|
|
|
|
export function saveWorkSurfaceHostDraft(
|
|
hosts: Host[],
|
|
target: WorkSurfaceHostEditorTarget,
|
|
draft: Host,
|
|
): Host[] | null {
|
|
if (target.mode === 'new') {
|
|
return upsertHostById(hosts, draft);
|
|
}
|
|
|
|
const latestHost = hosts.find((host) => host.id === target.openedHost.id);
|
|
if (!latestHost) return null;
|
|
|
|
return upsertHostById(
|
|
hosts,
|
|
mergeWorkSurfaceHostDraft(target.openedHost, draft, latestHost),
|
|
);
|
|
}
|
|
|
|
export function useWorkSurfaceHostEditor({
|
|
hosts,
|
|
onUpdateHosts,
|
|
onSaved,
|
|
}: UseWorkSurfaceHostEditorOptions) {
|
|
const [target, setTarget] = useState<WorkSurfaceHostEditorTarget | null>(null);
|
|
const requestIdRef = useRef(0);
|
|
|
|
const nextRequestId = useCallback(() => {
|
|
requestIdRef.current += 1;
|
|
return requestIdRef.current;
|
|
}, []);
|
|
|
|
const openNew = useCallback((defaultGroup?: string | null) => {
|
|
setTarget({
|
|
mode: 'new',
|
|
defaultGroup: defaultGroup || null,
|
|
requestId: nextRequestId(),
|
|
});
|
|
}, [nextRequestId]);
|
|
|
|
const openEdit = useCallback((host: Host) => {
|
|
setTarget({ mode: 'edit', openedHost: host, requestId: nextRequestId() });
|
|
}, [nextRequestId]);
|
|
|
|
const close = useCallback(() => {
|
|
setTarget(null);
|
|
}, []);
|
|
|
|
const save = useCallback((draft: Host) => {
|
|
if (!target) return;
|
|
let saved = false;
|
|
onUpdateHosts((prevHosts) => {
|
|
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
|
|
if (!nextHosts) return prevHosts;
|
|
saved = true;
|
|
return nextHosts;
|
|
});
|
|
if (!saved) {
|
|
close();
|
|
return;
|
|
}
|
|
onSaved?.(target.mode);
|
|
close();
|
|
}, [close, onSaved, onUpdateHosts, target]);
|
|
|
|
useEffect(() => {
|
|
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
|
|
close();
|
|
}
|
|
}, [close, hosts, target]);
|
|
|
|
const editorKey = useMemo(
|
|
() => (target ? buildWorkSurfaceHostEditorKey(target) : null),
|
|
[target],
|
|
);
|
|
|
|
return {
|
|
target,
|
|
editorKey,
|
|
openNew,
|
|
openEdit,
|
|
close,
|
|
save,
|
|
};
|
|
}
|