[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:
290
domain/toolbarItemLayout.ts
Normal file
290
domain/toolbarItemLayout.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Shared layout model for dense toolbar regions.
|
||||
*
|
||||
* Placement:
|
||||
* - show: render inline on the toolbar
|
||||
* - collapse: render inside the ⋮ overflow menu
|
||||
* - hide: do not render
|
||||
*/
|
||||
|
||||
export type ToolbarItemPlacement = 'show' | 'collapse' | 'hide';
|
||||
|
||||
export const TOOLBAR_ITEM_PLACEMENTS: readonly ToolbarItemPlacement[] = [
|
||||
'show',
|
||||
'collapse',
|
||||
'hide',
|
||||
] as const;
|
||||
|
||||
export type ToolbarItemLayout = {
|
||||
order: string[];
|
||||
placement: Record<string, ToolbarItemPlacement>;
|
||||
};
|
||||
|
||||
export type ToolbarItemLayoutDefaults = {
|
||||
/** Canonical item ids in default visual order. */
|
||||
order: readonly string[];
|
||||
/** Default placement per id. Missing ids default to "show". */
|
||||
placement?: Readonly<Partial<Record<string, ToolbarItemPlacement>>>;
|
||||
/**
|
||||
* Ids that cannot be hidden (or further restricted). They always stay
|
||||
* available; default placement is "show" unless defaults.placement says
|
||||
* otherwise for collapse-only locks.
|
||||
*/
|
||||
lockedIds?: readonly string[];
|
||||
/**
|
||||
* When true (default), at least one available item must remain
|
||||
* show or collapse so the region never becomes empty.
|
||||
*/
|
||||
requireReachable?: boolean;
|
||||
};
|
||||
|
||||
export type ToolbarItemPartition = {
|
||||
shown: string[];
|
||||
collapsed: string[];
|
||||
hidden: string[];
|
||||
};
|
||||
|
||||
export function isToolbarItemPlacement(value: unknown): value is ToolbarItemPlacement {
|
||||
return value === 'show' || value === 'collapse' || value === 'hide';
|
||||
}
|
||||
|
||||
function defaultPlacementFor(
|
||||
id: string,
|
||||
defaults: ToolbarItemLayoutDefaults,
|
||||
): ToolbarItemPlacement {
|
||||
const locked = defaults.lockedIds?.includes(id) ?? false;
|
||||
const configured = defaults.placement?.[id];
|
||||
if (configured && isToolbarItemPlacement(configured)) {
|
||||
if (locked && configured === 'hide') return 'show';
|
||||
return configured;
|
||||
}
|
||||
return 'show';
|
||||
}
|
||||
|
||||
function buildDefaultLayout(defaults: ToolbarItemLayoutDefaults): ToolbarItemLayout {
|
||||
const order = [...defaults.order];
|
||||
const placement: Record<string, ToolbarItemPlacement> = {};
|
||||
for (const id of order) {
|
||||
placement[id] = defaultPlacementFor(id, defaults);
|
||||
}
|
||||
return { order, placement };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts:
|
||||
* - full { order, placement } objects
|
||||
* - legacy order-only arrays (all items default placement)
|
||||
* - null / garbage → defaults
|
||||
*/
|
||||
export function normalizeToolbarItemLayout(
|
||||
value: unknown,
|
||||
defaults: ToolbarItemLayoutDefaults,
|
||||
): ToolbarItemLayout {
|
||||
const fallback = buildDefaultLayout(defaults);
|
||||
const known = new Set(defaults.order);
|
||||
if (known.size === 0) return fallback;
|
||||
|
||||
let rawOrder: unknown[] | null = null;
|
||||
let rawPlacement: Record<string, unknown> | null = null;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawOrder = value;
|
||||
} else if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (Array.isArray(obj.order)) rawOrder = obj.order;
|
||||
if (obj.placement && typeof obj.placement === 'object' && !Array.isArray(obj.placement)) {
|
||||
rawPlacement = obj.placement as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawOrder) return fallback;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const order: string[] = [];
|
||||
for (const candidate of rawOrder) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
if (!known.has(candidate) || seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
order.push(candidate);
|
||||
}
|
||||
for (const id of defaults.order) {
|
||||
if (!seen.has(id)) order.push(id);
|
||||
}
|
||||
|
||||
const placement: Record<string, ToolbarItemPlacement> = {};
|
||||
for (const id of order) {
|
||||
const fromRaw = rawPlacement?.[id];
|
||||
if (isToolbarItemPlacement(fromRaw)) {
|
||||
placement[id] = fromRaw;
|
||||
} else {
|
||||
placement[id] = defaultPlacementFor(id, defaults);
|
||||
}
|
||||
if ((defaults.lockedIds?.includes(id) ?? false) && placement[id] === 'hide') {
|
||||
placement[id] = defaultPlacementFor(id, defaults) === 'collapse' ? 'collapse' : 'show';
|
||||
}
|
||||
}
|
||||
|
||||
return ensureReachable({ order, placement }, defaults, known);
|
||||
}
|
||||
|
||||
function toIdSet(ids?: readonly string[] | ReadonlySet<string> | null): Set<string> | null {
|
||||
if (ids == null) return null;
|
||||
return ids instanceof Set ? ids : new Set(ids);
|
||||
}
|
||||
|
||||
function ensureReachable(
|
||||
layout: ToolbarItemLayout,
|
||||
defaults: ToolbarItemLayoutDefaults,
|
||||
known: Set<string>,
|
||||
/**
|
||||
* When provided, reachability is computed only over currently available ids
|
||||
* (e.g. session-specific toolbar actions). Unavailable "show" entries must
|
||||
* not keep an empty visible toolbar looking "reachable".
|
||||
*/
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
): ToolbarItemLayout {
|
||||
if (defaults.requireReachable === false) return layout;
|
||||
|
||||
const available = toIdSet(availableIds);
|
||||
|
||||
const isCandidate = (id: string): boolean => {
|
||||
if (!known.has(id)) return false;
|
||||
if (available && !available.has(id)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const hasReachable = layout.order.some((id) => {
|
||||
if (!isCandidate(id)) return false;
|
||||
const p = layout.placement[id] ?? 'show';
|
||||
return p === 'show' || p === 'collapse';
|
||||
});
|
||||
if (hasReachable) return layout;
|
||||
|
||||
// Prefer restoring the first always-present (available) non-locked default.
|
||||
const restoreId =
|
||||
defaults.order.find(
|
||||
(id) => isCandidate(id) && !(defaults.lockedIds?.includes(id) ?? false),
|
||||
) ??
|
||||
defaults.order.find((id) => isCandidate(id)) ??
|
||||
defaults.order[0];
|
||||
if (!restoreId) return layout;
|
||||
return {
|
||||
order: layout.order,
|
||||
placement: {
|
||||
...layout.placement,
|
||||
[restoreId]: 'show',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Split layout into shown / collapsed / hidden, optionally filtering by currently available ids. */
|
||||
export function partitionToolbarItems(
|
||||
layout: ToolbarItemLayout,
|
||||
availableIds?: readonly string[] | ReadonlySet<string>,
|
||||
): ToolbarItemPartition {
|
||||
const available =
|
||||
availableIds == null
|
||||
? null
|
||||
: availableIds instanceof Set
|
||||
? availableIds
|
||||
: new Set(availableIds);
|
||||
|
||||
const shown: string[] = [];
|
||||
const collapsed: string[] = [];
|
||||
const hidden: string[] = [];
|
||||
|
||||
for (const id of layout.order) {
|
||||
if (available && !available.has(id)) continue;
|
||||
const placement = layout.placement[id] ?? 'show';
|
||||
if (placement === 'show') shown.push(id);
|
||||
else if (placement === 'collapse') collapsed.push(id);
|
||||
else hidden.push(id);
|
||||
}
|
||||
|
||||
return { shown, collapsed, hidden };
|
||||
}
|
||||
|
||||
export function setToolbarItemPlacement(
|
||||
layout: ToolbarItemLayout,
|
||||
id: string,
|
||||
placement: ToolbarItemPlacement,
|
||||
defaults: ToolbarItemLayoutDefaults,
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
): ToolbarItemLayout {
|
||||
if (!layout.order.includes(id) && !defaults.order.includes(id)) return layout;
|
||||
if ((defaults.lockedIds?.includes(id) ?? false) && placement === 'hide') {
|
||||
return layout;
|
||||
}
|
||||
|
||||
const next: ToolbarItemLayout = {
|
||||
order: layout.order.includes(id) ? layout.order : [...layout.order, id],
|
||||
placement: {
|
||||
...layout.placement,
|
||||
[id]: placement,
|
||||
},
|
||||
};
|
||||
return ensureReachable(next, defaults, new Set(defaults.order), availableIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder `draggedId` relative to `targetId`.
|
||||
* Operates on the full order array (shown + collapsed + hidden).
|
||||
*/
|
||||
export function reorderToolbarItems(
|
||||
layout: ToolbarItemLayout,
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
placement: 'before' | 'after' = 'before',
|
||||
): ToolbarItemLayout {
|
||||
if (draggedId === targetId) return layout;
|
||||
const order = layout.order;
|
||||
if (!order.includes(draggedId) || !order.includes(targetId)) return layout;
|
||||
|
||||
const withoutDragged = order.filter((id) => id !== draggedId);
|
||||
const targetIndex = withoutDragged.indexOf(targetId);
|
||||
if (targetIndex === -1) return layout;
|
||||
const insertionIndex = placement === 'after' ? targetIndex + 1 : targetIndex;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
order: [
|
||||
...withoutDragged.slice(0, insertionIndex),
|
||||
draggedId,
|
||||
...withoutDragged.slice(insertionIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item one step earlier/later in order.
|
||||
* When `availableIds` is set, skip unavailable neighbors so the UI reorder
|
||||
* matches the filtered customize list.
|
||||
*/
|
||||
export function moveToolbarItem(
|
||||
layout: ToolbarItemLayout,
|
||||
id: string,
|
||||
direction: 'earlier' | 'later',
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
): ToolbarItemLayout {
|
||||
const available = toIdSet(availableIds);
|
||||
const sequence = available
|
||||
? layout.order.filter((candidate) => available.has(candidate))
|
||||
: layout.order;
|
||||
const index = sequence.indexOf(id);
|
||||
if (index === -1) return layout;
|
||||
const swapWith = direction === 'earlier' ? index - 1 : index + 1;
|
||||
if (swapWith < 0 || swapWith >= sequence.length) return layout;
|
||||
|
||||
const a = sequence[index];
|
||||
const b = sequence[swapWith];
|
||||
const order = layout.order.map((candidate) => {
|
||||
if (candidate === a) return b;
|
||||
if (candidate === b) return a;
|
||||
return candidate;
|
||||
});
|
||||
return { ...layout, order };
|
||||
}
|
||||
|
||||
export function resetToolbarItemLayout(defaults: ToolbarItemLayoutDefaults): ToolbarItemLayout {
|
||||
return buildDefaultLayout(defaults);
|
||||
}
|
||||
Reference in New Issue
Block a user