From 3cb3b245813fe24a2cc9a64e754fc012c4b760c1 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 12:54:24 +0200 Subject: [PATCH] Populate the cockpit kittyTree state on every control snapshot Wires the phase-7 kitty-tree reader into readControlSnapshot so the cockpit sidebar's user > session > pane tree fills in automatically when running inside a Kitty session with KITTY_LISTEN_ON exported. The reader runs once per refresh tick (default ~2s); errors and non-Kitty environments leave state.kittyTree null so the sidebar falls back to its pre-phase-7 layout. The reader is injectable via options.readKittyTree for tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cockpit/control.js | 31 +++++++++++- test/cockpit-kitty-tree-wire.test.js | 70 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 test/cockpit-kitty-tree-wire.test.js diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 9a52006..cdc3507 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -9,6 +9,7 @@ const { stripAnsi } = require('./theme'); const { renderWelcomePage } = require('./welcome'); const { runCockpitAction } = require('./action-runner'); const { findProjects } = require('./projects-finder'); +const { readKittyTree } = require('./kitty-tree'); const { readLogs, filterEntries, LEVELS: LOG_LEVELS } = require('./logs-reader'); const { PANE_MENU_ITEMS, @@ -963,12 +964,38 @@ function readControlSnapshot(options = {}, previousState) { const cockpitState = stateReader(repoPath); const settings = readCockpitSettings(repoPath, options); const at = typeof options.now === 'function' ? options.now() : new Date().toISOString(); - return applyCockpitAction(previousState || { repoPath }, { + const next = applyCockpitAction(previousState || { repoPath }, { type: 'refresh', cockpitState, settings, at, }); + return attachKittyTree(next, options); +} + +function attachKittyTree(state, options = {}) { + if (!state || typeof state !== 'object') return state; + const env = options.env || process.env; + if (!env || !env.KITTY_LISTEN_ON) { + if (state.kittyTree) { + return { ...state, kittyTree: null }; + } + return state; + } + const reader = typeof options.readKittyTree === 'function' ? options.readKittyTree : readKittyTree; + let tree; + try { + tree = reader({ + env, + repoRoot: state.repoPath, + runner: options.kittyTreeRunner, + timeoutMs: options.kittyTreeTimeoutMs, + }); + } catch (_error) { + return state; + } + if (!tree || tree.error) return state; + return { ...state, kittyTree: tree }; } function refreshMsFrom(options, state) { @@ -1079,10 +1106,12 @@ module.exports = { SETTINGS_FIELDS, applyCockpitAction, applyCockpitKey: applyKey, + attachKittyTree, buildCockpitActionContext, normalizeControlState, normalizeKey, readCockpitSettings, + readControlSnapshot, renderControlFrame, resolveSelectedSession, runCockpitAction, diff --git a/test/cockpit-kitty-tree-wire.test.js b/test/cockpit-kitty-tree-wire.test.js new file mode 100644 index 0000000..fdfc4da --- /dev/null +++ b/test/cockpit-kitty-tree-wire.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { readControlSnapshot } = require('../src/cockpit/control'); + +test('readControlSnapshot attaches state.kittyTree when KITTY_LISTEN_ON is set', () => { + const calls = []; + const fakeTree = { + user: 'deadpool', + sessionLabel: 'gitguardex', + osWindowId: 7, + windows: [{ id: 11, title: 'gx cockpit', kind: 'control', isFocused: true }], + error: '', + }; + const state = readControlSnapshot({ + repoPath: '/repo/gitguardex', + env: { KITTY_LISTEN_ON: 'unix:/tmp/test.sock' }, + readState: () => ({ repoPath: '/repo/gitguardex', sessions: [] }), + readSettings: () => ({}), + readKittyTree: (opts) => { + calls.push(opts); + return fakeTree; + }, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].repoRoot, '/repo/gitguardex'); + assert.equal(state.kittyTree && state.kittyTree.user, 'deadpool'); + assert.equal(state.kittyTree.windows.length, 1); +}); + +test('readControlSnapshot leaves kittyTree null when KITTY_LISTEN_ON is unset', () => { + const state = readControlSnapshot({ + repoPath: '/repo/gitguardex', + env: {}, + readState: () => ({ repoPath: '/repo/gitguardex', sessions: [] }), + readSettings: () => ({}), + readKittyTree: () => { + throw new Error('readKittyTree should not be called when KITTY_LISTEN_ON is unset'); + }, + }); + assert.equal(state.kittyTree || null, null); +}); + +test('readControlSnapshot drops kittyTree when the reader returns an error', () => { + const state = readControlSnapshot({ + repoPath: '/repo/gitguardex', + env: { KITTY_LISTEN_ON: 'unix:/tmp/test.sock' }, + readState: () => ({ repoPath: '/repo/gitguardex', sessions: [] }), + readSettings: () => ({}), + readKittyTree: () => ({ + user: 'deadpool', sessionLabel: 'gitguardex', osWindowId: null, windows: [], error: 'kitty @ ls failed', + }), + }); + assert.equal(state.kittyTree || null, null); +}); + +test('readControlSnapshot is resilient when readKittyTree throws', () => { + const state = readControlSnapshot({ + repoPath: '/repo/gitguardex', + env: { KITTY_LISTEN_ON: 'unix:/tmp/test.sock' }, + readState: () => ({ repoPath: '/repo/gitguardex', sessions: [] }), + readSettings: () => ({}), + readKittyTree: () => { throw new Error('boom'); }, + }); + // Throw is caught; state still produced, kittyTree absent. + assert.equal(state.kittyTree || null, null); +});