Files
NetMesh/domain/snippetSelection.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

312 lines
10 KiB
TypeScript

import type { Host, Snippet } from './models';
import { deleteSnippetFromVault } from './snippetAgentOps.ts';
import { renumberVaultOrder } from './vaultOrder.ts';
/** Normalize `netcatty:snippets:delete` detail into a set of snippet ids. */
export function collectSnippetDeleteIds(
detail?: { id?: string; ids?: readonly string[] } | null,
): Set<string> {
const ids = new Set<string>();
for (const id of detail?.ids ?? []) {
if (typeof id === 'string' && id.length > 0) ids.add(id);
}
if (typeof detail?.id === 'string' && detail.id.length > 0) {
ids.add(detail.id);
}
return ids;
}
export function deleteSelectedSnippetsFromVault(
snippets: Snippet[],
hosts: Host[],
selectedSnippetIds: ReadonlySet<string>,
): { snippets: Snippet[]; hosts: Host[]; deletedCount: number } {
let nextSnippets = [...snippets];
let nextHosts = [...hosts];
let deletedCount = 0;
for (const snippet of snippets) {
if (!snippet.id || !selectedSnippetIds.has(snippet.id)) continue;
const result = deleteSnippetFromVault(nextSnippets, nextHosts, snippet.id);
if ('error' in result) continue;
nextSnippets = result.snippets;
nextHosts = result.hosts;
deletedCount += 1;
}
return { snippets: nextSnippets, hosts: nextHosts, deletedCount };
}
/**
* Drop login/connect script bindings that point at snippets absent from the
* latest catalog. A queued full-array host write that encrypted before a
* concurrent bulk-delete must not restore those bindings after the delete
* releases the vault lock.
*/
export function pruneStaleHostSnippetBindings(
host: Host,
snippetIds: ReadonlySet<string>,
): Host {
let next = host;
if (host.loginScriptId && !snippetIds.has(host.loginScriptId)) {
next = { ...next, loginScriptId: undefined };
}
const connectIds = next.connectScriptIds;
if (connectIds?.length) {
const pruned = connectIds.filter((id) => Boolean(id) && snippetIds.has(id));
if (
pruned.length !== connectIds.length
|| pruned.some((id, index) => id !== connectIds[index])
) {
next = {
...next,
connectScriptIds: pruned.length > 0 ? pruned : undefined,
};
}
}
return next;
}
/** Returns `hosts` unchanged when every binding still resolves. */
export function pruneHostsStaleSnippetBindings(
hosts: readonly Host[],
snippets: readonly Snippet[],
): Host[] {
const snippetIds = new Set<string>();
for (const snippet of snippets) {
if (snippet.id) snippetIds.add(snippet.id);
}
let changed = false;
const next = hosts.map((host) => {
const pruned = pruneStaleHostSnippetBindings(host, snippetIds);
if (pruned !== host) changed = true;
return pruned;
});
return changed ? next : (hosts as Host[]);
}
/**
* Content fingerprint for three-way rebase. Omits `order` so a local reorder
* (which renumbers every row) does not look like an edit of unrelated snippets.
*/
function snippetContentFingerprint(snippet: Snippet): string {
const { order: _order, ...content } = snippet;
return JSON.stringify(content, (_key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value).sort().reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (value as Record<string, unknown>)[key];
return acc;
}, {});
}
return value;
});
}
/** Relative id sequence for ids present on both sides (order-change detector). */
function sharedIdSequence(
snippets: readonly Snippet[],
sharedIds: ReadonlySet<string>,
): string[] {
const sequence: string[] = [];
for (const snippet of snippets) {
if (!snippet.id || !sharedIds.has(snippet.id)) continue;
sequence.push(snippet.id);
}
return sequence;
}
function sameIdSequence(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) return false;
return left.every((id, index) => id === right[index]);
}
function applyPreferredOrder(
content: Snippet,
ourItem: Snippet,
theirItem: Snippet,
preferTheirSharedOrder: boolean,
): Snippet {
const order = preferTheirSharedOrder ? theirItem.order : ourItem.order;
return content.order === order ? content : { ...content, order };
}
/**
* Place ids that are new relative to `baseIds` into `result`, preserving each
* side's insertion anchors (between left/right neighbors already present).
* A trailing insertion (no right neighbor) appends so a local add stays at the
* end after an unrelated disk reorder of shared ids.
*/
function placeInsertions(
result: string[],
side: readonly Snippet[],
baseIds: ReadonlySet<string>,
keep: ReadonlyMap<string, Snippet>,
): void {
const present = new Set(result);
for (let index = 0; index < side.length; index += 1) {
const id = side[index]?.id;
if (!id || !keep.has(id) || baseIds.has(id) || present.has(id)) continue;
let left: string | null = null;
for (let j = index - 1; j >= 0; j -= 1) {
const prev = side[j]?.id;
if (prev && present.has(prev)) {
left = prev;
break;
}
}
let right: string | null = null;
for (let j = index + 1; j < side.length; j += 1) {
const next = side[j]?.id;
if (next && present.has(next)) {
right = next;
break;
}
}
if (right === null) {
result.push(id);
} else if (left === null) {
result.splice(result.indexOf(right), 0, id);
} else {
const leftIdx = result.indexOf(left);
const rightIdx = result.indexOf(right);
if (leftIdx < rightIdx) {
result.splice(rightIdx, 0, id);
} else {
result.splice(leftIdx + 1, 0, id);
}
}
present.add(id);
}
}
/**
* Three-way rebase for a queued full-array snippet save against the latest
* persisted vault snapshot.
*
* Unlike sync merge, a concurrent disk delete always wins over a local edit of
* the same id so bulk-delete cannot be resurrected by a stale window write.
* When an id exists on all sides, preserve a disk-only content edit; both-sides
* content conflicts prefer the local write (same as sync merge).
* List order is merged independently: shared-id reorder picks a backbone, then
* each side's insertions are placed by their own anchors so a local append does
* not discard a remote mid-list insertion (and the reverse).
*/
export function rebaseSnippetVaultWrite({
base,
ours,
theirs,
}: {
base: readonly Snippet[];
ours: readonly Snippet[];
theirs: readonly Snippet[];
}): Snippet[] {
const baseMap = new Map(base.map((snippet) => [snippet.id, snippet]));
const oursMap = new Map(ours.map((snippet) => [snippet.id, snippet]));
const theirsMap = new Map(theirs.map((snippet) => [snippet.id, snippet]));
const keep = new Map<string, Snippet>();
const allIds = new Set<string>([
...baseMap.keys(),
...oursMap.keys(),
...theirsMap.keys(),
]);
const baseIds = new Set<string>();
const baseOursIds = new Set<string>();
const baseTheirsIds = new Set<string>();
for (const id of baseMap.keys()) {
if (!id) continue;
baseIds.add(id);
if (oursMap.has(id)) baseOursIds.add(id);
if (theirsMap.has(id)) baseTheirsIds.add(id);
}
// Shared-id sequences ignore insertions. Detect reorder of existing ids
// separately from new-id placement so a local add does not look like a
// reorder that discards an unrelated disk reorder.
const oursSharedReordered = !sameIdSequence(
sharedIdSequence(base, baseOursIds),
sharedIdSequence(ours, baseOursIds),
);
const theirsSharedReordered = !sameIdSequence(
sharedIdSequence(base, baseTheirsIds),
sharedIdSequence(theirs, baseTheirsIds),
);
// Backbone follows disk only for a disk-only shared reorder. Insertions are
// merged by anchor below — not by picking one side's entire list order.
const preferTheirSharedOrder = !oursSharedReordered && theirsSharedReordered;
for (const id of allIds) {
if (!id) continue;
const baseItem = baseMap.get(id);
const ourItem = oursMap.get(id);
const theirItem = theirsMap.get(id);
const inBase = baseItem !== undefined;
const inOurs = ourItem !== undefined;
const inTheirs = theirItem !== undefined;
if (!inBase && inOurs && !inTheirs) {
keep.set(id, ourItem);
continue;
}
if (!inBase && !inOurs && inTheirs) {
keep.set(id, theirItem);
continue;
}
if (!inBase && inOurs && inTheirs) {
keep.set(id, ourItem);
continue;
}
if (inBase && inOurs && inTheirs) {
const oursChanged =
snippetContentFingerprint(ourItem) !== snippetContentFingerprint(baseItem);
const theirsChanged =
snippetContentFingerprint(theirItem) !== snippetContentFingerprint(baseItem);
if (!oursChanged && theirsChanged) {
// Disk-only content edit: keep their body; order follows shared reorder.
keep.set(
id,
applyPreferredOrder(theirItem, ourItem, theirItem, preferTheirSharedOrder),
);
} else {
// Unchanged, ours-only, or both-changed conflict → local content wins.
keep.set(
id,
applyPreferredOrder(ourItem, ourItem, theirItem, preferTheirSharedOrder),
);
}
continue;
}
// Local delete (even if disk still has / edited the row).
if (inBase && !inOurs && inTheirs) continue;
// Concurrent disk delete — do not resurrect from a stale local edit.
if (inBase && inOurs && !inTheirs) continue;
}
const backboneSide = preferTheirSharedOrder ? theirs : ours;
const secondarySide = preferTheirSharedOrder ? ours : theirs;
const orderedIds: string[] = [];
const seen = new Set<string>();
for (const snippet of backboneSide) {
const id = snippet.id;
if (!id || !keep.has(id) || !baseIds.has(id) || seen.has(id)) continue;
orderedIds.push(id);
seen.add(id);
}
// Place primary then secondary insertions so each side keeps its anchors.
placeInsertions(orderedIds, backboneSide, baseIds, keep);
placeInsertions(orderedIds, secondarySide, baseIds, keep);
const ordered: Snippet[] = [];
for (const id of orderedIds) {
const kept = keep.get(id);
if (!kept) continue;
ordered.push(kept);
}
// Anchored merge can leave duplicate finite `order` values (e.g. remote
// insertion X at 2000 while local B still has 2000). normalizeVaultOrder
// preserves finite orders, so renumber to match the merged sequence.
return renumberVaultOrder(ordered);
}