Files
NetMesh/domain/workspaceSidePanelTabRemap.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

205 lines
6.6 KiB
TypeScript

/**
* Side-panel chrome is keyed by the top-level work tab id. Merging orphans
* into a workspace (or dissolving a workspace back to orphans) changes that
* id, so open-tool / layout / mount maps must be remapped or the panel looks
* like it was cleared even though the underlying chat still exists.
*/
export type SidePanelTabPromote = {
kind: 'promote';
fromTabIds: readonly string[];
toTabId: string;
preferredFromTabId?: string | null;
};
export type SidePanelTabDemote = {
kind: 'demote';
fromTabId: string;
toTabIds: readonly string[];
preferredToTabId?: string | null;
};
export type SidePanelTabRemap = SidePanelTabPromote | SidePanelTabDemote;
function pickPreferredSourceId(
candidates: readonly string[],
preferredId: string | null | undefined,
hasValue: (tabId: string) => boolean,
): string | null {
if (preferredId && candidates.includes(preferredId) && hasValue(preferredId)) {
return preferredId;
}
for (const tabId of candidates) {
if (tabId && hasValue(tabId)) return tabId;
}
return null;
}
/**
* Copy an open side-panel entry across a tab-id change without deleting the
* source key. Keeping the source lets a later detach restore the orphan tab's
* open state even if demote never runs.
*/
export function remapSidePanelTabMap<T>(
source: ReadonlyMap<string, T>,
remap: SidePanelTabRemap,
): Map<string, T> {
if (remap.kind === 'promote') {
if (!remap.toTabId || source.has(remap.toTabId)) {
return source instanceof Map ? source : new Map(source);
}
const fromId = pickPreferredSourceId(
remap.fromTabIds,
remap.preferredFromTabId,
(tabId) => source.has(tabId),
);
if (!fromId) {
return source instanceof Map ? source : new Map(source);
}
const next = new Map(source);
next.set(remap.toTabId, source.get(fromId) as T);
return next;
}
if (!remap.fromTabId || !source.has(remap.fromTabId)) {
return source instanceof Map ? source : new Map(source);
}
// After merge we keep member keys around; the workspace tab is still the
// live chrome the user was editing. Always overwrite the preferred survivor
// so dissolve does not leave a stale pre-merge member entry in place.
const preferredTo = remap.preferredToTabId
&& remap.toTabIds.includes(remap.preferredToTabId)
? remap.preferredToTabId
: remap.toTabIds.find(Boolean);
if (!preferredTo) {
return source instanceof Map ? source : new Map(source);
}
const next = new Map(source);
next.set(preferredTo, source.get(remap.fromTabId) as T);
return next;
}
/**
* Move ownership-sensitive tab maps (e.g. SFTP host/path) across a tab-id
* change. Unlike {@link remapSidePanelTabMap}, this deletes the source key so
* portals / transfer owners are not duplicated under both ids.
*/
export function moveSidePanelTabMap<T>(
source: ReadonlyMap<string, T>,
remap: SidePanelTabRemap,
): Map<string, T> {
if (remap.kind === 'promote') {
if (!remap.toTabId) {
return source instanceof Map ? source : new Map(source);
}
if (source.has(remap.toTabId)) {
// Destination already owns a mount — drop member clones so only one
// transfer owner remains visible for the workspace tab.
let changed = false;
const next = new Map(source);
for (const fromId of remap.fromTabIds) {
if (!fromId || fromId === remap.toTabId || !next.has(fromId)) continue;
next.delete(fromId);
changed = true;
}
return changed ? next : (source instanceof Map ? source : new Map(source));
}
const fromId = pickPreferredSourceId(
remap.fromTabIds,
remap.preferredFromTabId,
(tabId) => source.has(tabId),
);
if (!fromId) {
return source instanceof Map ? source : new Map(source);
}
const next = new Map(source);
next.set(remap.toTabId, source.get(fromId) as T);
next.delete(fromId);
return next;
}
if (!remap.fromTabId || !source.has(remap.fromTabId)) {
return source instanceof Map ? source : new Map(source);
}
const preferredTo = remap.preferredToTabId
&& remap.toTabIds.includes(remap.preferredToTabId)
? remap.preferredToTabId
: remap.toTabIds.find(Boolean);
if (!preferredTo) {
return source instanceof Map ? source : new Map(source);
}
const next = new Map(source);
next.set(preferredTo, source.get(remap.fromTabId) as T);
next.delete(remap.fromTabId);
return next;
}
/**
* Move a tab-owned marker across the same workspace/session remaps as the
* ownership maps. Markers are not cloned: after a promote or demote, the old
* tab id must not keep an obsolete lifecycle decision alive.
*/
export function moveSidePanelTabSet(
source: ReadonlySet<string>,
remap: SidePanelTabRemap,
options?: { ownerTabIds?: ReadonlySet<string> },
): Set<string> {
const next = new Set(source);
const ownerTabIds = options?.ownerTabIds ?? source;
if (remap.kind === 'promote') {
if (ownerTabIds.has(remap.toTabId)) {
for (const tabId of remap.fromTabIds) next.delete(tabId);
return next;
}
const fromId = pickPreferredSourceId(
remap.fromTabIds,
remap.preferredFromTabId,
(tabId) => ownerTabIds.has(tabId),
);
if (fromId && source.has(fromId)) next.add(remap.toTabId);
if (fromId) next.delete(fromId);
return next;
}
if (!ownerTabIds.has(remap.fromTabId)) return next;
const preferredTo = remap.preferredToTabId
&& remap.toTabIds.includes(remap.preferredToTabId)
? remap.preferredToTabId
: remap.toTabIds.find(Boolean);
if (source.has(remap.fromTabId) && preferredTo) next.add(preferredTo);
next.delete(remap.fromTabId);
return next;
}
export function remapMountedSidePanelTabIds(
mountedTabIds: readonly string[],
remap: SidePanelTabRemap,
): string[] {
if (remap.kind === 'promote') {
if (!remap.toTabId || mountedTabIds.includes(remap.toTabId)) {
return [...mountedTabIds];
}
const fromId = pickPreferredSourceId(
remap.fromTabIds,
remap.preferredFromTabId,
(tabId) => mountedTabIds.includes(tabId),
);
if (!fromId) return [...mountedTabIds];
return [...mountedTabIds, remap.toTabId];
}
if (!remap.fromTabId || !mountedTabIds.includes(remap.fromTabId)) {
return [...mountedTabIds];
}
const preferredTo = remap.preferredToTabId
&& remap.toTabIds.includes(remap.preferredToTabId)
? remap.preferredToTabId
: remap.toTabIds.find((tabId) => tabId && !mountedTabIds.includes(tabId))
?? remap.toTabIds[0];
if (!preferredTo || mountedTabIds.includes(preferredTo)) {
return [...mountedTabIds];
}
return [...mountedTabIds, preferredTo];
}