[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
311
domain/snippetSelection.ts
Normal file
311
domain/snippetSelection.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user