Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package kl for openSUSE:Factory checked in at 2026-03-26 21:08:36 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/kl (Old) and /work/SRC/openSUSE:Factory/.kl.new.8177 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "kl" Thu Mar 26 21:08:36 2026 rev:4 rq:1342689 version:0.8.0 Changes: -------- --- /work/SRC/openSUSE:Factory/kl/kl.changes 2026-03-09 16:23:27.570968204 +0100 +++ /work/SRC/openSUSE:Factory/.kl.new.8177/kl.changes 2026-03-27 06:42:25.660442092 +0100 @@ -1,0 +2,15 @@ +Thu Mar 26 05:52:07 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.8.0: + * feat: upgrade viewport to v0.11.2 + * feat: start logs unwrapped, add hint for wrap and prettify + * docs: update README dev section + * fix: make entity state transitions more resilient to duplicate + container deltas + * fix: ensure entity tree selection is only maintained after + MaintainEntitySelectionAfterFirstContainer + * feat: vertically center focused matches in unwrapped logs + * chore: update demo + * ci: fix and pin linting + +------------------------------------------------------------------- Old: ---- kl-0.7.0.obscpio New: ---- kl-0.8.0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ kl.spec ++++++ --- /var/tmp/diff_new_pack.miXF56/_old 2026-03-27 06:42:26.792488865 +0100 +++ /var/tmp/diff_new_pack.miXF56/_new 2026-03-27 06:42:26.792488865 +0100 @@ -17,7 +17,7 @@ Name: kl -Version: 0.7.0 +Version: 0.8.0 Release: 0 Summary: An interactive Kubernetes log viewer for your terminal License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.miXF56/_old 2026-03-27 06:42:26.848491178 +0100 +++ /var/tmp/diff_new_pack.miXF56/_new 2026-03-27 06:42:26.852491344 +0100 @@ -3,7 +3,7 @@ <param name="url">https://github.com/robinovitch61/kl.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v0.7.0</param> + <param name="revision">refs/tags/v0.8.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.miXF56/_old 2026-03-27 06:42:26.892492996 +0100 +++ /var/tmp/diff_new_pack.miXF56/_new 2026-03-27 06:42:26.900493326 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/robinovitch61/kl.git</param> - <param name="changesrevision">0738d45b0fbe2c5bead17d41b55b9eaaa60f7b73</param></service></servicedata> + <param name="changesrevision">c4be260829fc37183a9963f4ab2194cd26438c6f</param></service></servicedata> (No newline at EOF) ++++++ kl-0.7.0.obscpio -> kl-0.8.0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/.golangci.yml new/kl-0.8.0/.golangci.yml --- old/kl-0.7.0/.golangci.yml 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/.golangci.yml 2026-03-25 04:09:13.000000000 +0100 @@ -20,6 +20,7 @@ - G108 # pprof endpoint - G114 # http serve without timeouts (debug only) - G115 # integer overflow conversion (linebuffer math) + - G118 # cancel func stored in model, called on shutdown - G301 # directory permissions - G304 # file path variable (CLI tool, not a server) - G401 # weak crypto (md5 used for color hashing, not security) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/README.md new/kl-0.8.0/README.md --- old/kl-0.7.0/README.md 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/README.md 2026-03-25 04:09:13.000000000 +0100 @@ -169,24 +169,27 @@ Run an example flask + postgres + nginx setup in a local [k3d](https://k3d.io/) cluster for testing locally: ```sh -k3d cluster create test -k3d cluster create test2 -kubectl --context k3d-test apply -f ./dev/deploy.yaml -kubectl --context k3d-test2 create namespace otherns -kubectl --context k3d-test2 apply -f ./dev/deploy.yaml -n otherns +k3d cluster create mycluster +k3d cluster create myothercluster +kubectl --context k3d-mycluster apply -f ./dev/deploy.yaml +kubectl --context k3d-myothercluster create namespace otherns +kubectl --context k3d-myothercluster apply -f ./dev/deploy.yaml -n otherns -# view both clusters and all namespaces in kl -kl --context k3d-test,k3d-test2 -A +# view both clusters and both namespaces in kl +kl --context k3d-mycluster,k3d-myothercluster -n default,otherns + +# use -A to include all namespaces (e.g. kube-system) in each cluster +kl --context k3d-mycluster,k3d-myothercluster -A # access the application's webpage -kubectl --context k3d-test2 -n otherns port-forward services/frontend-service 8080:80 +kubectl --context k3d-myothercluster -n otherns port-forward services/frontend-service 8080:80 open http://localhost:8080 # browser console one-liner to click button every second to generate logs setInterval(() => { document.getElementsByTagName("button")[0].click(); }, 1000); # or make requests directly to flask from the terminal -kubectl --context k3d-test2 port-forward services/flask-service 5000:5000 +kubectl --context k3d-myothercluster port-forward services/flask-service 5000:5000 curl http://localhost:5000/status ``` Binary files old/kl-0.7.0/demo/demo.gif and new/kl-0.8.0/demo/demo.gif differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/dev/deploy.yaml new/kl-0.8.0/dev/deploy.yaml --- old/kl-0.7.0/dev/deploy.yaml 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/dev/deploy.yaml 2026-03-25 04:09:13.000000000 +0100 @@ -103,8 +103,6 @@ value: "30" - name: PERIODIC_LOGGING value: "true" - - name: PERIODIC_LOGGING_BYTES_PER_LOG - value: "10000" - name: PERIODIC_LOGGING_LOGS_PER_SECOND value: "5" - name: PERIODIC_BIG_LOGGING @@ -140,8 +138,6 @@ value: "30" - name: PERIODIC_LOGGING value: "true" - - name: PERIODIC_LOGGING_BYTES_PER_LOG - value: "10000" - name: PERIODIC_LOGGING_LOGS_PER_SECOND value: "5" - name: PERIODIC_BIG_LOGGING @@ -177,8 +173,6 @@ value: "30" - name: PERIODIC_LOGGING value: "true" - - name: PERIODIC_LOGGING_BYTES_PER_LOG - value: "10000" - name: PERIODIC_LOGGING_LOGS_PER_SECOND value: "5" - name: PERIODIC_BIG_LOGGING diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/dev/flask/app.py new/kl-0.8.0/dev/flask/app.py --- old/kl-0.7.0/dev/flask/app.py 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/dev/flask/app.py 2026-03-25 04:09:13.000000000 +0100 @@ -12,25 +12,22 @@ from psycopg2 import pool -class JSONFormatter(logging.Formatter): +class MixedFormatter(logging.Formatter): + _plain = logging.Formatter("%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S") + def format(self, record): - log_record = { - "timestamp": datetime.utcnow().isoformat(), - "level": record.levelname, - "message": record.getMessage(), - "module": record.module, - "function": record.funcName, - "line": record.lineno - } - if record.exc_info: - log_record["exception"] = self.formatException(record.exc_info) - return json.dumps(log_record) + msg = record.getMessage() + try: + json.loads(msg) + return msg + except (ValueError, TypeError): + return self._plain.format(record) -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) handler = logging.StreamHandler() -handler.setFormatter(JSONFormatter()) -logger.addHandler(handler) +handler.setFormatter(MixedFormatter()) +logging.root.setLevel(logging.DEBUG) +logging.root.addHandler(handler) +logger = logging.getLogger(__name__) app = Flask(__name__) CORS(app) @@ -90,35 +87,83 @@ exit_thread = threading.Thread(target=exit_after_delay, daemon=True) exit_thread.start() -def generate_random_text(num_bytes, prefix=""): - words = ['hello', 'world', 'test', 'random', 'text', 'words', 'generator', - 'python', 'code', 'sample'] - # choose words randomly, returning exactly num_bytes of text - res = prefix - while len(res) < num_bytes: - res += random.choice(words) + " " - return res[:num_bytes] +_PLAIN_ENTRIES = [ + (logging.DEBUG, "cache lookup: key=user:1042 hit=True ttl=298s"), + (logging.DEBUG, "db query took 3ms: SELECT * FROM users WHERE id=$1"), + (logging.DEBUG, "db query took 18ms: SELECT count(*) FROM orders WHERE status='pending'"), + (logging.DEBUG, "cache miss: key=session:abc123, fetching from db"), + (logging.DEBUG, "worker thread pool: 4/16 threads active"), + (logging.DEBUG, "config loaded: POSTGRES_HOST=postgres-service PORT=5000"), + (logging.INFO, "GET /api/v1/users 200 14ms"), + (logging.INFO, "POST /api/v1/orders 201 22ms"), + (logging.INFO, "GET /api/v1/products?category=electronics 200 31ms"), + (logging.INFO, "DELETE /api/v1/sessions/xyz 204 5ms"), + (logging.INFO, "user 1042 logged in from 203.0.113.47"), + (logging.INFO, "order #8821 created for user 1042, total=$142.50"), + (logging.INFO, "email queued: [email protected] -> user:5510"), + (logging.INFO, "scheduled job 'cleanup_expired_sessions' completed, removed 14 rows"), + (logging.INFO, "db pool: 3/20 connections in use"), + (logging.WARNING, "slow query (1.2s): SELECT orders JOIN users ON users.id=orders.user_id"), + (logging.WARNING, "rate limit approaching for ip 198.51.100.22: 87/100 req/min"), + (logging.WARNING, "jwt token expiring soon for user 2201, issuing refresh"), + (logging.WARNING, "disk usage at 81% on /var/data, threshold is 85%"), + (logging.WARNING, "retrying db connection (attempt 2/3): connection refused"), + (logging.ERROR, "failed to send email to user:3309: SMTP timeout after 10s"), + (logging.ERROR, "unhandled exception in worker: ZeroDivisionError: division by zero"), + (logging.ERROR, "payment gateway returned 502, order #9001 not processed"), + (logging.ERROR, "db write failed: deadlock detected, rolling back transaction"), +] + +_JSON_ENTRIES = [ + (logging.DEBUG, {"event": "cache_get", "key": "user:2048", "hit": True, "ttl": 120}), + (logging.DEBUG, {"event": "query", "sql": "SELECT * FROM sessions WHERE token=$1", "ms": 2}), + (logging.INFO, {"event": "request", "method": "GET", "path": "/health", "status": 200, "ms": 4}), + (logging.INFO, {"event": "request", "method": "POST", "path": "/api/v1/login", "status": 200, "ms": 37, "user_id": 1042}), + (logging.INFO, {"event": "request", "method": "GET", "path": "/api/v1/orders", "status": 200, "ms": 55, "count": 23}), + (logging.INFO, {"event": "job_done", "job": "send_digest_emails", "sent": 312, "failed": 1}), + (logging.INFO, {"event": "db_pool", "active": 5, "idle": 15, "max": 20}), + (logging.WARNING,{"event": "slow_query", "ms": 980, "sql": "UPDATE orders SET status=$1 WHERE user_id=$2"}), + (logging.WARNING,{"event": "rate_limit", "ip": "203.0.113.99", "requests": 95, "limit": 100}), + (logging.ERROR, {"event": "exception", "type": "ConnectionError", "msg": "db connection lost", "attempt": 1}), + (logging.ERROR, {"event": "request", "method": "POST", "path": "/api/v1/checkout", "status": 500, "ms": 102, "error": "payment timeout"}), +] + +def _pick_log_entry(): + if random.random() < 0.35: + level, data = random.choice(_JSON_ENTRIES) + return level, json.dumps(data) + else: + return random.choice(_PLAIN_ENTRIES) def periodic_logger(): - num_bytes = int(os.getenv("PERIODIC_LOGGING_BYTES_PER_LOG", "1000")) logs_per_second = int(os.getenv("PERIODIC_LOGGING_LOGS_PER_SECOND", "1")) - delay = 1/logs_per_second + delay = 1 / logs_per_second while True: - logger.info(generate_random_text(num_bytes)) + level, msg = _pick_log_entry() + logger.log(level, msg) time.sleep(delay) -# Periodically log random text +# Periodically emit realistic log entries if os.getenv("PERIODIC_LOGGING"): logging_thread = threading.Thread(target=periodic_logger, daemon=True) logging_thread.start() +def _make_long_text(num_bytes): + words = ["processing", "request", "user", "order", "session", "cache", + "database", "index", "query", "worker", "event", "payload", + "stream", "record", "batch", "token", "config", "metric"] + res = "long " + while len(res) < num_bytes: + res += random.choice(words) + " " + return res[:num_bytes] + def periodic_big_logger(): num_bytes = int(os.getenv("PERIODIC_BIG_LOGGING_BYTES_PER_LOG", "500_000")) logs_per_second = int(os.getenv("PERIODIC_BIG_LOGGING_LOGS_PER_SECOND", "0.05")) - delay = 1/logs_per_second + delay = 1 / logs_per_second while True: - logger.info(generate_random_text(num_bytes, prefix="long ")) + logger.info(_make_long_text(num_bytes)) time.sleep(delay) # Periodically log really long random text @@ -128,10 +173,8 @@ @app.route("/health") def health(): - logger.info("Health check endpoint called") - logger.info("FIRST Lorem ipsum \n\tdolor sit amet, consectetur adipiscing elit. \n\tSed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim \n\tad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.") - logger.info("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod \n\ttempor incididunt ut labore et dolore magna aliqua. \n\tUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.") - logger.info("LAST Lorem ipsum \n\tdolor sit amet, consectetur adipiscing elit. \n\tSed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad \n\tminim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.") + logger.debug("health check: db pool active=%d idle=%d", 3, 17) + logger.info("GET /health 200 2ms") return jsonify({"status": "healthy"}), 200 @app.route("/status", methods=["GET"]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/go.mod new/kl-0.8.0/go.mod --- old/kl-0.7.0/go.mod 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/go.mod 2026-03-25 04:09:13.000000000 +0100 @@ -17,7 +17,7 @@ github.com/mattn/go-runewidth v0.0.20 github.com/muesli/reflow v0.3.0 github.com/rivo/uniseg v0.4.7 - github.com/robinovitch61/viewport v0.11.0 + github.com/robinovitch61/viewport v0.11.2 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/go.sum new/kl-0.8.0/go.sum --- old/kl-0.7.0/go.sum 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/go.sum 2026-03-25 04:09:13.000000000 +0100 @@ -123,8 +123,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/robinovitch61/viewport v0.11.0 h1:SHyFpbVt1j2GpbstdYNAE44tAWSOE3dCYFM2CcR19/M= -github.com/robinovitch61/viewport v0.11.0/go.mod h1:1Pvc8fjY7bgra0mGEsDD1kK6ECqBxT04DuEsOCzE5OY= +github.com/robinovitch61/viewport v0.11.2 h1:PuVv+MLJp1LXanYX/fS40hztW2OzlXQAxvOD+hBow38= +github.com/robinovitch61/viewport v0.11.2/go.mod h1:1Pvc8fjY7bgra0mGEsDD1kK6ECqBxT04DuEsOCzE5OY= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/app.go new/kl-0.8.0/internal/app.go --- old/kl-0.7.0/internal/app.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/app.go 2026-03-25 04:09:13.000000000 +0100 @@ -819,19 +819,14 @@ if len(existingContainerEntities) == 0 && !m.state.seenFirstContainer { m.pages[m.state.focusedPageType] = m.pages[m.state.focusedPageType].WithFocus() - cmds = append(cmds, tea.Tick(constants.AttemptMaintainEntitySelectionAfterFirstContainer, func(t time.Time) tea.Msg { return message.StartMaintainEntitySelectionMsg{} })) + cmds = append(cmds, tea.Tick(constants.MaintainEntitySelectionAfterFirstContainer, func(t time.Time) tea.Msg { return message.StartMaintainEntitySelectionMsg{} })) m.state.seenFirstContainer = true } for _, delta := range msg.DeltaSet.OrderedDeltas() { - // get the existing entity for the container, if it exists - var existingContainerEntity *entity.Entity - for _, containerEntity := range existingContainerEntities { - if containerEntity.Container.Equals(delta.Container) { - existingContainerEntity = &containerEntity - break - } - } + // get the existing entity for the container from the current tree, not a stale snapshot, + // as prior iterations may have changed the entity's state + existingContainerEntity := m.entityTree.GetEntity(delta.Container) if delta.ToDelete { if existingContainerEntity != nil { @@ -866,14 +861,7 @@ func (m Model) handleStartedLogScannerMsg(msg command.StartedLogScannerMsg) (Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd - existingContainerEntities := m.entityTree.GetContainerEntities() - var startedContainerEntity *entity.Entity - for _, containerEntity := range existingContainerEntities { - if msg.LogScanner.Container.Equals(containerEntity.Container) { - startedContainerEntity = &containerEntity - break - } - } + startedContainerEntity := m.entityTree.GetEntity(msg.LogScanner.Container) if startedContainerEntity == nil { msg.LogScanner.Cancel() return m, nil diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/app_test.go new/kl-0.8.0/internal/app_test.go --- old/kl-0.7.0/internal/app_test.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/app_test.go 2026-03-25 04:09:13.000000000 +0100 @@ -226,3 +226,71 @@ t.Errorf("expected view to contain cluster name 'test-cluster', got:\n%s", view) } } + +func TestDuplicateDeltas_NoDoubleStartScanner(t *testing.T) { + m := newTestModel() + + // create a container in WantScanning state (waiting + activated) + ct := newAppTestContainer() + ct.Status.State = container.ContainerWaiting + var createSet container.ContainerDeltaSet + createSet.Add(newAppTestDelta(ct, true)) + m = updateModel(t, m, command.GetContainerDeltasMsg{DeltaSet: createSet}) + + ent := m.entityTree.GetEntity(ct) + if ent == nil { + t.Fatal("expected entity to exist") + } + if ent.State != entity.WantScanning { + t.Fatalf("expected WantScanning, got %v", ent.State) + } + + // send two update deltas for the same container transitioning to Running in the same batch + runningCt := ct + runningCt.Status.State = container.ContainerRunning + var updateSet container.ContainerDeltaSet + updateSet.Add(container.ContainerDelta{ + Time: time.Now(), + Container: runningCt, + }) + updateSet.Add(container.ContainerDelta{ + Time: time.Now().Add(time.Millisecond), + Container: runningCt, + }) + m = updateModel(t, m, command.GetContainerDeltasMsg{DeltaSet: updateSet}) + + ent = m.entityTree.GetEntity(runningCt) + if ent == nil { + t.Fatal("expected entity to exist") + } + if ent.State != entity.ScannerStarting { + t.Fatalf("expected ScannerStarting, got %v", ent.State) + } + + // simulate first scanner starting successfully + _, cancel1 := context.WithCancel(context.Background()) + scanner1 := k8s_log.NewLogScanner(runningCt, nil, cancel1, nil) + m = updateModel(t, m, command.StartedLogScannerMsg{LogScanner: scanner1}) + + ent = m.entityTree.GetEntity(runningCt) + if ent == nil { + t.Fatal("expected entity to exist") + } + if ent.State != entity.Scanning { + t.Fatalf("expected Scanning, got %v", ent.State) + } + + // if a duplicate StartScanner was dispatched, a second ScannerStarted would arrive + // for an entity already in Scanning state, which must not panic + _, cancel2 := context.WithCancel(context.Background()) + scanner2 := k8s_log.NewLogScanner(runningCt, nil, cancel2, nil) + m = updateModel(t, m, command.StartedLogScannerMsg{LogScanner: scanner2}) + + ent = m.entityTree.GetEntity(runningCt) + if ent == nil { + t.Fatal("expected entity to exist") + } + if ent.State != entity.Scanning { + t.Fatalf("expected Scanning, got %v", ent.State) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/constants/constants.go new/kl-0.8.0/internal/constants/constants.go --- old/kl-0.7.0/internal/constants/constants.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/constants/constants.go 2026-03-25 04:09:13.000000000 +0100 @@ -53,9 +53,8 @@ // InitialLookbackMins controls the initial number of minutes to look back in logs var InitialLookbackMins = 1 -// AttemptMaintainEntitySelectionAfterFirstContainer controls how long to delay after the first container before -// attempting to maintain the currently selected Entity in the tree. The thinking goes that the tree may not be fully -// populated yet and the user won't even have time to orient themselves and then the selection is somewhere in the -// middle of the tree. But after a short amount of time, they will have actively selected something and we can try to -// maintain that selection -var AttemptMaintainEntitySelectionAfterFirstContainer = 1 * time.Second +// MaintainEntitySelectionAfterFirstContainer controls how long to delay after the first container before maintaining +// the currently selected Entity in the tree. The thinking goes that the tree may not be fully populated yet and the +// user won't even have time to orient themselves and then the selection is somewhere in the middle of the tree. But +// after a short amount of time, they will have actively selected something and we can try to maintain that selection. +var MaintainEntitySelectionAfterFirstContainer = 1 * time.Second diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/k8s/entity/entity.go new/kl-0.8.0/internal/k8s/entity/entity.go --- old/kl-0.7.0/internal/k8s/entity/entity.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/k8s/entity/entity.go 2026-03-25 04:09:13.000000000 +0100 @@ -298,6 +298,10 @@ tree.AddOrReplace(e) return e, tree, []EntityAction{} + case Scanning: + // can happen when duplicate StartScanner actions are dispatched from a single delta batch + scanner.Cancel() + return e, tree, []EntityAction{} case Deleted: // can happen when a same-batch Update(WantScanning→ScannerStarting) dispatches StartScanner, // then a Delete(WantScanning→Deleted) overwrites the state before the scanner starts diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/k8s/entity/entity_test.go new/kl-0.8.0/internal/k8s/entity/entity_test.go --- old/kl-0.7.0/internal/k8s/entity/entity_test.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/k8s/entity/entity_test.go 2026-03-25 04:09:13.000000000 +0100 @@ -520,8 +520,21 @@ assertActions(t, actions, []entity.EntityAction{}) } +func TestScannerStarted_AlreadyScanning(t *testing.T) { + tree := newTestTree() + ent := newTestEntity(entity.Scanning, container.ContainerRunning) + tree.AddOrReplace(ent) + + scanner := newTestScanner() + result, _, actions := ent.ScannerStarted(tree, nil, scanner) + + // should stay Scanning and cancel the duplicate scanner + assertState(t, result, entity.Scanning) + assertActions(t, actions, []entity.EntityAction{}) +} + func TestScannerStarted_FromInvalidState_Panics(t *testing.T) { - for _, state := range []entity.EntityState{entity.Inactive, entity.WantScanning, entity.Scanning, entity.ScannerStopping} { + for _, state := range []entity.EntityState{entity.Inactive, entity.WantScanning, entity.ScannerStopping} { t.Run(state.String(), func(t *testing.T) { tree := newTestTree() ent := newTestEntity(state, container.ContainerRunning) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/page/entities.go new/kl-0.8.0/internal/page/entities.go --- old/kl-0.7.0/internal/page/entities.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/page/entities.go 2026-03-25 04:09:13.000000000 +0100 @@ -60,9 +60,9 @@ Top: keyMap.Top, Bottom: keyMap.Bottom, }), + viewport.WithSelectionEnabled[entity.Entity](true), + viewport.WithWrapText[entity.Entity](false), ) - vp.SetSelectionEnabled(true) - vp.SetWrapText(false) fvp := filterableviewport.New(vp, filterableviewport.WithKeyMap[entity.Entity](filterableviewport.KeyMap{ @@ -200,9 +200,6 @@ f := p.getCurrentFilter() p.entityTree.UpdatePrettyPrintPrefixes(f) p.filterableViewport.SetObjects(p.entityTree.GetVisibleEntities(f)) - p.filterableViewport.SetSelectionComparator(func(a, b entity.Entity) bool { - return a.EqualTo(b) - }) p.filterableViewport.SetAdjustObjectsForFilter(func(filterText string, isRegex bool) []entity.Entity { f := makeFilterFromText(filterText, isRegex, p.keyMap) entityTree.UpdatePrettyPrintPrefixes(f) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/page/log.go new/kl-0.8.0/internal/page/log.go --- old/kl-0.7.0/internal/page/log.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/page/log.go 2026-03-25 04:09:13.000000000 +0100 @@ -74,9 +74,9 @@ Bottom: keyMap.Bottom, }), viewport.WithSelectionStyleOverridesItemStyle[SingleLogLine](false), + viewport.WithSelectionEnabled[SingleLogLine](false), + viewport.WithWrapText[SingleLogLine](true), ) - vp.SetSelectionEnabled(false) - vp.SetWrapText(true) fvp := filterableviewport.New(vp, filterableviewport.WithKeyMap[SingleLogLine](filterableviewport.KeyMap{ @@ -96,6 +96,7 @@ filterableviewport.WithEmptyText[SingleLogLine]("'/', 'r', or 'i' to filter"), filterableviewport.WithFilterLinePosition[SingleLogLine](filterableviewport.FilterLineTop), filterableviewport.WithFilterLinePrefix[SingleLogLine]("Single Log"), + filterableviewport.WithHorizontalPad[SingleLogLine](100000), // effectively center matches when text is unwrapped filterableviewport.WithStyles[SingleLogLine](filterableviewport.Styles{ Match: filterableviewport.MatchStyles{ Focused: theme.MatchFocused, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/page/logs.go new/kl-0.8.0/internal/page/logs.go --- old/kl-0.7.0/internal/page/logs.go 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/page/logs.go 2026-03-25 04:09:13.000000000 +0100 @@ -59,9 +59,9 @@ Bottom: keyMap.Bottom, }), viewport.WithSelectionStyleOverridesItemStyle[model.PageLog](false), + viewport.WithSelectionEnabled[model.PageLog](true), + viewport.WithWrapText[model.PageLog](false), ) - vp.SetSelectionEnabled(true) - vp.SetWrapText(true) fvp := filterableviewport.New(vp, filterableviewport.WithKeyMap[model.PageLog](filterableviewport.KeyMap{ @@ -81,6 +81,7 @@ filterableviewport.WithEmptyText[model.PageLog]("'/', 'r', or 'i' to filter"), filterableviewport.WithFilterLinePosition[model.PageLog](filterableviewport.FilterLineTop), filterableviewport.WithFilterLinePrefix[model.PageLog](fmt.Sprintf("(L)ogs, %s", getOrder(!descending))), + filterableviewport.WithHorizontalPad[model.PageLog](100000), // effectively center matches when text is unwrapped filterableviewport.WithStyles[model.PageLog](filterableviewport.Styles{ Match: filterableviewport.MatchStyles{ Focused: theme.MatchFocused, @@ -390,6 +391,7 @@ func (p *LogsPage) updateFilterLabel() { prefix := fmt.Sprintf("(L)ogs, %s", getOrder(p.logContainer.Ascending())) if p.focused { + prefix += " [(w)rap, (p)rettify]" prefix = p.theme.FilterPrefixFocused.Render(prefix) } p.filterableViewport.SetFilterLinePrefix(prefix) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kl-0.7.0/internal/textinput/README.md new/kl-0.8.0/internal/textinput/README.md --- old/kl-0.7.0/internal/textinput/README.md 2026-03-06 18:33:21.000000000 +0100 +++ new/kl-0.8.0/internal/textinput/README.md 2026-03-25 04:09:13.000000000 +0100 @@ -1,2 +1,2 @@ -This textinput is forked from https://github.com/charmbracelet/bubbles in order to add some utility functions, e.g. -`IsEmpty` \ No newline at end of file +This textinput is forked from https://github.com/charmbracelet/bubbles in order to add some utility functions for +performance, e.g. `IsEmpty` \ No newline at end of file ++++++ kl.obsinfo ++++++ --- /var/tmp/diff_new_pack.miXF56/_old 2026-03-27 06:42:27.352512003 +0100 +++ /var/tmp/diff_new_pack.miXF56/_new 2026-03-27 06:42:27.356512167 +0100 @@ -1,5 +1,5 @@ name: kl -version: 0.7.0 -mtime: 1772818401 -commit: 0738d45b0fbe2c5bead17d41b55b9eaaa60f7b73 +version: 0.8.0 +mtime: 1774408153 +commit: c4be260829fc37183a9963f4ab2194cd26438c6f ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/kl/vendor.tar.gz /work/SRC/openSUSE:Factory/.kl.new.8177/vendor.tar.gz differ: char 19, line 1
