• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tstack / lnav / 20284884426-2753

16 Dec 2025 10:23PM UTC coverage: 68.23% (-0.7%) from 68.903%
20284884426-2753

push

github

tstack
[log] show invalid utf hex dump in log view too

25 of 25 new or added lines in 2 files covered. (100.0%)

503 existing lines in 33 files now uncovered.

51170 of 74996 relevant lines covered (68.23%)

433797.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

14.13
/src/breadcrumb_curses.cc
1
/**
2
 * Copyright (c) 2022, Timothy Stack
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 * * Redistributions of source code must retain the above copyright notice, this
10
 * list of conditions and the following disclaimer.
11
 * * Redistributions in binary form must reproduce the above copyright notice,
12
 * this list of conditions and the following disclaimer in the documentation
13
 * and/or other materials provided with the distribution.
14
 * * Neither the name of Timothy Stack nor the names of its contributors
15
 * may be used to endorse or promote products derived from this software
16
 * without specific prior written permission.
17
 *
18
 * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY
19
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
 * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
22
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 */
29

30
#include "breadcrumb_curses.hh"
31

32
#include "base/itertools.enumerate.hh"
33
#include "base/itertools.hh"
34
#include "base/keycodes.hh"
35
#include "itertools.similar.hh"
36

37
using namespace lnav::roles::literals;
38

39
void
40
breadcrumb_curses::no_op_action(breadcrumb_curses&)
×
41
{
42
}
43

44
breadcrumb_curses::breadcrumb_curses()
1✔
45
{
46
    this->bc_match_search_overlay.sos_parent = this;
1✔
47
    this->bc_match_source.set_reverse_selection(true);
1✔
48
    this->bc_match_view.set_title("breadcrumb popup");
2✔
49
    this->bc_match_view.set_selectable(true);
1✔
50
    this->bc_match_view.set_overlay_source(&this->bc_match_search_overlay);
1✔
51
    this->bc_match_view.set_sub_source(&this->bc_match_source);
1✔
52
    this->bc_match_view.set_height(0_vl);
1✔
53
    this->bc_match_view.set_show_scrollbar(true);
1✔
54
    this->bc_match_view.set_default_role(role_t::VCR_POPUP);
1✔
55
    this->bc_match_view.set_head_space(0_vl);
1✔
56
    this->add_child_view(&this->bc_match_view);
1✔
57
}
1✔
58

59
bool
60
breadcrumb_curses::do_update()
9✔
61
{
62
    if (!this->bc_line_source) {
9✔
63
        return false;
×
64
    }
65

66
    if (!this->vc_needs_update) {
9✔
67
        return view_curses::do_update();
1✔
68
    }
69

70
    size_t sel_crumb_offset = 0;
8✔
71
    auto width = ncplane_dim_x(this->vc_window);
8✔
72
    auto crumbs = this->bc_focused_crumbs.empty() ? this->bc_line_source()
8✔
73
                                                  : this->bc_focused_crumbs;
8✔
74
    if (this->bc_last_selected_crumb
8✔
75
        && this->bc_last_selected_crumb.value() >= crumbs.size())
8✔
76
    {
77
        this->bc_last_selected_crumb = crumbs.size() - 1;
×
78
    }
79
    this->bc_displayed_crumbs.clear();
8✔
80
    attr_line_t crumbs_line;
8✔
81
    for (const auto& [crumb_index, crumb] : lnav::itertools::enumerate(crumbs))
16✔
82
    {
83
        auto accum_width = crumbs_line.column_width();
8✔
84
        auto elem_width = crumb.c_display_value.column_width();
8✔
85
        auto is_selected = this->bc_selected_crumb
86
            && (crumb_index == this->bc_selected_crumb.value());
8✔
87

88
        if (is_selected && ((accum_width + elem_width) > width)) {
8✔
89
            crumbs_line.clear();
×
90
            crumbs_line.append("\u22ef\uff1a"_breadcrumb);
×
91
            accum_width = 2;
×
92
        }
93

94
        line_range crumb_range;
8✔
95
        crumb_range.lr_start = (int) crumbs_line.length();
8✔
96
        crumbs_line.append(crumb.c_display_value);
8✔
97
        crumb_range.lr_end = (int) crumbs_line.length();
8✔
98
        if (is_selected) {
8✔
99
            sel_crumb_offset = accum_width;
×
100
            crumbs_line.get_attrs().emplace_back(
×
101
                crumb_range, VC_STYLE.value(text_attrs::with_reverse()));
×
102
        }
103

104
        this->bc_displayed_crumbs.emplace_back(
8✔
105
            line_range{
×
106
                (int) accum_width,
107
                (int) (accum_width + elem_width),
8✔
108
                line_range::unit::codepoint,
109
            },
110
            crumb_index);
111
        crumbs_line.append(" \uff1a"_breadcrumb);
8✔
112
    }
113

114
    if (!this->vc_enabled) {
8✔
115
        for (auto& attr : crumbs_line.al_attrs) {
12✔
116
            if (attr.sa_type != &VC_ROLE) {
8✔
UNCOV
117
                continue;
×
118
            }
119

120
            auto role = attr.sa_value.get<role_t>();
8✔
121
            if (role == role_t::VCR_STATUS_TITLE) {
8✔
122
                attr.sa_value = role_t::VCR_STATUS_DISABLED_TITLE;
4✔
123
            }
124
        }
125
    }
126

127
    line_range lr{0, static_cast<int>(width)};
8✔
128
    auto default_role = this->vc_enabled ? role_t::VCR_STATUS
8✔
129
                                         : role_t::VCR_INACTIVE_STATUS;
130
    mvwattrline(this->vc_window, this->vc_y, 0, crumbs_line, lr, default_role);
8✔
131

132
    if (this->bc_selected_crumb) {
8✔
133
        this->bc_match_view.set_x(sel_crumb_offset);
×
134
    }
135
    view_curses::do_update();
8✔
136

137
    return true;
8✔
138
}
8✔
139

140
void
141
breadcrumb_curses::reload_data()
×
142
{
143
    if (!this->bc_selected_crumb) {
×
144
        return;
×
145
    }
146

147
    auto& selected_crumb_ref
148
        = this->bc_focused_crumbs[this->bc_selected_crumb.value()];
×
149
    this->bc_possible_values = selected_crumb_ref.c_possibility_provider();
×
150

151
    std::optional<size_t> selected_value;
×
152
    this->bc_similar_values = this->bc_possible_values
×
153
        | lnav::itertools::similar_to(
×
154
                                  [](const auto& elem) { return elem.p_key; },
×
155
                                  this->bc_current_search,
×
156
                                  128)
157
        | lnav::itertools::sort_with(breadcrumb::possibility::sort_cmp);
×
158
    for (auto& al : this->bc_similar_values) {
×
159
        al.p_display_value.highlight_fuzzy_matches(this->bc_current_search);
×
160
    }
161
    if (selected_crumb_ref.c_key.is<std::string>()
×
162
        && selected_crumb_ref.c_expected_input
×
163
            != breadcrumb::crumb::expected_input_t::anything)
164
    {
165
        auto& selected_crumb_key = selected_crumb_ref.c_key.get<std::string>();
×
166
        auto found_poss_opt = this->bc_similar_values
×
167
            | lnav::itertools::find_if([&selected_crumb_key](const auto& elem) {
×
168
                                  return elem.p_key == selected_crumb_key;
×
169
                              });
×
170

171
        if (found_poss_opt) {
×
172
            selected_value = std::distance(this->bc_similar_values.begin(),
×
173
                                           found_poss_opt.value());
×
174
        } else {
175
            selected_value = 0;
×
176
        }
177
    }
178

179
    auto matches = this->bc_similar_values
×
180
        | lnav::itertools::map(&breadcrumb::possibility::p_display_value);
×
181
    this->bc_match_source.replace_with(matches);
×
182
    auto width = this->bc_possible_values
×
183
        | lnav::itertools::fold(
×
184
                     [](const auto& match, auto& accum) {
×
185
                         auto mlen = match.p_display_value.length();
×
186
                         if (mlen > accum) {
×
187
                             return mlen;
×
188
                         }
189
                         return accum;
×
190
                     },
191
                     selected_crumb_ref.c_display_value.length());
192

193
    if (static_cast<ssize_t>(selected_crumb_ref.c_search_placeholder.size())
×
194
        > width)
×
195
    {
196
        width = selected_crumb_ref.c_search_placeholder.size();
×
197
    }
198
    this->bc_match_view.set_height(vis_line_t(
×
199
        std::min(this->bc_match_source.get_lines().size() + 1, size_t{4})));
×
200
    this->bc_match_view.set_width(width + 3);
×
201
    this->bc_match_view.set_needs_update();
×
202
    this->bc_match_view.set_selection(
×
203
        vis_line_t(selected_value.value_or(-1_vl)));
×
204
    if (selected_value) {
×
205
        this->bc_match_view.set_top(vis_line_t(selected_value.value()));
×
206
    }
207
    this->bc_match_view.reload_data();
×
208
    this->set_needs_update();
×
209
}
210

211
void
212
breadcrumb_curses::focus()
×
213
{
214
    this->bc_match_view.set_y(this->vc_y + 1);
×
215
    this->bc_focused_crumbs = this->bc_line_source();
×
216
    if (this->bc_focused_crumbs.empty()) {
×
217
        return;
×
218
    }
219

220
    this->bc_current_search.clear();
×
221
    if (this->bc_last_selected_crumb
×
222
        && this->bc_last_selected_crumb.value()
×
223
            >= this->bc_focused_crumbs.size())
×
224
    {
225
        this->bc_last_selected_crumb = this->bc_focused_crumbs.size() - 1;
×
226
    }
227
    this->bc_selected_crumb = this->bc_last_selected_crumb.value_or(0);
×
228
    this->reload_data();
×
229
}
230

231
void
232
breadcrumb_curses::blur()
×
233
{
234
    this->bc_last_selected_crumb = this->bc_selected_crumb;
×
235
    this->bc_focused_crumbs.clear();
×
236
    this->bc_selected_crumb = std::nullopt;
×
237
    this->bc_current_search.clear();
×
238
    this->bc_match_view.set_height(0_vl);
×
239
    this->bc_match_view.set_selection(-1_vl);
×
240
    this->bc_match_source.clear();
×
241
    this->set_needs_update();
×
242
}
243

244
void
245
breadcrumb_curses::focus_next()
×
246
{
247
    this->blur();
×
248
    this->focus();
×
249
    this->reload_data();
×
250
    if (this->bc_selected_crumb.value() < this->bc_focused_crumbs.size() - 1) {
×
251
        this->bc_selected_crumb = this->bc_selected_crumb.value() + 1;
×
252
    }
253
    this->bc_current_search.clear();
×
254
    this->reload_data();
×
255
}
256

257
bool
258
breadcrumb_curses::handle_key(const ncinput& ch)
×
259
{
260
    bool retval = false;
×
261
    auto mapped_id = ch.id;
×
262

263
    if (mapped_id == NCKEY_TAB && ncinput_shift_p(&ch)) {
×
264
        mapped_id = NCKEY_LEFT;
×
265
    } else if (ncinput_ctrl_p(&ch)) {
×
266
        switch (mapped_id) {
×
267
            case 'a':
×
268
            case 'A':
269
                mapped_id = KEY_CTRL('a');
×
270
                break;
×
271
            case 'e':
×
272
            case 'E':
273
                mapped_id = KEY_CTRL('e');
×
274
                break;
×
275
        }
276
    }
277
    switch (mapped_id) {
×
278
        case KEY_CTRL('a'):
×
279
            if (this->bc_selected_crumb) {
×
280
                this->bc_selected_crumb = 0;
×
281
                this->bc_current_search.clear();
×
282
                this->reload_data();
×
283
            }
284
            retval = true;
×
285
            break;
×
286
        case KEY_CTRL('e'):
×
287
            if (this->bc_selected_crumb) {
×
288
                this->bc_selected_crumb = this->bc_focused_crumbs.size() - 1;
×
289
                this->bc_current_search.clear();
×
290
                this->reload_data();
×
291
            }
292
            retval = true;
×
293
            break;
×
294
        case NCKEY_LEFT:
×
295
            if (this->bc_selected_crumb) {
×
296
                if (this->bc_selected_crumb.value() > 0) {
×
297
                    this->bc_selected_crumb
298
                        = this->bc_selected_crumb.value() - 1;
×
299
                } else {
300
                    this->bc_selected_crumb
301
                        = this->bc_focused_crumbs.size() - 1;
×
302
                }
303
                this->bc_current_search.clear();
×
304
                this->reload_data();
×
305
            }
306
            retval = true;
×
307
            break;
×
308
        case NCKEY_TAB:
×
309
        case NCKEY_RIGHT:
310
            if (this->bc_selected_crumb) {
×
311
                if (!this->perform_selection(perform_behavior_t::if_different))
×
312
                {
313
                    this->focus_next();
×
314
                }
315
            }
316
            retval = true;
×
317
            break;
×
318
        case NCKEY_HOME:
×
319
            this->bc_match_view.set_selection(0_vl);
×
320
            retval = true;
×
321
            break;
×
322
        case NCKEY_END:
×
323
            this->bc_match_view.set_selection(
×
324
                this->bc_match_view.get_inner_height() - 1_vl);
×
325
            retval = true;
×
326
            break;
×
327
        case NCKEY_PGDOWN:
×
328
            this->bc_match_view.shift_selection(
×
329
                listview_curses::shift_amount_t::down_page);
330
            retval = true;
×
331
            break;
×
332
        case NCKEY_PGUP:
×
333
            this->bc_match_view.shift_selection(
×
334
                listview_curses::shift_amount_t::up_page);
335
            retval = true;
×
336
            break;
×
337
        case NCKEY_UP:
×
338
            this->bc_match_view.shift_selection(
×
339
                listview_curses::shift_amount_t::up_line);
340
            retval = true;
×
341
            break;
×
342
        case NCKEY_DOWN:
×
343
            this->bc_match_view.shift_selection(
×
344
                listview_curses::shift_amount_t::down_line);
345
            retval = true;
×
346
            break;
×
347
        case KEY_DELETE:
×
348
        case NCKEY_BACKSPACE:
349
            if (!this->bc_current_search.empty()) {
×
350
                this->bc_current_search.pop_back();
×
351
                this->reload_data();
×
352
            }
353
            retval = true;
×
354
            break;
×
355
        case NCKEY_ENTER:
×
356
        case '\r':
357
            this->perform_selection(perform_behavior_t::always);
×
358
            break;
×
359
        case KEY_ESCAPE:
×
360
            break;
×
361
        default:
×
362
            if (ch.id < 0x7f && isprint(ch.id)) {
×
363
                this->bc_current_search.push_back(ch.id);
×
364
                this->reload_data();
×
365
                retval = true;
×
366
            }
367
            break;
×
368
    }
369

370
    if (!retval) {
×
371
        this->blur();
×
372
    }
373
    this->set_needs_update();
×
374
    return retval;
×
375
}
376

377
bool
378
breadcrumb_curses::perform_selection(perform_behavior_t behavior)
×
379
{
380
    auto retval = false;
×
381

382
    if (!this->bc_selected_crumb) {
×
383
        return retval;
×
384
    }
385

386
    auto& selected_crumb_ref
387
        = this->bc_focused_crumbs[this->bc_selected_crumb.value()];
×
388
    auto match_sel = this->bc_match_view.get_selection();
×
389
    if (match_sel.has_value()
×
390
        && match_sel.value() < vis_line_t(this->bc_similar_values.size()))
×
391
    {
392
        const auto& new_value
393
            = this->bc_similar_values[match_sel.value()].p_key;
×
394

395
        switch (behavior) {
×
396
            case perform_behavior_t::if_different:
×
397
                if (breadcrumb::crumb::key_t{new_value}
×
398
                    == selected_crumb_ref.c_key)
×
399
                {
400
                    return retval;
×
401
                }
402
                break;
×
403
            case perform_behavior_t::always:
×
404
                break;
×
405
        }
406
        if (this->bc_perform_handler) {
×
407
            this->bc_perform_handler(
×
408
                *this, selected_crumb_ref.c_performer, new_value);
×
409
        }
410

411
        retval = true;
×
412
    } else if (!this->bc_current_search.empty()) {
×
413
        switch (selected_crumb_ref.c_expected_input) {
×
414
            case breadcrumb::crumb::expected_input_t::exact:
×
415
                break;
×
416
            case breadcrumb::crumb::expected_input_t::index:
×
417
            case breadcrumb::crumb::expected_input_t::index_or_exact: {
418
                size_t index;
419

420
                if (sscanf(this->bc_current_search.c_str(), "%zu", &index) == 1)
×
421
                {
422
                    selected_crumb_ref.c_performer(index);
×
423
                }
424
                break;
×
425
            }
426
            case breadcrumb::crumb::expected_input_t::anything:
×
427
                if (this->bc_perform_handler) {
×
428
                    this->bc_perform_handler(*this,
×
429
                                             selected_crumb_ref.c_performer,
×
430
                                             this->bc_current_search);
×
431
                }
432
                break;
×
433
        }
434
        retval = true;
×
435
    }
436

437
    return retval;
×
438
}
439

440
bool
441
breadcrumb_curses::search_overlay_source::list_static_overlay(
×
442
    const listview_curses& lv,
443
    media_t media,
444
    int y,
445
    int bottom,
446
    attr_line_t& value_out)
447
{
448
    if (y != 0) {
×
449
        return false;
×
450
    }
451
    auto* parent = this->sos_parent;
×
452
    auto sel_opt = parent->bc_focused_crumbs
×
453
        | lnav::itertools::nth(parent->bc_selected_crumb);
×
454
    auto exp_input = sel_opt
455
        | lnav::itertools::map(&breadcrumb::crumb::c_expected_input)
×
456
        | lnav::itertools::unwrap_or(
×
457
                         breadcrumb::crumb::expected_input_t::exact);
458

459
    value_out.with_attr_for_all(VC_STYLE.value(text_attrs::with_underline()));
×
460

461
    if (!parent->bc_current_search.empty()) {
×
462
        value_out = parent->bc_current_search;
×
463

464
        role_t combobox_role = role_t::VCR_STATUS;
×
465
        switch (exp_input) {
×
466
            case breadcrumb::crumb::expected_input_t::exact:
×
467
                if (parent->bc_similar_values.empty()) {
×
468
                    combobox_role = role_t::VCR_ALERT_STATUS;
×
469
                }
470
                break;
×
471
            case breadcrumb::crumb::expected_input_t::index: {
×
472
                size_t index;
473

474
                if (sscanf(parent->bc_current_search.c_str(), "%zu", &index)
×
475
                        != 1
476
                    || index < 0
477
                    || (index
×
478
                        >= (sel_opt | lnav::itertools::map([](const auto& cr) {
×
479
                                return cr->c_possible_range.value_or(0);
×
480
                            })
481
                            | lnav::itertools::unwrap_or(size_t{0}))))
×
482
                {
483
                    combobox_role = role_t::VCR_ALERT_STATUS;
×
484
                }
485
                break;
×
486
            }
487
            case breadcrumb::crumb::expected_input_t::index_or_exact: {
×
488
                size_t index;
489

490
                if (sscanf(parent->bc_current_search.c_str(), "%zu", &index)
×
491
                    == 1)
×
492
                {
493
                    if (index < 0
×
494
                        || (index
×
495
                            >= (sel_opt
×
496
                                | lnav::itertools::map([](const auto& cr) {
×
497
                                      return cr->c_possible_range.value_or(0);
×
498
                                  })
499
                                | lnav::itertools::unwrap_or(size_t{0}))))
×
500
                    {
501
                        combobox_role = role_t::VCR_ALERT_STATUS;
×
502
                    }
503
                } else if (parent->bc_similar_values.empty()) {
×
504
                    combobox_role = role_t::VCR_ALERT_STATUS;
×
505
                }
506
                break;
×
507
            }
508
            case breadcrumb::crumb::expected_input_t::anything:
×
509
                break;
×
510
        }
511
        value_out.with_attr_for_all(VC_ROLE.value(combobox_role));
×
512
        return true;
×
513
    }
514
    if (parent->bc_selected_crumb) {
×
515
        auto& selected_crumb_ref
516
            = parent->bc_focused_crumbs[parent->bc_selected_crumb.value()];
×
517

518
        if (!selected_crumb_ref.c_search_placeholder.empty()) {
×
519
            value_out = selected_crumb_ref.c_search_placeholder;
×
520
            value_out.with_attr_for_all(
×
521
                VC_ROLE.value(role_t::VCR_INACTIVE_STATUS));
×
522
            return true;
×
523
        }
524
    }
525

526
    return false;
×
527
}
528

529
bool
530
breadcrumb_curses::handle_mouse(mouse_event& me)
×
531
{
532
    if (me.me_state == mouse_button_state_t::BUTTON_STATE_PRESSED
×
533
        && this->bc_focused_crumbs.empty())
×
534
    {
535
        this->focus();
×
536
        this->on_focus(*this);
×
537
        this->do_update();
×
538
        this->bc_initial_mouse_event = true;
×
539
    }
540

541
    auto find_res = this->bc_displayed_crumbs
×
542
        | lnav::itertools::find_if([&me](const auto& elem) {
×
543
                        return me.me_button == mouse_button_t::BUTTON_LEFT
×
544
                            && elem.dc_range.contains(me.me_x);
×
545
                    });
×
546

547
    if (!this->bc_focused_crumbs.empty()) {
×
548
        if (me.me_y > 0 || !find_res
×
549
            || find_res.value()->dc_index == this->bc_selected_crumb)
×
550
        {
551
            if (view_curses::handle_mouse(me)) {
×
552
                if (me.me_y > 0
×
553
                    && (me.me_state
×
554
                            == mouse_button_state_t::BUTTON_STATE_DOUBLE_CLICK
555
                        || me.me_state
×
556
                            == mouse_button_state_t::BUTTON_STATE_RELEASED))
557
                {
558
                    this->perform_selection(perform_behavior_t::if_different);
×
559
                    this->blur();
×
560
                    this->reload_data();
×
561
                    this->on_blur(*this);
×
562
                }
563
                return true;
×
564
            }
565
        }
566
        if (!this->bc_initial_mouse_event
×
567
            && me.me_state == mouse_button_state_t::BUTTON_STATE_RELEASED
×
568
            && me.me_y == 0 && find_res
×
569
            && find_res.value()->dc_index == this->bc_selected_crumb.value())
×
570
        {
571
            this->blur();
×
572
            this->reload_data();
×
573
            this->on_blur(*this);
×
574
            return true;
×
575
        }
576
    }
577

578
    if (me.me_state == mouse_button_state_t::BUTTON_STATE_RELEASED) {
×
579
        this->bc_initial_mouse_event = false;
×
580
    }
581

582
    if (me.me_y != 0) {
×
583
        return true;
×
584
    }
585

586
    if (find_res) {
×
587
        auto crumb_index = find_res.value()->dc_index;
×
588

589
        if (this->bc_selected_crumb) {
×
590
            this->blur();
×
591
            this->focus();
×
592
            this->reload_data();
×
593
            this->bc_selected_crumb = crumb_index;
×
594
            this->bc_current_search.clear();
×
595
            this->reload_data();
×
596
        }
597
    }
598

599
    return true;
×
600
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc