jnav 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jnav/__init__.py +0 -0
- jnav/__main__.py +108 -0
- jnav/app.py +398 -0
- jnav/buffer.py +48 -0
- jnav/detail_tree.py +242 -0
- jnav/field_mapping.py +136 -0
- jnav/filter_manager_screen.py +100 -0
- jnav/filter_provider.py +103 -0
- jnav/filter_tree.py +344 -0
- jnav/filtering.py +191 -0
- jnav/header.py +48 -0
- jnav/help_screen.py +79 -0
- jnav/inline_tree.py +75 -0
- jnav/key_sequences.py +211 -0
- jnav/log_entry_item.py +96 -0
- jnav/log_entry_renderer.py +90 -0
- jnav/log_list_view.py +292 -0
- jnav/log_model.py +114 -0
- jnav/logging.py +52 -0
- jnav/manager_screen_common.py +27 -0
- jnav/model.py +13 -0
- jnav/offset_group.py +30 -0
- jnav/parsing.py +101 -0
- jnav/reading.py +52 -0
- jnav/role_mapper.py +53 -0
- jnav/scrollbar_overlay.py +79 -0
- jnav/search_engine.py +87 -0
- jnav/selector_manager_screen.py +187 -0
- jnav/selector_provider.py +64 -0
- jnav/store.py +37 -0
- jnav/text_input_screen.py +63 -0
- jnav/tree_rendering.py +194 -0
- jnav/virtual_list_view.py +383 -0
- jnav-1.0.0.dist-info/METADATA +186 -0
- jnav-1.0.0.dist-info/RECORD +37 -0
- jnav-1.0.0.dist-info/WHEEL +4 -0
- jnav-1.0.0.dist-info/entry_points.txt +3 -0
jnav/__init__.py
ADDED
|
File without changes
|
jnav/__main__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import aioreactive as rx
|
|
10
|
+
import click
|
|
11
|
+
from platformdirs import user_data_dir
|
|
12
|
+
|
|
13
|
+
from jnav.filter_provider import FilterProvider
|
|
14
|
+
from jnav.log_model import LogModel
|
|
15
|
+
from jnav.role_mapper import RoleMapper
|
|
16
|
+
from jnav.search_engine import SearchEngine
|
|
17
|
+
from jnav.selector_provider import SelectorProvider
|
|
18
|
+
from jnav.store import Store
|
|
19
|
+
|
|
20
|
+
from .app import JnavApp
|
|
21
|
+
from .buffer import buffer_time_or_count
|
|
22
|
+
from .logging import init_logging
|
|
23
|
+
from .parsing import ParsedEntry, parse_entry
|
|
24
|
+
from .reading import read_file, read_pipe, setup_stdin_pipe
|
|
25
|
+
|
|
26
|
+
logging.getLogger("aioreactive").setLevel(logging.WARNING)
|
|
27
|
+
|
|
28
|
+
_STATE_DIR = Path(user_data_dir("jnav"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _state_file_for(file_path: str) -> Path:
|
|
32
|
+
key = hashlib.sha256(str(Path(file_path).resolve()).encode()).hexdigest()[:16]
|
|
33
|
+
return _STATE_DIR / f"{key}.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_input_iterator(file: str | None) -> AsyncIterator[str] | None:
|
|
37
|
+
if file:
|
|
38
|
+
return read_file(file)
|
|
39
|
+
elif not sys.stdin.isatty():
|
|
40
|
+
pipe = setup_stdin_pipe()
|
|
41
|
+
return read_pipe(pipe)
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run(file: str | None, follow: bool) -> None:
|
|
47
|
+
lines = _get_input_iterator(file)
|
|
48
|
+
|
|
49
|
+
if lines is None:
|
|
50
|
+
click.echo("Usage: jnav [FILE] or pipe JSONL via stdin", err=True)
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
|
|
53
|
+
filter_provider = FilterProvider()
|
|
54
|
+
role_mapper = RoleMapper()
|
|
55
|
+
selectors = SelectorProvider()
|
|
56
|
+
store = Store()
|
|
57
|
+
model = LogModel(
|
|
58
|
+
store=store,
|
|
59
|
+
filter_provider=filter_provider,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
search = SearchEngine(model)
|
|
63
|
+
|
|
64
|
+
await model.start()
|
|
65
|
+
await search.start()
|
|
66
|
+
|
|
67
|
+
entry_stream = rx.from_async_iterable(
|
|
68
|
+
buffer_time_or_count(
|
|
69
|
+
rx.pipe(
|
|
70
|
+
rx.from_async_iterable(lines),
|
|
71
|
+
rx.map(lambda line: parse_entry(line)),
|
|
72
|
+
rx.filter(lambda result: result is not None),
|
|
73
|
+
rx.map(lambda entry: cast(ParsedEntry, entry)),
|
|
74
|
+
),
|
|
75
|
+
max_count=10000,
|
|
76
|
+
timeout=0.1,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
await entry_stream.subscribe_async(store.append_entries)
|
|
81
|
+
|
|
82
|
+
state_file = _state_file_for(file) if file else None
|
|
83
|
+
app = JnavApp(
|
|
84
|
+
model=model,
|
|
85
|
+
filter_provider=filter_provider,
|
|
86
|
+
role_mapper=role_mapper,
|
|
87
|
+
selectors=selectors,
|
|
88
|
+
search=search,
|
|
89
|
+
state_file=state_file,
|
|
90
|
+
follow=follow,
|
|
91
|
+
file_name=file if file else "<stdin>",
|
|
92
|
+
)
|
|
93
|
+
app.title = "jnav"
|
|
94
|
+
await app.run_async()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.command()
|
|
98
|
+
@click.argument("file", required=False, type=click.Path(exists=True))
|
|
99
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow the file for new entries")
|
|
100
|
+
@click.version_option()
|
|
101
|
+
def main(file: str | None, follow: bool) -> None:
|
|
102
|
+
"""Interactive JSON log viewer with jq filtering."""
|
|
103
|
+
init_logging()
|
|
104
|
+
asyncio.run(_run(file, follow=follow))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
jnav/app.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import ClassVar, override
|
|
5
|
+
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.theme import Theme
|
|
11
|
+
from textual.widgets import Footer, Static
|
|
12
|
+
|
|
13
|
+
from jnav.filter_manager_screen import FilterManagerScreen
|
|
14
|
+
from jnav.filter_provider import FilterProvider
|
|
15
|
+
from jnav.header import Header
|
|
16
|
+
from jnav.help_screen import HelpScreen
|
|
17
|
+
from jnav.log_model import LogModel
|
|
18
|
+
from jnav.role_mapper import RoleMapper
|
|
19
|
+
from jnav.search_engine import SearchEngine
|
|
20
|
+
from jnav.selector_manager_screen import SelectorManagerScreen
|
|
21
|
+
from jnav.selector_provider import SelectorProvider
|
|
22
|
+
from jnav.text_input_screen import TextInputScreen
|
|
23
|
+
from jnav.virtual_list_view import VirtualListView
|
|
24
|
+
|
|
25
|
+
from .detail_tree import DetailTree
|
|
26
|
+
from .log_list_view import LogListView
|
|
27
|
+
from .store import IndexedEntry
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FilterBar(Static):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JnavApp(App[None]):
|
|
37
|
+
ENABLE_COMMAND_PALETTE = False
|
|
38
|
+
|
|
39
|
+
DEFAULT_CSS = """
|
|
40
|
+
JnavApp Footer {
|
|
41
|
+
background: $surface;
|
|
42
|
+
}
|
|
43
|
+
JnavApp FooterKey {
|
|
44
|
+
margin: 0 1 0 0;
|
|
45
|
+
}
|
|
46
|
+
JnavApp FooterKey .footer-key--key {
|
|
47
|
+
color: $primary;
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
CSS = """
|
|
52
|
+
* {
|
|
53
|
+
scrollbar-size-vertical: 1;
|
|
54
|
+
scrollbar-color: $surface-lighten-2;
|
|
55
|
+
scrollbar-color-hover: $surface-lighten-3;
|
|
56
|
+
scrollbar-color-active: $foreground-darken-2;
|
|
57
|
+
scrollbar-background: $surface;
|
|
58
|
+
scrollbar-background-hover: $surface;
|
|
59
|
+
border-title-color: $primary;
|
|
60
|
+
}
|
|
61
|
+
ModalScreen {
|
|
62
|
+
background: $background 0%;
|
|
63
|
+
}
|
|
64
|
+
HeaderIcon { display: none; }
|
|
65
|
+
Header.-tall { height: 1; }
|
|
66
|
+
.tree--key { color: $primary; text-style: italic; }
|
|
67
|
+
.tree--key-selected { color: $primary; text-style: bold; }
|
|
68
|
+
.tree--value { color: $foreground; }
|
|
69
|
+
.tree--value-null { color: $foreground; text-style: dim italic; }
|
|
70
|
+
.tree--json-string { color: $warning; text-style: italic; }
|
|
71
|
+
.tree--search-highlight { color: $background; background: $accent; }
|
|
72
|
+
.summary--search-highlight { color: $background; background: $accent; }
|
|
73
|
+
#content-area {
|
|
74
|
+
height: 1fr;
|
|
75
|
+
}
|
|
76
|
+
#filter-bar {
|
|
77
|
+
height: 1;
|
|
78
|
+
padding: 0 1;
|
|
79
|
+
color: $text-muted;
|
|
80
|
+
background: $background;
|
|
81
|
+
}
|
|
82
|
+
#log-panel {
|
|
83
|
+
opacity: 0.75;
|
|
84
|
+
}
|
|
85
|
+
#log-panel.focused {
|
|
86
|
+
opacity: 1.0;
|
|
87
|
+
}
|
|
88
|
+
#log-panel.focused > #filter-bar {
|
|
89
|
+
color: $primary;
|
|
90
|
+
}
|
|
91
|
+
#log-list:focus {
|
|
92
|
+
background-tint: transparent;
|
|
93
|
+
}
|
|
94
|
+
#detail-panel {
|
|
95
|
+
width: 40%;
|
|
96
|
+
border: round $background-lighten-2;
|
|
97
|
+
border-title-align: center;
|
|
98
|
+
background: $background;
|
|
99
|
+
}
|
|
100
|
+
#detail-panel.focused {
|
|
101
|
+
border: round $primary;
|
|
102
|
+
}
|
|
103
|
+
#detail-tree {
|
|
104
|
+
background: $background;
|
|
105
|
+
}
|
|
106
|
+
#detail-tree:focus {
|
|
107
|
+
background-tint: transparent;
|
|
108
|
+
}
|
|
109
|
+
#detail-tree > .tree--guides {
|
|
110
|
+
color: $background-lighten-2;
|
|
111
|
+
}
|
|
112
|
+
#detail-tree > .tree--guides-selected {
|
|
113
|
+
color: $foreground;
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
118
|
+
Binding("F", "open_filters", "Filters ≡"),
|
|
119
|
+
Binding("S", "open_columns", "Selectors ≡"),
|
|
120
|
+
Binding("d", "toggle_detail", "Toggle Detail"),
|
|
121
|
+
Binding("r", "reset", "Reset"),
|
|
122
|
+
Binding("question_mark", "show_help", "Help", key_display="?"),
|
|
123
|
+
Binding("q", "quit", "Quit", show=False),
|
|
124
|
+
Binding("slash", "start_search", "Search", key_display="/", show=False),
|
|
125
|
+
Binding("h", "focus_list", show=False),
|
|
126
|
+
Binding("l", "focus_detail", show=False),
|
|
127
|
+
Binding("n", "search_next", show=False),
|
|
128
|
+
Binding("N", "search_prev", show=False),
|
|
129
|
+
Binding("escape", "escape", show=False),
|
|
130
|
+
Binding("enter", "inspect", "Inspect", show=False),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
model: LogModel,
|
|
136
|
+
filter_provider: FilterProvider,
|
|
137
|
+
role_mapper: RoleMapper,
|
|
138
|
+
selectors: SelectorProvider,
|
|
139
|
+
search: SearchEngine,
|
|
140
|
+
file_name: str,
|
|
141
|
+
state_file: Path | None = None,
|
|
142
|
+
follow: bool = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
super().__init__()
|
|
145
|
+
self.register_theme(
|
|
146
|
+
Theme(
|
|
147
|
+
name="jnav",
|
|
148
|
+
primary="#339af0",
|
|
149
|
+
accent="#20c997",
|
|
150
|
+
error="#ff6b6b",
|
|
151
|
+
warning="#ff922b",
|
|
152
|
+
success="#20c997",
|
|
153
|
+
surface="#15191d",
|
|
154
|
+
background="#212529",
|
|
155
|
+
foreground="#f8fafb",
|
|
156
|
+
dark=True,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
self.theme = "jnav"
|
|
160
|
+
self._model = model
|
|
161
|
+
self._filter_provider = filter_provider
|
|
162
|
+
self._role_mapper = role_mapper
|
|
163
|
+
self._selectors = selectors
|
|
164
|
+
self._search = search
|
|
165
|
+
self._search_pos: int = -1
|
|
166
|
+
self._expanded_mode: bool = True
|
|
167
|
+
self._state_file: Path | None = state_file
|
|
168
|
+
self._detail_visible_on_load: bool = False
|
|
169
|
+
self._show_selected_only_on_load: bool = False
|
|
170
|
+
self._start_following = follow
|
|
171
|
+
self._file_name = file_name
|
|
172
|
+
self.sub_title = file_name
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def compose(self) -> ComposeResult:
|
|
176
|
+
yield Header(self._file_name)
|
|
177
|
+
yield Horizontal(
|
|
178
|
+
Vertical(
|
|
179
|
+
LogListView(
|
|
180
|
+
model=self._model,
|
|
181
|
+
role_mapper=self._role_mapper,
|
|
182
|
+
selectors=self._selectors,
|
|
183
|
+
search=self._search,
|
|
184
|
+
filter_provider=self._filter_provider,
|
|
185
|
+
id="log-list",
|
|
186
|
+
follow=self._start_following,
|
|
187
|
+
),
|
|
188
|
+
FilterBar(id="filter-bar"),
|
|
189
|
+
id="log-panel",
|
|
190
|
+
),
|
|
191
|
+
Vertical(
|
|
192
|
+
DetailTree(
|
|
193
|
+
"entry",
|
|
194
|
+
selectors=self._selectors,
|
|
195
|
+
filters=self._filter_provider,
|
|
196
|
+
search=self._search,
|
|
197
|
+
role_mapper=self._role_mapper,
|
|
198
|
+
id="detail-tree",
|
|
199
|
+
),
|
|
200
|
+
id="detail-panel",
|
|
201
|
+
),
|
|
202
|
+
id="content-area",
|
|
203
|
+
)
|
|
204
|
+
yield Footer()
|
|
205
|
+
|
|
206
|
+
async def on_mount(self) -> None:
|
|
207
|
+
await self._load_state()
|
|
208
|
+
|
|
209
|
+
await self._model.on_append.subscribe_async(self._on_entries_changed)
|
|
210
|
+
await self._model.on_rebuild.subscribe_async(self._on_entries_changed)
|
|
211
|
+
await self._search.on_change.subscribe_async(self._on_search_changed)
|
|
212
|
+
|
|
213
|
+
lv = self.query_one("#log-list", LogListView)
|
|
214
|
+
lv.set_expanded_mode(self._expanded_mode)
|
|
215
|
+
lv.focus()
|
|
216
|
+
|
|
217
|
+
detail_panel = self.query_one("#detail-panel")
|
|
218
|
+
detail_panel.border_title = "Detail"
|
|
219
|
+
detail_panel.display = self._detail_visible_on_load
|
|
220
|
+
detail_tree = self.query_one("#detail-tree", DetailTree)
|
|
221
|
+
detail_tree.show_selected_only = self._show_selected_only_on_load
|
|
222
|
+
|
|
223
|
+
self.call_after_refresh(self._initial_build)
|
|
224
|
+
|
|
225
|
+
async def _initial_build(self) -> None:
|
|
226
|
+
await self.query_one("#log-list", LogListView).initial_build()
|
|
227
|
+
self._update_filter_bar()
|
|
228
|
+
|
|
229
|
+
async def _load_state(self) -> None:
|
|
230
|
+
if not self._state_file or not self._state_file.exists():
|
|
231
|
+
return
|
|
232
|
+
try:
|
|
233
|
+
state = json.loads(self._state_file.read_text())
|
|
234
|
+
except json.JSONDecodeError, OSError:
|
|
235
|
+
return
|
|
236
|
+
if "filter_tree" in state:
|
|
237
|
+
await self._filter_provider.load_root(state["filter_tree"])
|
|
238
|
+
await self._selectors.set_selectors(state.get("selectors", []))
|
|
239
|
+
await self._role_mapper.set_mapping(state.get("role_mapping"))
|
|
240
|
+
self._expanded_mode = state.get("expanded_mode", False)
|
|
241
|
+
await self._model.set_filtering_enabled(not state.get("filters_paused", False))
|
|
242
|
+
await self._search.set_term(state.get("search_term", ""))
|
|
243
|
+
self._detail_visible_on_load = state.get("detail_visible", False)
|
|
244
|
+
self._show_selected_only_on_load = state.get("show_selected_only", False)
|
|
245
|
+
|
|
246
|
+
def _save_state(self) -> None:
|
|
247
|
+
if not self._state_file:
|
|
248
|
+
return
|
|
249
|
+
detail = self.query_one("#detail-tree", DetailTree)
|
|
250
|
+
panel = self.query_one("#detail-panel")
|
|
251
|
+
lv = self.query_one("#log-list", LogListView)
|
|
252
|
+
state = {
|
|
253
|
+
"filter_tree": self._filter_provider.dump_root(),
|
|
254
|
+
"selectors": self._selectors.selectors,
|
|
255
|
+
"role_mapping": self._role_mapper.mapping.model_dump(),
|
|
256
|
+
"expanded_mode": lv.expanded_mode,
|
|
257
|
+
"filters_paused": self._model.filtering_enabled is False,
|
|
258
|
+
"search_term": self._search.term,
|
|
259
|
+
"entry_index": lv.current_index(),
|
|
260
|
+
"detail_visible": panel.display,
|
|
261
|
+
"show_selected_only": detail.show_selected_only,
|
|
262
|
+
}
|
|
263
|
+
try:
|
|
264
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
self._state_file.write_text(json.dumps(state))
|
|
266
|
+
except OSError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
async def _on_entries_changed(self, _: object) -> None:
|
|
270
|
+
self._update_filter_bar()
|
|
271
|
+
|
|
272
|
+
async def _on_search_changed(self, _: None) -> None:
|
|
273
|
+
self._search_pos = -1
|
|
274
|
+
self._update_filter_bar()
|
|
275
|
+
|
|
276
|
+
def _update_filter_bar(self) -> None:
|
|
277
|
+
bar = self.query_one("#filter-bar", FilterBar)
|
|
278
|
+
total = self._model.total_count()
|
|
279
|
+
shown = len(self._model.visible_indices)
|
|
280
|
+
n_cols = sum(1 for s in self._selectors.selectors if s["enabled"])
|
|
281
|
+
|
|
282
|
+
parts: list[str] = [f"Showing {shown}/{total}"]
|
|
283
|
+
if n_cols:
|
|
284
|
+
parts.append(f"{n_cols} field{'s' if n_cols != 1 else ''}")
|
|
285
|
+
if self._search.term:
|
|
286
|
+
total = len(self._search.matches)
|
|
287
|
+
pos = self._search_pos + 1 if total else 0
|
|
288
|
+
parts.append(f"/{self._search.term} ({pos}/{total})")
|
|
289
|
+
|
|
290
|
+
bar.update(" \u2502 ".join(parts))
|
|
291
|
+
|
|
292
|
+
@override
|
|
293
|
+
async def action_quit(self) -> None:
|
|
294
|
+
self._save_state()
|
|
295
|
+
self.workers.cancel_all()
|
|
296
|
+
self.exit()
|
|
297
|
+
|
|
298
|
+
def _focus_main(self) -> None:
|
|
299
|
+
self.query_one("#log-list", LogListView).focus()
|
|
300
|
+
|
|
301
|
+
def action_open_filters(self) -> None:
|
|
302
|
+
self.push_screen(FilterManagerScreen(self._filter_provider))
|
|
303
|
+
|
|
304
|
+
def action_open_columns(self) -> None:
|
|
305
|
+
self.push_screen(SelectorManagerScreen(self._selectors))
|
|
306
|
+
|
|
307
|
+
def action_start_search(self) -> None:
|
|
308
|
+
async def on_dismiss(term: str | None) -> None:
|
|
309
|
+
if not term:
|
|
310
|
+
return
|
|
311
|
+
await self._search.set_term(term)
|
|
312
|
+
if self._search.matches:
|
|
313
|
+
self._search_pos = 0
|
|
314
|
+
self.query_one("#log-list", LogListView).jump_to_index(
|
|
315
|
+
self._search.matches[0]
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
self.notify("No matches found", timeout=2)
|
|
319
|
+
|
|
320
|
+
self.push_screen(TextInputScreen(), on_dismiss)
|
|
321
|
+
|
|
322
|
+
def action_search_next(self) -> None:
|
|
323
|
+
if not self._search.matches:
|
|
324
|
+
return
|
|
325
|
+
lv = self.query_one("#log-list", LogListView)
|
|
326
|
+
current = lv.current_index()
|
|
327
|
+
for i, store_idx in enumerate(self._search.matches):
|
|
328
|
+
if store_idx > current:
|
|
329
|
+
self._search_pos = i
|
|
330
|
+
lv.jump_to_index(store_idx)
|
|
331
|
+
self._update_filter_bar()
|
|
332
|
+
return
|
|
333
|
+
self.notify("No more matches", timeout=1)
|
|
334
|
+
|
|
335
|
+
def action_search_prev(self) -> None:
|
|
336
|
+
if not self._search.matches:
|
|
337
|
+
return
|
|
338
|
+
lv = self.query_one("#log-list", LogListView)
|
|
339
|
+
current = lv.current_index()
|
|
340
|
+
for i in range(len(self._search.matches) - 1, -1, -1):
|
|
341
|
+
if self._search.matches[i] < current:
|
|
342
|
+
self._search_pos = i
|
|
343
|
+
lv.jump_to_index(self._search.matches[i])
|
|
344
|
+
self._update_filter_bar()
|
|
345
|
+
return
|
|
346
|
+
self.notify("No more matches", timeout=1)
|
|
347
|
+
|
|
348
|
+
def action_toggle_detail(self) -> None:
|
|
349
|
+
panel = self.query_one("#detail-panel")
|
|
350
|
+
if panel.display:
|
|
351
|
+
panel.display = False
|
|
352
|
+
self._focus_main()
|
|
353
|
+
else:
|
|
354
|
+
panel.display = True
|
|
355
|
+
|
|
356
|
+
def action_inspect(self) -> None:
|
|
357
|
+
lv = self.query_one("#log-list", LogListView)
|
|
358
|
+
if self.focused != lv:
|
|
359
|
+
return
|
|
360
|
+
panel = self.query_one("#detail-panel")
|
|
361
|
+
if not panel.display:
|
|
362
|
+
panel.display = True
|
|
363
|
+
self.query_one("#detail-tree", DetailTree).focus()
|
|
364
|
+
|
|
365
|
+
async def action_reset(self) -> None:
|
|
366
|
+
await self._selectors.clear_selectors()
|
|
367
|
+
await self._search.clear()
|
|
368
|
+
await self._filter_provider.clear_filters()
|
|
369
|
+
self.notify("Filters and fields cleared", timeout=2)
|
|
370
|
+
|
|
371
|
+
def action_focus_list(self) -> None:
|
|
372
|
+
if self.query_one("#detail-panel").display:
|
|
373
|
+
self.query_one("#log-list", LogListView).focus()
|
|
374
|
+
|
|
375
|
+
def action_focus_detail(self) -> None:
|
|
376
|
+
if self.query_one("#detail-panel").display:
|
|
377
|
+
self.query_one("#detail-tree", DetailTree).focus()
|
|
378
|
+
|
|
379
|
+
def action_show_help(self) -> None:
|
|
380
|
+
self.push_screen(HelpScreen())
|
|
381
|
+
|
|
382
|
+
async def action_escape(self) -> None:
|
|
383
|
+
tree = self.query_one("#detail-tree", DetailTree)
|
|
384
|
+
if self.focused == tree:
|
|
385
|
+
self._focus_main()
|
|
386
|
+
elif self._search.active:
|
|
387
|
+
await self._search.clear()
|
|
388
|
+
|
|
389
|
+
@on(VirtualListView.Highlighted, "#log-list")
|
|
390
|
+
def on_log_highlighted(self, event: VirtualListView.Highlighted) -> None:
|
|
391
|
+
ie = event.item
|
|
392
|
+
if isinstance(ie, IndexedEntry):
|
|
393
|
+
self.query_one("#detail-tree", DetailTree).show_entry(ie.entry, ie.index)
|
|
394
|
+
|
|
395
|
+
@on(VirtualListView.Selected, "#log-list")
|
|
396
|
+
def on_log_selected(self, event: VirtualListView.Selected) -> None:
|
|
397
|
+
del event # unused
|
|
398
|
+
self.action_inspect()
|
jnav/buffer.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterable
|
|
3
|
+
|
|
4
|
+
from aioreactive import AsyncObservable, to_async_iterable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def buffer_time_or_count[T](
|
|
8
|
+
source: AsyncObservable[T],
|
|
9
|
+
max_count: int = 500,
|
|
10
|
+
timeout: float = 0.1,
|
|
11
|
+
) -> AsyncIterable[list[T]]:
|
|
12
|
+
batch: list[T] = []
|
|
13
|
+
iterator = aiter(to_async_iterable(source))
|
|
14
|
+
next_task: asyncio.Task[T] | None = None
|
|
15
|
+
try:
|
|
16
|
+
while True:
|
|
17
|
+
# Create a new task if we don't already have one pending
|
|
18
|
+
if next_task is None:
|
|
19
|
+
next_task = asyncio.ensure_future(anext(iterator))
|
|
20
|
+
|
|
21
|
+
# Wait for either the next item or the timeout
|
|
22
|
+
done, _ = await asyncio.wait({next_task}, timeout=timeout)
|
|
23
|
+
|
|
24
|
+
# If the next item is ready, add it to the batch
|
|
25
|
+
if next_task in done:
|
|
26
|
+
# If the iterator is exhausted, yield any remaining batch and exit
|
|
27
|
+
try:
|
|
28
|
+
entry = next_task.result()
|
|
29
|
+
except StopAsyncIteration:
|
|
30
|
+
if batch:
|
|
31
|
+
yield batch
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Append the item to the batch and reset the next task
|
|
35
|
+
next_task = None
|
|
36
|
+
batch.append(entry)
|
|
37
|
+
|
|
38
|
+
# Yield the batch if we've reached the max count
|
|
39
|
+
if len(batch) >= max_count:
|
|
40
|
+
yield batch
|
|
41
|
+
batch = []
|
|
42
|
+
# If the timeout expired, yield the current batch if it's not empty
|
|
43
|
+
elif batch:
|
|
44
|
+
yield batch
|
|
45
|
+
batch = []
|
|
46
|
+
finally:
|
|
47
|
+
if next_task is not None and not next_task.done():
|
|
48
|
+
next_task.cancel()
|