diff --git a/README.md b/README.md index 2327e0d..7a8f97b 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,43 @@ Code ждёт меня прямо сейчас?** текстом `«Claude needs your permission»`. Различить их можно было бы только через `PreToolUse` — ценой записи файла на каждый вызов инструмента. -Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель -живёт в центральном боксе рядом с часами, и без предела достаточно открытых -сессий сдвинули бы часы с центра. +Чипов показывается три (настраивается), остальные сворачиваются в `+N`. По +умолчанию ряд живёт в центральном боксе сразу справа от часов, и без предела +достаточно открытых сессий сдвинули бы часы с центра. Чипы идут по срочности, а внутри одного состояния первой стоит **самая давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в состоянии показывается только у первого чипа: пять счётчиков рядом — это ряд чисел, а не ответ на вопрос. +## Место в панели + +Бокс (левый, центральный, правый) и позиция внутри бокса задаются в настройках и +применяются сразу, без релогина. Индекс 0 — первым в боксе; в центральном 1 — +сразу справа от часов, единственного его обитателя по умолчанию. Индекс больше +числа элементов кладёт ряд в конец, так что «10» — это способ сказать «последним». + +Перестановка **пересоздаёт** индикатор, а не двигает актор: `addToStatusArea` — +это и есть регистрация под uuid, а публичного вызова «перенести в другой бокс» в +shell нет; всё остальное лезет в приватные боксы `Main.panel`. Стоит это одного +перечитывания нескольких маленьких файлов состояния, и только когда настройку +трогают. + +Здесь же вскрылась давняя утечка. `PanelMenu.ButtonBox` в своём `_init` делает +`this.connect('destroy', this._onDestroy.bind(this))`, а его `_onDestroy` +уничтожает `container` — тот самый `St.Bin`, который лежит в боксе панели. +Имя разрешается по цепочке прототипов, поэтому наш метод с тем же именем **молча +подменял** шелловский, и контейнер оставался в панели после каждого выключения +расширения. Наш обработчик теперь называется `_teardown`. Измерено: до +переименования центральный бокс рос на один пустой актор с каждой перестановкой +(2 → 3 → 4 → 5), после — стабильно 2. + ## Метки чипов -Имя проекта сжимается до трёх знаков: если в имени несколько сегментов -(`-`, `_`, camelCase) — инициалы, иначе первые буквы. +Имя проекта сжимается до трёх знаков — в настройках можно больше, но не меньше: +три хватает, чтобы развести инициалы, и достаточно узко, чтобы ряд не толкал +часы. Если в имени несколько сегментов (`-`, `_`, camelCase) — инициалы, иначе +первые буквы. | Проект | Чип | |---|---| @@ -80,6 +104,14 @@ Code ждёт меня прямо сейчас?** закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под рукой, хуже метки с цифрой, которая уже не выглядит нужной. +Более широкая метка берёт **больше инициалов**, а не более длинный префикс — по +той же причине. Поэтому `dev-skills` останется `ds` при любой ширине, а +`claude-code-gnome-extension` при четырёх знаках станет `ccge`. + +Ширина — единственное, что сбрасывает закреплённые метки: перерисовка, о которой +попросили сами, это не метка, уехавшая под рукой. Смена ширины перелейблит все +сессии разом. + Сокращение отключается в настройках — тогда в чипах полные имена проектов. В меню строка начинается с той же метки, чтобы соответствие «`ds` — это dev-skills» читалось, а не угадывалось. diff --git a/extension.js b/extension.js index 74c0b26..af2a0b8 100644 --- a/extension.js +++ b/extension.js @@ -5,16 +5,50 @@ import { ClaudeStatusIndicator } from './lib/indicator.js'; export default class ClaudeCodeStatusExtension extends Extension { enable() { - this._indicator = new ClaudeStatusIndicator(this); - // Centre box, index 1: immediately right of the clock, which is the - // centre box's only occupant by default. The status area on the right - // is where you look for the system's own state; sessions belong next - // to the thing you already glance at. - Main.panel.addToStatusArea(this.uuid, this._indicator, 1, 'center'); + this._settings = this.getSettings(); + this._place(); + // Placement is applied by rebuilding rather than by moving the actor. + // addToStatusArea is what registers the indicator under this uuid and + // there is no documented call to move one between panel boxes; the + // alternatives all reach into Main.panel's private boxes. Rebuilding + // costs one re-read of a handful of small state files, and only when + // the setting is touched. + this._placementId = this._settings.connect('changed::panel-box', + () => this._replace()); + this._positionId = this._settings.connect('changed::panel-position', + () => this._replace()); } disable() { + for (const id of [this._placementId, this._positionId]) { + if (id) + this._settings.disconnect(id); + } + this._placementId = 0; + this._positionId = 0; this._indicator?.destroy(); this._indicator = null; + this._settings = null; + } + + _place() { + this._indicator = new ClaudeStatusIndicator(this); + // Centre box, index 1 by default: immediately right of the clock, + // which is the centre box's only occupant. The status area on the + // right is where you look for the system's own state; sessions belong + // next to the thing you already glance at. An index past the end of + // the box lands at the end, so a large one is a way of saying "last". + Main.panel.addToStatusArea(this.uuid, this._indicator, + this._settings.get_int('panel-position'), + this._settings.get_string('panel-box')); + } + + _replace() { + // The indicator's own destroy handler is what unregisters it from the + // status area, so this must happen before the next addToStatusArea -- + // that call throws on a uuid that is still registered. + this._indicator?.destroy(); + this._indicator = null; + this._place(); } } diff --git a/lib/abbrev.js b/lib/abbrev.js index 5e68077..6c7c68f 100644 --- a/lib/abbrev.js +++ b/lib/abbrev.js @@ -1,4 +1,4 @@ -// Three-character chip labels for the panel. +// 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 @@ -9,7 +9,17 @@ // // Imports nothing, so it runs under plain node or gjs. -const MAX = 3; +// 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) { @@ -22,29 +32,35 @@ function segments(name) { .filter(Boolean); } -/** Up to three characters for a project name. +/** 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. + * keep apart. A wider label takes more initials, not a longer prefix, for the + * same reason. */ -export function abbreviate(name) { +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, MAX).toLowerCase(); + 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()) { +export function assignChips(sessions, previous = new Map(), width = DEFAULT_WIDTH) { + const max = usable(width); const labels = new Map(); const taken = new Set(); @@ -61,13 +77,13 @@ export function assignChips(sessions, previous = new Map()) { .sort((a, b) => (a.since || 0) - (b.since || 0)); for (const session of fresh) { - const base = abbreviate(session.base); + const base = abbreviate(session.base, max); let label = base; - // Digits eat into the base rather than extending past three characters, - // so every chip stays the same width and the row does not ripple. + // 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; + label = base.slice(0, Math.max(1, max - suffix.length)) + suffix; } labels.set(session.sessionId, label); taken.add(label); diff --git a/lib/indicator.js b/lib/indicator.js index 01f7bf0..d4a42c8 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -73,7 +73,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button { // other way -- another extension rebuilding the panel boxes -- would // leave the timer and the file monitor running against a disposed // actor, screaming into the log every 20 seconds. - this.connect('destroy', () => this._onDestroy()); + // + // Not named _onDestroy, which is the name PanelMenu.ButtonBox gives its + // own handler. It connects `this._onDestroy.bind(this)` in _init, and + // that resolves through the prototype chain -- so a subclass method of + // that name silently replaces it, and the St.Bin the panel box actually + // holds is never destroyed. Measured: an empty container stayed behind + // in the box on every teardown. + this.connect('destroy', () => this._teardown()); this._store.start(); } @@ -81,8 +88,8 @@ class ClaudeStatusIndicator extends PanelMenu.Button { _buildPanel() { // One chip per session rather than one aggregate: with five projects - // open, "the most urgent one" answers a question you did not ask. The - // row sits right of the clock, so it grows away from the centre. + // open, "the most urgent one" answers a question you did not ask. + // Where the row sits in the panel is a setting; extension.js places it. this._chipBox = new St.BoxLayout({ style_class: 'panel-status-menu-box ccs-panel-box', y_align: Clutter.ActorAlign.CENTER, @@ -176,6 +183,17 @@ class ClaudeStatusIndicator extends PanelMenu.Button { const showAge = this._settings.get_boolean('show-age'); const abbreviate = this._settings.get_boolean('abbreviate-names'); const maxChips = this._settings.get_int('max-chips'); + const width = this._settings.get_int('abbrev-length'); + + // Labels are sticky by design, which here works against the setting: + // widening would leave every session on screen at its old width until + // it ended. Changing the width is the one thing that discards the map + // -- a relabelling the person asked for is not a label moving under + // their hand. + if (width !== this._chipWidth) { + this._chipLabels = new Map(); + this._chipWidth = width; + } this._chipLabels = assignChips( sessions.map(s => ({ @@ -186,7 +204,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button { since: s.started || s.since, base: projectName(s.cwd), })), - this._chipLabels); + this._chipLabels, width); const labelFor = session => abbreviate ? this._chipLabels.get(session.sessionId) @@ -398,7 +416,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button { // ---- Teardown ------------------------------------------------------- - _onDestroy() { + _teardown() { if (this._destroyed) return; this._destroyed = true; diff --git a/prefs.js b/prefs.js index 646f21c..9d934c6 100644 --- a/prefs.js +++ b/prefs.js @@ -15,15 +15,38 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { }); window.add(page); - const dispGroup = new Adw.PreferencesGroup({ title: _('Panel') }); + const placeGroup = new Adw.PreferencesGroup({ + title: _('Placement'), + description: _('Where the row of chips sits in the top bar. Applied at once — no need to reload.'), + }); + page.add(placeGroup); + + placeGroup.add(this._comboRow(settings, 'panel-box', + _('Panel box'), + _('The centre box holds the clock; the right one is the system status area.'), + [ + { value: 'left', label: _('Left') }, + { value: 'center', label: _('Centre') }, + { value: 'right', label: _('Right') }, + ])); + placeGroup.add(this._spinRow(settings, 'panel-position', + _('Position in that box'), + _('0 is first. In the centre box, 1 puts the chips just right of the clock. Past the end means last.'), + 0, 10)); + + const dispGroup = new Adw.PreferencesGroup({ title: _('Chips') }); page.add(dispGroup); dispGroup.add(this._switchRow(settings, 'show-project-name', _('Label chips with the project'), _('Name the sessions, not just their states.'))); dispGroup.add(this._switchRow(settings, 'abbreviate-names', - _('Shorten names to three characters'), + _('Shorten names'), _('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.'))); + dispGroup.add(this._spinRow(settings, 'abbrev-length', + _('Label length'), + _('Characters a shortened label may use. Three keeps the row narrow; longer reads more like the name. Changing it relabels every session at once.'), + 3, 10)); dispGroup.add(this._spinRow(settings, 'max-chips', _('Chips shown'), _('The most urgent sessions get a chip; the rest are counted as “+N”.'), @@ -118,6 +141,32 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { return row; } + /** A string key with a fixed set of values. + * + * Bound by hand: Gio.Settings.bind maps a boolean to 'active' and an int + * to 'value', but a string to a selected index needs bind_with_mapping, + * which is not introspectable. The write direction is the only one wired + * up -- while this window is open, this row is the only thing that writes + * the key, and it is read afresh every time the window is built. + */ + _comboRow(settings, key, title, subtitle, options) { + const row = new Adw.ComboRow({ + title, subtitle, + model: Gtk.StringList.new(options.map(o => o.label)), + }); + const values = options.map(o => o.value); + const current = values.indexOf(settings.get_string(key)); + // Set before connecting, so restoring the stored value is not itself + // taken for a change the person made. + row.selected = current < 0 ? 0 : current; + row.connect('notify::selected', () => { + const value = values[row.selected]; + if (value) + settings.set_string(key, value); + }); + return row; + } + _switchRow(settings, key, title, subtitle) { const row = new Adw.SwitchRow({ title, subtitle }); settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT); diff --git a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml index 8d9ae02..0afaf58 100644 --- a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml +++ b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml @@ -2,6 +2,22 @@ + + + + + + + 'center' + Which panel box the chips live in + The centre box holds the clock and is the thing you already glance at, which is why the chips start there. The right box is the system's own state area; the left one sits after the activities button and the app menu. + + + 1 + + Index within that box + 0 puts the chips first, before everything else in the box; the default of 1 puts them immediately right of the clock, the centre box's only other occupant. An index past the end of the box lands at the end. + true Label each chip with its project @@ -9,9 +25,15 @@ true - Shorten project names to three characters + Shorten project names on the chips Chips show initials ("dev-skills" becomes "ds") so a row of sessions stays narrow. Sessions that would collide, including two in the same project, get a digit by seniority: the older one keeps its label. Turn off to show full project names. + + 3 + + How many characters a shortened label may use + Three is enough to tell initials apart and narrow enough that a row of chips does not push the clock about. Longer labels read more like the project name; a disambiguating digit still eats into the label rather than extending past this width, so every chip stays the same size. Below three, distinct projects start sharing a label. + 3 diff --git a/tests/test-abbrev.js b/tests/test-abbrev.js index cf8a525..fedce97 100644 --- a/tests/test-abbrev.js +++ b/tests/test-abbrev.js @@ -26,6 +26,17 @@ check('camelCase counts as segments', 'om', abbreviate('outlineMcp')); check('digits survive', 'p2', abbreviate('proj_2')); check('empty name does not crash', '?', abbreviate('')); +// --- a wider label --------------------------------------------------------- +// More initials, not a longer prefix: the whole point of initials is keeping +// "dev-skills" and "dev-conventions" apart, and a prefix at any width does not. +check('a wider label takes more initials', 'ccge', + abbreviate('claude-code-gnome-extension', 4)); +check('and stops at the segments it has', 'ds', abbreviate('dev-skills', 6)); +check('a single word gets more of itself', 'jellyb', abbreviate('jellybit', 6)); +check('the default is still three', 'ccg', abbreviate('claude-code-gnome-extension')); +check('a nonsense width falls back to the default', 'ccg', + abbreviate('claude-code-gnome-extension', 'wide')); + // --- collisions ------------------------------------------------------------ const two = [ { sessionId: 'a', since: 100, base: 'dev-skills' }, @@ -70,6 +81,21 @@ check('every label fits in three characters', [3], lengths); check('twelve sessions in one project are all distinct', 12, new Set(wide.values()).size); +// A digit still eats into the label instead of extending past the width, which +// is what keeps a row of chips from rippling when one of them gains a digit. +const wider = assignChips(many, new Map(), 5); +check('a wider run holds its own width', + [5], [...new Set([...wider.values()].map(l => l.length))]); +check('and stays distinct', 12, new Set(wider.values()).size); +check('the oldest keeps the clean label', 'umbar', wider.get('s0')); +check('the next one gives up a character', 'umba2', wider.get('s1')); + +// Stickiness outranks the width: labels already handed out are kept as they +// are. The indicator drops the map when the setting changes, which is the only +// way a label is allowed to move. +const kept = assignChips(many, wide, 5); +check('an existing label is not re-cut', 'umb', kept.get('s0')); + out(failures ? `\n${failures} failure(s)` : '\nall passed'); if (typeof imports !== 'undefined') imports.system.exit(failures ? 1 : 0); diff --git a/tests/test-prefs.js b/tests/test-prefs.js index 3be96ec..345b374 100644 --- a/tests/test-prefs.js +++ b/tests/test-prefs.js @@ -36,10 +36,13 @@ const schemas = Gio.SettingsSchemaSource.new_from_directory( const { default: Prefs } = await import(`file://${tmp}/prefs.js`); const prefs = new Prefs(); Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream -prefs.getSettings = () => new Gio.Settings({ - settings_schema: schemas.lookup( - 'org.gnome.shell.extensions.claude-code-status', true), -}); +// A memory backend, not the default one: the test writes a key to check the +// hand-rolled combo binding, and it has no business touching the settings of +// whoever is running it. +const backend = Gio.memory_settings_backend_new(); +prefs.getSettings = () => Gio.Settings.new_full( + schemas.lookup('org.gnome.shell.extensions.claude-code-status', true), + backend, '/org/gnome/shell/extensions/claude-code-status/'); const window = new Adw.PreferencesWindow(); prefs.fillPreferencesWindow(window); @@ -48,7 +51,8 @@ const rows = []; const walk = widget => { for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) { const type = c.constructor.$gtype.name; - if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' || type === 'AdwActionRow') + if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' || + type === 'AdwComboRow' || type === 'AdwActionRow') rows.push({ type, title: c.title, subtitle: c.subtitle }); walk(c); } @@ -70,9 +74,33 @@ for (const row of rows) const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true) .list_keys().length; const controls = rows.filter( - r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length; + r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow' || + r.type === 'AdwComboRow').length; check('a control for every settings key', controls === keys, `${controls} of ${keys}`); +// The combo is bound by hand rather than through Gio.Settings.bind, so the +// binding is worth a test: it must start on the stored value and write back +// the value, not the row index. +const combo = []; +const findCombos = widget => { + for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) { + if (c.constructor.$gtype.name === 'AdwComboRow') + combo.push(c); + findCombos(c); + } +}; +findCombos(window); +const settings = prefs.getSettings(); +check('the panel box row exists', combo.length === 1, `${combo.length} combo rows`); +if (combo.length) { + check('starts on the stored value', + combo[0].selected === 1, `selected ${combo[0].selected}`); // 'center' + combo[0].selected = 2; + check('writing back stores the value, not the index', + settings.get_string('panel-box') === 'right', + settings.get_string('panel-box')); +} + // The hook status line is the reason this page is worth opening at all: a // silent panel looks the same whether nothing runs or nothing is installed. const status = rows.find(r => r.title === 'Status');