[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:
204
domain/workspaceSidePanelTabRemap.ts
Normal file
204
domain/workspaceSidePanelTabRemap.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
Reference in New Issue
Block a user