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/filter_tree.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TYPE_CHECKING, ClassVar, override
|
|
3
|
+
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
from textual.binding import Binding, BindingType
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widgets import Tree
|
|
8
|
+
from textual.widgets.tree import TreeNode
|
|
9
|
+
|
|
10
|
+
from jnav.filter_provider import FilterProvider
|
|
11
|
+
from jnav.filtering import (
|
|
12
|
+
Filter,
|
|
13
|
+
FilterGroup,
|
|
14
|
+
FilterNode,
|
|
15
|
+
build_expression,
|
|
16
|
+
check_filter_warning,
|
|
17
|
+
)
|
|
18
|
+
from jnav.text_input_screen import TextInputScreen
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from textual import getters
|
|
22
|
+
from textual.app import App
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FilterTreeData:
|
|
27
|
+
node: FilterNode
|
|
28
|
+
parent: FilterGroup
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FilterTree(Tree[FilterTreeData]):
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
app = getters.app(App[None])
|
|
34
|
+
|
|
35
|
+
class Changed(Message):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
FilterTree {
|
|
40
|
+
height: auto;
|
|
41
|
+
max-height: 14;
|
|
42
|
+
border: none;
|
|
43
|
+
background: transparent;
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
48
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
49
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
50
|
+
Binding("a", "add_filter", "Add", show=True),
|
|
51
|
+
Binding("e", "edit_filter", "Edit", show=True),
|
|
52
|
+
Binding("d", "delete", "Delete", show=True),
|
|
53
|
+
Binding("t", "toggle_item", "Toggle", show=True),
|
|
54
|
+
Binding("o", "toggle_combine", "AND/OR", show=True),
|
|
55
|
+
Binding("n", "toggle_negated", "Negate", show=True),
|
|
56
|
+
Binding("g", "add_group", "Add group", show=True),
|
|
57
|
+
Binding("p", "paste", "Paste", show=True),
|
|
58
|
+
Binding("c", "flatten", "Flatten", show=True),
|
|
59
|
+
Binding("y", "yank", "Yank", show=True),
|
|
60
|
+
Binding("r", "rename", "Rename", show=True),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
filter_provider: FilterProvider,
|
|
66
|
+
*,
|
|
67
|
+
id: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__("Filters", id=id)
|
|
70
|
+
self._fp = filter_provider
|
|
71
|
+
self._clipboard: FilterNode | None = None
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
self.rebuild()
|
|
76
|
+
|
|
77
|
+
def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[FilterTreeData]) -> None:
|
|
78
|
+
data = event.node.data
|
|
79
|
+
if data is None:
|
|
80
|
+
return
|
|
81
|
+
node = data.node
|
|
82
|
+
if isinstance(node, FilterGroup):
|
|
83
|
+
node.collapsed = True
|
|
84
|
+
event.node.set_label(self._node_label(event.node))
|
|
85
|
+
|
|
86
|
+
def on_tree_node_expanded(self, event: Tree.NodeExpanded[FilterTreeData]) -> None:
|
|
87
|
+
data = event.node.data
|
|
88
|
+
if data is None:
|
|
89
|
+
return
|
|
90
|
+
node = data.node
|
|
91
|
+
if isinstance(node, FilterGroup):
|
|
92
|
+
node.collapsed = False
|
|
93
|
+
event.node.set_label(self._render_label(node))
|
|
94
|
+
|
|
95
|
+
def rebuild(self) -> None:
|
|
96
|
+
saved_line = self.cursor_line
|
|
97
|
+
|
|
98
|
+
self.clear()
|
|
99
|
+
self.root.set_label(self._render_label(self._fp.root))
|
|
100
|
+
self.root.data = FilterTreeData(node=self._fp.root, parent=self._fp.root)
|
|
101
|
+
self._populate(self.root, self._fp.root)
|
|
102
|
+
self.root.expand()
|
|
103
|
+
|
|
104
|
+
if self.cursor_line != saved_line:
|
|
105
|
+
self.cursor_line = min(saved_line, self.last_line)
|
|
106
|
+
self.post_message(self.Changed())
|
|
107
|
+
|
|
108
|
+
def refresh_cursor_node(self) -> None:
|
|
109
|
+
tree_node = self.cursor_node
|
|
110
|
+
if tree_node is not None and tree_node.data is not None:
|
|
111
|
+
tree_node.set_label(self._node_label(tree_node))
|
|
112
|
+
self.post_message(self.Changed())
|
|
113
|
+
|
|
114
|
+
def _node_label(self, tree_node: TreeNode[FilterTreeData]) -> Text:
|
|
115
|
+
data = tree_node.data
|
|
116
|
+
assert data is not None
|
|
117
|
+
node = data.node
|
|
118
|
+
if isinstance(node, FilterGroup) and not tree_node.is_expanded:
|
|
119
|
+
style = "" if node.enabled else "dim strike"
|
|
120
|
+
if node.label:
|
|
121
|
+
label = Text(node.label, style=f"italic {style}")
|
|
122
|
+
else:
|
|
123
|
+
preview = build_expression(
|
|
124
|
+
FilterGroup(operator=node.operator, children=node.children)
|
|
125
|
+
)
|
|
126
|
+
label = Text(preview, style=style) if preview else None
|
|
127
|
+
if label is not None:
|
|
128
|
+
if node.negated:
|
|
129
|
+
return Text.assemble(("NOT ", f"bold {style}"), label)
|
|
130
|
+
return label
|
|
131
|
+
return self._render_label(node)
|
|
132
|
+
|
|
133
|
+
def _populate(
|
|
134
|
+
self,
|
|
135
|
+
tree_parent: TreeNode[FilterTreeData],
|
|
136
|
+
group: FilterGroup,
|
|
137
|
+
) -> None:
|
|
138
|
+
for child in group.children:
|
|
139
|
+
data = FilterTreeData(node=child, parent=group)
|
|
140
|
+
label = self._render_label(child)
|
|
141
|
+
if isinstance(child, FilterGroup):
|
|
142
|
+
branch = tree_parent.add(label, data=data)
|
|
143
|
+
self._populate(branch, child)
|
|
144
|
+
if child.collapsed:
|
|
145
|
+
branch.collapse()
|
|
146
|
+
branch.set_label(self._node_label(branch))
|
|
147
|
+
else:
|
|
148
|
+
branch.expand()
|
|
149
|
+
else:
|
|
150
|
+
tree_parent.add_leaf(label, data=data)
|
|
151
|
+
|
|
152
|
+
def _render_label(self, node: FilterNode) -> Text:
|
|
153
|
+
style = "" if node.enabled else "dim strike"
|
|
154
|
+
if isinstance(node, FilterGroup):
|
|
155
|
+
op_hint = (
|
|
156
|
+
("", "") if not node.label else (f" ({node.operator.upper()})", "dim")
|
|
157
|
+
)
|
|
158
|
+
if node.label:
|
|
159
|
+
label_style = f"italic {style}"
|
|
160
|
+
if node.negated:
|
|
161
|
+
return Text.assemble(
|
|
162
|
+
("NOT ", f"bold {style}"),
|
|
163
|
+
(node.label, label_style),
|
|
164
|
+
op_hint,
|
|
165
|
+
)
|
|
166
|
+
return Text.assemble((node.label, label_style), op_hint)
|
|
167
|
+
if node.negated:
|
|
168
|
+
op = "NAND" if node.operator == "and" else "NOR"
|
|
169
|
+
else:
|
|
170
|
+
op = node.operator.upper()
|
|
171
|
+
return Text(op, style=style)
|
|
172
|
+
|
|
173
|
+
if node.label:
|
|
174
|
+
display_style = f"italic {style}"
|
|
175
|
+
display = node.label
|
|
176
|
+
else:
|
|
177
|
+
display_style = style
|
|
178
|
+
display = node.expr
|
|
179
|
+
if node.negated:
|
|
180
|
+
return Text.assemble(("NOT ", f"bold {style}"), (display, display_style))
|
|
181
|
+
return Text(display, style=display_style)
|
|
182
|
+
|
|
183
|
+
def _cursor_data(self) -> FilterTreeData | None:
|
|
184
|
+
node = self.cursor_node
|
|
185
|
+
if node is None or node.data is None:
|
|
186
|
+
return None
|
|
187
|
+
return node.data
|
|
188
|
+
|
|
189
|
+
def _insert_at_cursor(self) -> tuple[FilterGroup, int | None]:
|
|
190
|
+
data = self._cursor_data()
|
|
191
|
+
if data is not None:
|
|
192
|
+
cursor_node = data.node
|
|
193
|
+
parent = data.parent
|
|
194
|
+
if isinstance(cursor_node, FilterGroup):
|
|
195
|
+
return (cursor_node, None)
|
|
196
|
+
return (parent, parent.children.index(cursor_node))
|
|
197
|
+
return (self._fp.root, None)
|
|
198
|
+
|
|
199
|
+
async def action_toggle_item(self) -> None:
|
|
200
|
+
data = self._cursor_data()
|
|
201
|
+
if data is None:
|
|
202
|
+
return
|
|
203
|
+
await self._fp.toggle_node(data.node)
|
|
204
|
+
self.refresh_cursor_node()
|
|
205
|
+
|
|
206
|
+
async def action_toggle_negated(self) -> None:
|
|
207
|
+
data = self._cursor_data()
|
|
208
|
+
if data is None:
|
|
209
|
+
return
|
|
210
|
+
await self._fp.toggle_negated(data.node)
|
|
211
|
+
self.refresh_cursor_node()
|
|
212
|
+
|
|
213
|
+
async def action_add_group(self) -> None:
|
|
214
|
+
data = self._cursor_data()
|
|
215
|
+
if data is None:
|
|
216
|
+
return
|
|
217
|
+
node = data.node
|
|
218
|
+
parent = data.parent
|
|
219
|
+
if isinstance(node, FilterGroup):
|
|
220
|
+
await self._fp.add_group(node)
|
|
221
|
+
else:
|
|
222
|
+
idx = parent.children.index(node)
|
|
223
|
+
await self._fp.add_group(parent, idx)
|
|
224
|
+
self.rebuild()
|
|
225
|
+
|
|
226
|
+
async def action_toggle_combine(self) -> None:
|
|
227
|
+
data = self._cursor_data()
|
|
228
|
+
if data is None:
|
|
229
|
+
return
|
|
230
|
+
if not isinstance(data.node, FilterGroup):
|
|
231
|
+
return
|
|
232
|
+
await self._fp.set_node_operator(data.node, data.parent)
|
|
233
|
+
self.rebuild()
|
|
234
|
+
|
|
235
|
+
def action_yank(self) -> None:
|
|
236
|
+
data = self._cursor_data()
|
|
237
|
+
if data is None:
|
|
238
|
+
return
|
|
239
|
+
node = data.node
|
|
240
|
+
if node is self._fp.root:
|
|
241
|
+
return
|
|
242
|
+
self._clipboard = node.model_copy(deep=True)
|
|
243
|
+
|
|
244
|
+
async def action_flatten(self) -> None:
|
|
245
|
+
data = self._cursor_data()
|
|
246
|
+
if data is None:
|
|
247
|
+
return
|
|
248
|
+
node = data.node
|
|
249
|
+
if not isinstance(node, FilterGroup) or node is self._fp.root:
|
|
250
|
+
return
|
|
251
|
+
await self._fp.flatten_group(node, data.parent)
|
|
252
|
+
self.rebuild()
|
|
253
|
+
|
|
254
|
+
async def action_delete(self) -> None:
|
|
255
|
+
data = self._cursor_data()
|
|
256
|
+
if data is None:
|
|
257
|
+
return
|
|
258
|
+
node = data.node
|
|
259
|
+
if node is self._fp.root:
|
|
260
|
+
return
|
|
261
|
+
self._clipboard = node
|
|
262
|
+
await self._fp.remove_node(node, data.parent)
|
|
263
|
+
self.rebuild()
|
|
264
|
+
|
|
265
|
+
async def action_paste(self) -> None:
|
|
266
|
+
if self._clipboard is None:
|
|
267
|
+
return
|
|
268
|
+
parent, idx = self._insert_at_cursor()
|
|
269
|
+
if idx is not None:
|
|
270
|
+
parent.children.insert(idx + 1, self._clipboard)
|
|
271
|
+
else:
|
|
272
|
+
parent.children.append(self._clipboard)
|
|
273
|
+
self._clipboard = None
|
|
274
|
+
await self._fp.on_change.asend(None)
|
|
275
|
+
self.rebuild()
|
|
276
|
+
|
|
277
|
+
def action_add_filter(self) -> None:
|
|
278
|
+
target = self._insert_at_cursor()
|
|
279
|
+
|
|
280
|
+
async def on_dismiss(expr: str | None) -> None:
|
|
281
|
+
if not expr:
|
|
282
|
+
return
|
|
283
|
+
warning = check_filter_warning(expr)
|
|
284
|
+
parent, idx = target
|
|
285
|
+
leaf = Filter(expr=expr)
|
|
286
|
+
if idx is not None:
|
|
287
|
+
parent.children.insert(idx + 1, leaf)
|
|
288
|
+
else:
|
|
289
|
+
parent.children.append(leaf)
|
|
290
|
+
await self._fp.on_change.asend(None)
|
|
291
|
+
self.rebuild()
|
|
292
|
+
if warning:
|
|
293
|
+
self.app.notify(warning, severity="warning", timeout=3)
|
|
294
|
+
|
|
295
|
+
self.app.push_screen(
|
|
296
|
+
TextInputScreen("Add filter", placeholder="jq expression..."),
|
|
297
|
+
on_dismiss,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def action_edit_filter(self) -> None:
|
|
301
|
+
data = self._cursor_data()
|
|
302
|
+
if data is None:
|
|
303
|
+
return
|
|
304
|
+
node = data.node
|
|
305
|
+
if not isinstance(node, Filter):
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
async def on_dismiss(expr: str | None) -> None:
|
|
309
|
+
if not expr:
|
|
310
|
+
return
|
|
311
|
+
warning = check_filter_warning(expr)
|
|
312
|
+
await self._fp.edit_leaf(node, expr)
|
|
313
|
+
self.rebuild()
|
|
314
|
+
if warning:
|
|
315
|
+
self.app.notify(warning, severity="warning", timeout=3)
|
|
316
|
+
|
|
317
|
+
self.app.push_screen(
|
|
318
|
+
TextInputScreen(
|
|
319
|
+
"Edit filter",
|
|
320
|
+
placeholder="jq expression...",
|
|
321
|
+
initial_value=node.expr,
|
|
322
|
+
),
|
|
323
|
+
on_dismiss,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def action_rename(self) -> None:
|
|
327
|
+
data = self._cursor_data()
|
|
328
|
+
if data is None:
|
|
329
|
+
return
|
|
330
|
+
node = data.node
|
|
331
|
+
|
|
332
|
+
async def on_dismiss(label: str | None) -> None:
|
|
333
|
+
node.label = label
|
|
334
|
+
await self._fp.on_change.asend(None)
|
|
335
|
+
self.rebuild()
|
|
336
|
+
|
|
337
|
+
self.app.push_screen(
|
|
338
|
+
TextInputScreen(
|
|
339
|
+
"Rename",
|
|
340
|
+
placeholder="label...",
|
|
341
|
+
initial_value=node.label or "",
|
|
342
|
+
),
|
|
343
|
+
on_dismiss,
|
|
344
|
+
)
|
jnav/filtering.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Annotated, Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
import jq
|
|
9
|
+
from pydantic import BaseModel, Discriminator, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Filter(BaseModel):
|
|
13
|
+
type: Literal["leaf"] = "leaf"
|
|
14
|
+
expr: str
|
|
15
|
+
enabled: bool = True
|
|
16
|
+
negated: bool = False
|
|
17
|
+
label: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FilterGroup(BaseModel):
|
|
21
|
+
type: Literal["group"] = "group"
|
|
22
|
+
operator: Literal["and", "or"] = "and"
|
|
23
|
+
enabled: bool = True
|
|
24
|
+
negated: bool = False
|
|
25
|
+
label: str | None = None
|
|
26
|
+
collapsed: bool = False
|
|
27
|
+
children: list[FilterNode] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
FilterNode = Annotated[Filter | FilterGroup, Discriminator("type")]
|
|
31
|
+
|
|
32
|
+
FilterGroup.model_rebuild()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@functools.lru_cache(maxsize=32)
|
|
36
|
+
def _compile_jq(expression: str):
|
|
37
|
+
return jq.compile(expression)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def apply_jq_filter(
|
|
41
|
+
expression: str,
|
|
42
|
+
entries: list[dict[str, Any]],
|
|
43
|
+
) -> tuple[list[int], str | None]:
|
|
44
|
+
try:
|
|
45
|
+
prog = _compile_jq(expression)
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
return [], str(e)
|
|
48
|
+
matched: list[int] = []
|
|
49
|
+
for i, entry in enumerate(entries):
|
|
50
|
+
try:
|
|
51
|
+
results = prog.input_value(entry).all()
|
|
52
|
+
if any(r is not None and r is not False for r in results):
|
|
53
|
+
matched.append(i)
|
|
54
|
+
except ValueError:
|
|
55
|
+
continue
|
|
56
|
+
return matched, None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_expression(node: FilterNode) -> str | None:
|
|
60
|
+
"""Recursively build a jq expression from a filter tree.
|
|
61
|
+
|
|
62
|
+
Returns None if the node is disabled or has no effective children.
|
|
63
|
+
"""
|
|
64
|
+
if not node.enabled:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if isinstance(node, Filter):
|
|
68
|
+
expr = node.expr
|
|
69
|
+
if node.negated:
|
|
70
|
+
expr = f"{expr} | not"
|
|
71
|
+
return expr
|
|
72
|
+
|
|
73
|
+
child_exprs: list[tuple[str, FilterNode]] = []
|
|
74
|
+
for child in node.children:
|
|
75
|
+
child_expr = build_expression(child)
|
|
76
|
+
if child_expr is not None:
|
|
77
|
+
child_exprs.append((child_expr, child))
|
|
78
|
+
|
|
79
|
+
if not child_exprs:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
if len(child_exprs) == 1:
|
|
83
|
+
result = child_exprs[0][0]
|
|
84
|
+
else:
|
|
85
|
+
joiner = f" {node.operator} "
|
|
86
|
+
wrapped: list[str] = []
|
|
87
|
+
for expr, child in child_exprs:
|
|
88
|
+
needs_parens = "|" in expr or (
|
|
89
|
+
isinstance(child, FilterGroup)
|
|
90
|
+
and len(child.children) > 1
|
|
91
|
+
and child.operator != node.operator
|
|
92
|
+
)
|
|
93
|
+
wrapped.append(f"({expr})" if needs_parens else expr)
|
|
94
|
+
result = joiner.join(wrapped)
|
|
95
|
+
|
|
96
|
+
if node.negated:
|
|
97
|
+
result = f"({result}) | not"
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def apply_filter_tree(
|
|
103
|
+
root: FilterGroup,
|
|
104
|
+
entries: list[dict[str, Any]],
|
|
105
|
+
) -> tuple[list[int], str | None]:
|
|
106
|
+
"""Apply a filter tree to a list of entries."""
|
|
107
|
+
combined = build_expression(root)
|
|
108
|
+
if combined is None:
|
|
109
|
+
return list(range(len(entries))), None
|
|
110
|
+
return apply_jq_filter(combined, entries)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def text_search_expr(term: str) -> str:
|
|
114
|
+
escaped = term.lower().replace("\\", "\\\\").replace('"', '\\"')
|
|
115
|
+
return f'[.. | strings] | any(ascii_downcase | contains("{escaped}"))'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_ASSIGNMENT_RE = re.compile(r"(?<![=!<>])=(?!=)")
|
|
119
|
+
_STRING_LITERAL_RE = re.compile(r'"[^"\\]*(?:\\.[^"\\]*)*"')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_filter_warning(expression: str) -> str | None:
|
|
123
|
+
without_strings = _STRING_LITERAL_RE.sub('""', expression)
|
|
124
|
+
if _ASSIGNMENT_RE.search(without_strings):
|
|
125
|
+
return "jq update operators don't filter. Did you mean a comparison like '=='?"
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_nested(entry: dict[str, Any], path: str) -> object:
|
|
130
|
+
try:
|
|
131
|
+
results = _compile_jq(path).input_value(entry).all()
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None
|
|
134
|
+
if len(results) == 1:
|
|
135
|
+
return results[0]
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def jq_value_literal(value: object) -> str:
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
return json.dumps(value)
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
return "true" if value else "false"
|
|
144
|
+
if value is None:
|
|
145
|
+
return "null"
|
|
146
|
+
if isinstance(value, (int, float)):
|
|
147
|
+
if not math.isfinite(value):
|
|
148
|
+
raise ValueError(f"jq does not support {value!r}")
|
|
149
|
+
return str(value)
|
|
150
|
+
return json.dumps(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _jq_path_to_str(parts: Sequence[str | int]) -> str:
|
|
154
|
+
result = ""
|
|
155
|
+
for p in parts:
|
|
156
|
+
if isinstance(p, int):
|
|
157
|
+
result += f"[{p}]"
|
|
158
|
+
else:
|
|
159
|
+
result = f"{result}.{p}" if result else p
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_selected_paths(columns: set[str], entry: dict[str, Any]) -> set[str]:
|
|
164
|
+
"""Expand jq column expressions into concrete paths for a specific entry."""
|
|
165
|
+
result: set[str] = set()
|
|
166
|
+
for col in columns:
|
|
167
|
+
if "[]" not in col and "|" not in col:
|
|
168
|
+
result.add(col.lstrip("."))
|
|
169
|
+
continue
|
|
170
|
+
jq_path = col
|
|
171
|
+
try:
|
|
172
|
+
raw = jq.compile(f"[path({jq_path})]").input_value(entry).first()
|
|
173
|
+
for parts in raw:
|
|
174
|
+
result.add(_jq_path_to_str(parts))
|
|
175
|
+
continue
|
|
176
|
+
except ValueError:
|
|
177
|
+
pass
|
|
178
|
+
try:
|
|
179
|
+
results = jq.compile(jq_path).input_value(entry).all()
|
|
180
|
+
base_expr = jq_path.split("|")[0].strip()
|
|
181
|
+
raw_bases = jq.compile(f"[path({base_expr})]").input_value(entry).first()
|
|
182
|
+
for base_parts in raw_bases:
|
|
183
|
+
base = _jq_path_to_str(base_parts)
|
|
184
|
+
for r in results:
|
|
185
|
+
if isinstance(r, dict):
|
|
186
|
+
r = cast(dict[str, object], r)
|
|
187
|
+
for key in r:
|
|
188
|
+
result.add(f"{base}.{key}")
|
|
189
|
+
except ValueError:
|
|
190
|
+
result.add(col)
|
|
191
|
+
return result
|
jnav/header.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from typing import ClassVar, override
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Header(Static):
|
|
8
|
+
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
9
|
+
"header--bracket",
|
|
10
|
+
"header--text",
|
|
11
|
+
"header--file",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
Header {
|
|
16
|
+
height: 1;
|
|
17
|
+
background: $surface;
|
|
18
|
+
}
|
|
19
|
+
Header > .header--bracket {
|
|
20
|
+
color: $accent;
|
|
21
|
+
text-style: bold;
|
|
22
|
+
}
|
|
23
|
+
Header > .header--text {
|
|
24
|
+
color: $foreground;
|
|
25
|
+
text-style: bold;
|
|
26
|
+
}
|
|
27
|
+
Header > .header--file {
|
|
28
|
+
color: $text-muted;
|
|
29
|
+
text-style: italic;
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, file_name: str) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._file_name = file_name
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def render(self) -> Text:
|
|
39
|
+
bracket = self.get_component_rich_style("header--bracket")
|
|
40
|
+
text = self.get_component_rich_style("header--text")
|
|
41
|
+
file_name = self.get_component_rich_style("header--file")
|
|
42
|
+
return Text.assemble(
|
|
43
|
+
("[", bracket),
|
|
44
|
+
("J", text),
|
|
45
|
+
("]", bracket),
|
|
46
|
+
("NAV", text),
|
|
47
|
+
(f" {self._file_name}", file_name),
|
|
48
|
+
)
|
jnav/help_screen.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from typing import ClassVar, override
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding, BindingType
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
HELP_TEXT = """\
|
|
10
|
+
[b]Navigation[/b]
|
|
11
|
+
[b]j/k[/b] Cursor down/up
|
|
12
|
+
[b]gg/G[/b] Jump to top/bottom
|
|
13
|
+
[b]Ctrl+D/U[/b] Half-page down/up
|
|
14
|
+
[b]h/l[/b] Focus list/detail
|
|
15
|
+
[b]Enter[/b] Inspect entry
|
|
16
|
+
[b]Escape[/b] Back
|
|
17
|
+
|
|
18
|
+
[b]Actions[/b]
|
|
19
|
+
[b]/[/b] Search
|
|
20
|
+
[b]n/N[/b] Next/prev match
|
|
21
|
+
[b]F[/b] Filter manager
|
|
22
|
+
[b]ft[/b] Text filter
|
|
23
|
+
[b]fp[/b] Pause/resume filters
|
|
24
|
+
[b]S[/b] Selected fields manager
|
|
25
|
+
[b]vi[/b] Toggle inline expanded tree
|
|
26
|
+
[b]d[/b] Toggle detail panel
|
|
27
|
+
[b]r[/b] Reset filters, fields, search
|
|
28
|
+
[b]?[/b] This help
|
|
29
|
+
[b]q[/b] Quit
|
|
30
|
+
|
|
31
|
+
[b]Detail Panel[/b]
|
|
32
|
+
[b]ff[/b] Filter by value
|
|
33
|
+
[b]fn[/b] Has field
|
|
34
|
+
[b]s[/b] (Un)select field
|
|
35
|
+
[b]ve[/b] View value in $EDITOR
|
|
36
|
+
[b]vo[/b] Show selected fields only
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HelpScreen(ModalScreen[bool]):
|
|
41
|
+
DEFAULT_CSS = """
|
|
42
|
+
HelpScreen {
|
|
43
|
+
align: center middle;
|
|
44
|
+
}
|
|
45
|
+
#help-modal {
|
|
46
|
+
width: 55;
|
|
47
|
+
max-width: 90%;
|
|
48
|
+
height: auto;
|
|
49
|
+
max-height: 80%;
|
|
50
|
+
border: round $surface-lighten-2;
|
|
51
|
+
background: $surface;
|
|
52
|
+
padding: 1 2;
|
|
53
|
+
}
|
|
54
|
+
#help-hints {
|
|
55
|
+
color: $text-muted;
|
|
56
|
+
padding: 1 0 0 0;
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
61
|
+
Binding("escape", "close", "Close", priority=True),
|
|
62
|
+
Binding("q", "close", show=False),
|
|
63
|
+
Binding("ctrl+c", "close", show=False),
|
|
64
|
+
Binding("question_mark", "close", show=False),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
def compose(self) -> ComposeResult:
|
|
69
|
+
yield Vertical(
|
|
70
|
+
Static(HELP_TEXT),
|
|
71
|
+
Static("[b]esc[/b] or [b]?[/b] to close", id="help-hints"),
|
|
72
|
+
id="help-modal",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
self.query_one("#help-modal").border_title = "Keybindings"
|
|
77
|
+
|
|
78
|
+
def action_close(self) -> None:
|
|
79
|
+
self.dismiss(True)
|