// Short chip labels for the panel: three characters by default, settable up. // // A chip per session only pays off if the label stays put. Two rules do that: // labels are assigned oldest-session-first, so a session starting now takes the // suffixed variant rather than pushing one off the label already on screen; and // a label, once given, belongs to that session until it ends -- even after the // session it was disambiguated against has closed. A label that moves under // your hand is worse than one carrying a digit that no longer looks necessary. // // Imports nothing, so it runs under plain node or gjs. // Both entry points take a width, and both default to this: the module is // imported by tests and by the indicator alike, and a caller that forgets the // setting should get the documented default rather than a stray one. const DEFAULT_WIDTH = 3; /** Widths arrive from GSettings and from tests. One character is the floor: * at zero every label would be empty and the collision loop would not end. */ function usable(width) { const n = Math.trunc(Number(width)); return Number.isFinite(n) && n >= 1 ? n : DEFAULT_WIDTH; } /** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */ function segments(name) { // Unicode-aware: splitting on [^a-zA-Z0-9] makes every Cyrillic letter a // separator, so "проект" reduces to nothing and every non-Latin project // ends up sharing the label "?". return name .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1 $2') .split(/[^\p{L}\p{N}]+/u) .filter(Boolean); } /** Up to `width` characters for a project name. * * Initials for multi-segment names, first letters for single words. Initials * matter more than they look: a plain prefix collapses "dev-skills" and * "dev-conventions" onto the same "dev", which is the exact case this has to * keep apart. A wider label takes more initials, not a longer prefix, for the * same reason. */ export function abbreviate(name, width = DEFAULT_WIDTH) { const parts = segments(String(name ?? '')); if (!parts.length) return '?'; const raw = parts.length > 1 ? parts.map(p => p[0]).join('') : parts[0]; return raw.slice(0, usable(width)).toLowerCase(); } /** Assign a label to every session, reusing the ones already handed out. * * `previous` is the mapping from the last run; pass the returned map back in. * Sessions absent from `sessions` drop out, which frees their label for reuse. * * `width` applies to labels handed out now. Kept labels are kept whatever * width they were cut at -- stickiness outranks it, and the caller that * changes the width is the one that has to drop the old map. */ export function assignChips(sessions, previous = new Map(), width = DEFAULT_WIDTH) { const max = usable(width); const labels = new Map(); const taken = new Set(); for (const session of sessions) { const kept = previous.get(session.sessionId); if (kept !== undefined) { labels.set(session.sessionId, kept); taken.add(kept); } } const fresh = sessions .filter(s => !labels.has(s.sessionId)) .sort((a, b) => (a.since || 0) - (b.since || 0)); for (const session of fresh) { const base = abbreviate(session.base, max); let label = base; // Digits eat into the base rather than extending past the width, so // every chip stays the same size and the row does not ripple. for (let n = 2; taken.has(label); n++) { const suffix = String(n); label = base.slice(0, Math.max(1, max - suffix.length)) + suffix; } labels.set(session.sessionId, label); taken.add(label); } return labels; }