import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminalTracker } from '@jupyterlab/terminal'; import { LabIcon } from '@jupyterlab/ui-components'; import { DisposableDelegate, IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import { COLOURS, setWidgetTabColour } from './colours'; import { applyDynamicStyle, asDividerContrast, buildDynamicCss, mergePalette } from './dynamicStyle'; import { fetchTerminalFingerprints, TerminalFingerprints } from './fingerprints'; import { IStoredColour, normaliseStoredTabId, parseStoredColour, stableTabId, staleTerminalIds, terminalSessionName } from './identity'; import { IColourChoice, IColourfulTabs } from './tokens'; export { IColourChoice, IColourfulTabs } from './tokens'; /** * Create a LabIcon with a coloured square SVG * Uses CSS classes for theme-aware colours via CSS variables */ function createColourIcon(id: string): LabIcon { const svgStr = ` `; return new LabIcon({ name: `colourful-tab:icon-${id}`, svgstr: svgStr }); } /** * LocalStorage key for persisting tab colours */ const STORAGE_KEY = 'jupyterlab-colourful-tab-colours'; /** * Storage for tab colours (persists across refreshes via localStorage) * Maps a stable tab id to its stored colour */ const tabColours: Map = new Map(); /** * Widget ids whose tab colour another extension owns, counted per widget (see * `IColourfulTabs`'s `claim`). A claimed tab is never persisted here, because * its owner persists the choice against a durable identity instead, and a pick * or a clear on one writes nothing at all: the stored entry is the file's (or * terminal's) colour, shared by every view of it in every window, so it is * left for the other views. The acted view is not repainted from it again for * the session (see `suppressedClaimedViews`). A claimed tab is still repainted * from a stored colour, but only from a fingerprint-verified entry. * * Counted rather than a set because two holders can own the same tab at once - * a panel rebuilt over a still-running terminal overlaps its predecessor - and * the first to dispose must not release the second's claim. `IColourfulTabs` is * public API, so the second holder is not hypothetical either: without the * count, one extension's dispose silently hands another extension's tab back to * this store, and neither side can see why. */ const tabClaims: Map = new Map(); /** * Claimed tab widgets the user has picked or cleared from the menu. The stored * entry stays - it is the file's colour, which its other views rely on, in this * window or another - so the owner-blank restore must not repaint it over the * choice the user just made on this view. Session-scoped: a claimed choice is * never persisted, and neither is this. A later unclaimed pick on that view * rewrites the stored entry and releases the mark, because the entry the mark * held back is no longer the one that could repaint over the choice. */ const suppressedClaimedViews = new Set(); /** * Live terminal fingerprints, or null while the server has not answered - "no * terminals" and "cannot tell" are different states and the prune needs both. */ let terminalFingerprints: TerminalFingerprints | null = null; /** * Load tab colours from localStorage, replacing the in-memory map. Runs at * activation and before every write, because other windows on the same lab * write the same key. */ function loadTabColours(): void { tabColours.clear(); try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const data = JSON.parse(stored) as Record; Object.entries(data).forEach(([tabId, value]) => { // Validated rather than cast: everything downstream reads `colour` and // `fp` off the entry, so one value of another shape throws on every // prune and every repaint (see `parseStoredColour`) const entry = parseStoredColour(value, COLOURS.length); if (entry) { // A legacy key beginning `path:` was a raw file path; under the // sentinel it is the escaped key of that same file (DEF-IDENT-22). // Rewritten in memory on read, written back by the next save tabColours.set(normaliseStoredTabId(tabId), entry); } }); } } catch (e) { console.warn('Colourful Tab: Failed to load colours from storage', e); } } /** * Save tab colours to localStorage */ function saveTabColours(): void { try { const data: Record = {}; tabColours.forEach((entry, tabId) => { // Only a fingerprinted entry needs the object shape. A file path is a // real identity and carries none, so those entries stay the bare number // the format has always used data[tabId] = entry.fp ? entry : entry.colour; }); localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { console.warn('Colourful Tab: Failed to save colours to storage', e); } } /** * Currently right-clicked tab element (set by contextmenu event) */ let currentTabElement: HTMLElement | null = null; /** * Reference to terminal tracker for getting session names */ let terminalTracker: ITerminalTracker | null = null; /** * Build a map from widget ID to terminal session name */ function getTerminalSessionMap(): Map { const widgetToSession = new Map(); if (terminalTracker) { terminalTracker.forEach(widget => { const session = widget.content?.session; if (session?.model?.name) { widgetToSession.set(widget.id, session.model.name); } }); } return widgetToSession; } /** * Apply colour class to a tab element */ function applyTabColour(tabElement: HTMLElement, colourIndex: number): void { // Remove all existing colour classes COLOURS.forEach(c => tabElement.classList.remove(c.cssClass)); // Apply new colour class if (colourIndex >= 0 && colourIndex < COLOURS.length) { tabElement.classList.add(COLOURS[colourIndex].cssClass); } } /** * Clear colour from a tab element */ function clearTabColour(tabElement: HTMLElement): void { COLOURS.forEach(c => tabElement.classList.remove(c.cssClass)); } /** * Get the identifier a tab's colour is PERSISTED under, or null when the tab * has no stable identity (see `stableTabId` for why a widget id is not one). */ function getStableTabId(tabElement: HTMLElement): string | null { const widgetId = tabElement.dataset.id; const sessionName = widgetId ? (getTerminalSessionMap().get(widgetId) ?? null) : null; return stableTabId(tabElement.getAttribute('title'), sessionName); } /** * True when another extension owns this tab's colour (see `claim`). */ function isClaimedTab(tabElement: HTMLElement): boolean { const widgetId = tabElement.dataset.id; return !!widgetId && tabClaims.has(widgetId); } /** * Drop stored colours whose terminal incarnation is gone, so a recycled * terminal name starts clear (DEF-6). Closing a terminal's TAB while its * session keeps running leaves the fingerprint unchanged, so that colour * correctly survives. */ function pruneStaleTerminalColours(liveNames: string[]): void { loadTabColours(); const stale = staleTerminalIds(tabColours, terminalFingerprints, liveNames); if (stale.length === 0) { return; } const currentTabs = getAllTabsByStableId(); stale.forEach(id => { // Deleting the entry does not unpaint the tab. The class was applied when // the colour was set and nothing else removes it, so the dead terminal's // colour would keep showing on the recycled name for the rest of the // session - the visible symptom the prune exists to remove. Only the // colour THIS entry painted is cleared: a tab showing another colour is // showing it for another reason (the `setColour` API). A CLAIMED tab is // exempt, because there the painter is the owner and not this entry: the // legacy entry an upgrader carries and the colour the owner painted are // routinely the same palette index, so stripping the class here removes the // owner's colour and nothing repaints it until the owner's next pass. const entry = tabColours.get(id); const cssClass = entry ? COLOURS[entry.colour]?.cssClass : undefined; (currentTabs.get(id) ?? []).forEach(tabElement => { if ( cssClass && !isClaimedTab(tabElement) && tabElement.classList.contains(cssClass) ) { clearTabColour(tabElement); } }); tabColours.delete(id); }); saveTabColours(); } /** * Find all current tabs with their stable identifiers. One file open in several * views gives several tabs under one id. */ function getAllTabsByStableId(): Map { const idToTabs = new Map(); const tabs = document.querySelectorAll('#jp-main-dock-panel .lm-TabBar-tab'); tabs.forEach(tab => { const tabElement = tab as HTMLElement; const stableId = getStableTabId(tabElement); if (stableId) { idToTabs.set(stableId, [...(idToTabs.get(stableId) ?? []), tabElement]); } }); return idToTabs; } /** * True when the stored entry can be tied to the terminal incarnation currently * answering to its name. * * Only a fingerprint match establishes that. With the route unavailable the * prune falls back to the running list, which structurally cannot see a * recycled name - the name IS running - so a dead terminal's entry survives * every prune, and an entry with no fingerprint or a fingerprint that no longer * matches is exactly what that leaves behind. * * A file path is a real identity and needs no fingerprint, so it answers true * by identity. The paint pass still gives the owner's own colour precedence: * it skips any tab that already carries a colour class before this is asked. */ function isVerifiedEntry(storedId: string, entry: IStoredColour): boolean { const name = terminalSessionName(storedId); if (name === null) { return true; } return entry.fp !== undefined && entry.fp === terminalFingerprints?.[name]; } /** * Refresh all tab colours (useful after DOM changes). * * Only paints a stored menu colour onto a tab that currently has NO colour * class at all. A tab already showing a colour may be rendering the public * `setColour` API's colour (carried in `title.className`), and overwriting it * here starts an endless fight: this map repaints on every mutation while * Lumino re-renders the title colour on every tab update - the tab visibly * flickers between the two colours (DEF-5). Stale map entries (terminal * session names are reused by the server) made this hit tabs the user never * coloured. * * A claimed tab carrying no colour class is a tab whose owner has painted * nothing - the ordinary case for an owner that holds no colour for what runs * there - so the user's stored colour still shows there, but only from a * FINGERPRINT-VERIFIED entry and only while the user has not picked or cleared * that view from the menu this session, unless a later unclaimed pick on that * view rewrote the entry and released the mark (see `suppressedClaimedViews`). * An unverified entry is what a recycled name leaves behind, and painting one onto * an assistant's tab is DEF-6 returning by its visual half. The unclaimed path * keeps the one guard it has always had: narrowing it there would drop colours * set before the fingerprint existed, and every colour at all on a server * without the route. */ function refreshAllTabColours(): void { const currentTabs = getAllTabsByStableId(); tabColours.forEach((entry, storedId) => { (currentTabs.get(storedId) ?? []).forEach(tabElement => { if (COLOURS.some(c => tabElement.classList.contains(c.cssClass))) { return; } if (suppressedClaimedViews.has(tabElement.dataset.id ?? '')) { return; } if (isClaimedTab(tabElement) && !isVerifiedEntry(storedId, entry)) { return; } applyTabColour(tabElement, entry.colour); }); }); } /** * Drop the stored menu colour for a widget's tab when the public API takes * ownership of it. Without this, a stale localStorage entry under the same * stable id (terminal session names are reused) keeps fighting the API colour * and resurfaces whenever the API colour is cleared (DEF-5). `claim` does NOT * drop the entry: it suppresses persistence alone, and the entry is what the * tab shows while its owner holds no colour for it and its fingerprint still * matches. Returns silently when the tab is not in the DOM, since there is then * nothing to resolve a stable id from. */ function releaseMenuColour(widgetId: string): void { // The shell sets a tab's `data-id` to the widget id const tabElement = document.querySelector( `#jp-main-dock-panel .lm-TabBar-tab[data-id="${CSS.escape(widgetId)}"]` ) as HTMLElement | null; if (!tabElement) { return; } const stableId = getStableTabId(tabElement); loadTabColours(); if (stableId && tabColours.has(stableId)) { tabColours.delete(stableId); saveTabColours(); } } /** * Debounce timer for refresh */ let refreshTimer: ReturnType | null = null; /** * Debounced refresh to avoid excessive DOM operations */ function debouncedRefresh(): void { if (refreshTimer) { clearTimeout(refreshTimer); } refreshTimer = setTimeout(() => { refreshAllTabColours(); applyToolbarColour(); refreshTimer = null; }, 50); } /** * Apply colour to each panel's toolbar based on that panel's current tab. * * Reads the colour from the tab's live class list rather than the menu-driven * `tabColours` map, so the toolbar follows colours set either way: the * right-click menu and the public `setColour` API (used by consumers like * jupyterlab_claude_code_extension) both land as a `jp-colourful-tab-*` class * on the tab element (DEF-3). Split layouts have one current tab per tab bar, * so each tab is paired with its OWN widget's toolbar via the shared widget id * (the shell sets tab `data-id` to the widget id, and Lumino's `Widget.id` is * the widget node's DOM id) - a colour never leaks onto another panel's * toolbar, and widgets without a toolbar (e.g. terminals) colour none (DEF-4). */ function applyToolbarColour(): void { // Clear all toolbar colours first const toolbars = document.querySelectorAll('jp-toolbar'); toolbars.forEach(toolbar => { COLOURS.forEach(c => toolbar.classList.remove(c.cssClass)); }); // Colour each panel's toolbar from its own current tab const currentTabs = document.querySelectorAll( '#jp-main-dock-panel .lm-TabBar-tab.lm-mod-current' ); currentTabs.forEach(tab => { const tabElement = tab as HTMLElement; const colour = COLOURS.find(c => tabElement.classList.contains(c.cssClass)); const widgetId = tabElement.dataset.id; if (!colour || !widgetId) { return; } const toolbar = document .getElementById(widgetId) ?.querySelector('jp-toolbar'); if (toolbar) { toolbar.classList.add(colour.cssClass); } }); } /** * The public API handed to consumers through `IColourfulTabs`. * * A class rather than the object literal it replaces because `colourChanged` * has to name the API itself as its Lumino sender, which a literal cannot do * while it is still being built. */ class ColourfulTabs implements IColourfulTabs { /** Every menu pick and every clear. This is the ONLY way a consumer learns of * a choice: a stored value is a leftover rather than evidence of a click, * and a pick on a claimed tab is never stored at all. */ get colourChanged(): ISignal { return this._colourChanged; } /** Tint a widget's dock tab via its Lumino title, which rides tab re-renders. * The tint itself is separate from the menu-driven, localStorage-backed * per-tab colours; what it does to the STORED one depends on the claim, as * below. */ setColour(widget: Widget, colourId: string | null): void { if (colourId && widget && !widget.isDisposed && !tabClaims.has(widget.id)) { // The API takes ownership of this tab's colour - drop any stored menu // colour so the two paths stop fighting over the tab (DEF-5). A CLAIMED // tab is exempt: an owner paints the tab it claimed through this very // method, and a verified stored colour is what that tab shows whenever // the owner holds none for it - deleting here would erase the user's // earlier choice on the owner's first paint. releaseMenuColour(widget.id); } setWidgetTabColour(widget, colourId); } /** * Record that another extension persists this tab's colour, until the * returned disposable is disposed. * * The claim suppresses PERSISTENCE: a menu pick on a claimed tab still paints * the tab and is still announced through `colourChanged`, but is not written * here, because the owner writes it against a durable identity instead. A * pick or a clear on a claimed tab writes nothing at all - the stored entry * is the file's (or terminal's) colour, shared by every view of it in every * window, so it is left in place for the other views. The acted view is not * repainted from it again for the session (see `suppressedClaimedViews`), so * the user's choice is not undone before the owner has filed it. * * A stored colour is still restored onto a claimed tab carrying no colour * class, which means its owner has painted nothing and the user has not * picked or cleared that view this session, but only when the entry's * fingerprint matches the terminal now behind its name (a path key needs no * fingerprint). The owner's colour therefore wins whenever it has one, a * verified earlier choice shows when it does not, and nothing is transferred * or copied. * * Claims are counted, so a second holder keeps the tab owned after the first * releases. `DisposableDelegate` runs its callback at most once, so disposing * one holder twice releases one claim. */ claim(widget: Widget): IDisposable { const widgetId = widget.id; tabClaims.set(widgetId, (tabClaims.get(widgetId) ?? 0) + 1); return new DisposableDelegate(() => { const held = tabClaims.get(widgetId) ?? 0; if (held > 1) { tabClaims.set(widgetId, held - 1); } else { tabClaims.delete(widgetId); } }); } /** Announce a menu pick, or a clear with a null colour id. */ emitChoice(widgetId: string, colourId: string | null): void { this._colourChanged.emit({ widgetId, colourId }); } private _colourChanged = new Signal(this); } /** * Initialization data for the jupyterlab_colourful_tab_extension extension. */ const plugin: JupyterFrontEndPlugin = { id: 'jupyterlab_colourful_tab_extension:plugin', description: 'JupyterLab extension that makes tabs coloured using pastel colours to help identify them when many are open', autoStart: true, provides: IColourfulTabs, optional: [ITerminalTracker, ISettingRegistry], activate: ( app: JupyterFrontEnd, tracker: ITerminalTracker | null, settingRegistry: ISettingRegistry | null ) => { console.log( 'JupyterLab extension jupyterlab_colourful_tab_extension is activated!' ); // Store terminal tracker reference for session name lookups terminalTracker = tracker; // Load persisted colours from localStorage loadTabColours(); // Age out colours whose terminal incarnation is gone, so a recycled // terminal name starts clear (DEF-6). The fingerprints are the authority // here: the running list cannot see a recycled name, because the name is // running - it is the substitution behind it that the list cannot report. void fetchTerminalFingerprints().then(fingerprints => { terminalFingerprints = fingerprints; // Without fingerprints the running list is the only evidence of death, // and at activation the terminal manager has not necessarily fetched it - // pruning against an empty list here would wipe every terminal colour, // which is the 1.1.3 regression. With fingerprints the map alone decides, // so the list is not consulted. if (fingerprints) { pruneStaleTerminalColours([]); } }); // And again whenever the running set changes. Driven by the server's own // running list rather than by tab restoration - the 1.1.3 regression came // from a cleanup that ran before tab identifiers had resolved. app.serviceManager.terminals.runningChanged.connect((_, models) => { const liveNames = models.map(m => m.name); void fetchTerminalFingerprints().then(fingerprints => { terminalFingerprints = fingerprints; pruneStaleTerminalColours(liveNames); }); }); // Settings drive the palette and the same-colour divider by regenerating // an injected