jsonl-viewer 0.0.1__tar.gz
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.
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jsonl-viewer
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A terminal viewer for JSONL files
|
|
5
|
+
Author: Adam Fourney
|
|
6
|
+
Author-email: Adam Fourney <adam.fourney@gmail.com>
|
|
7
|
+
Requires-Dist: textual>=8.0.0
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Project-URL: Homepage, https://github.com/afourney/jlv
|
|
10
|
+
Project-URL: Repository, https://github.com/afourney/jlv
|
|
11
|
+
Project-URL: Issues, https://github.com/afourney/jlv/issues
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# jlv
|
|
15
|
+
|
|
16
|
+
A terminal viewer for JSONL files (one JSON value per line).
|
|
17
|
+
|
|
18
|
+
Install the `jsonl-viewer` package; the command is still `jlv`:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install jsonl-viewer
|
|
22
|
+
jlv your-file.jsonl
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Alternatively, use `pip install jsonl-viewer`. From a source checkout:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv run jlv your-file.jsonl
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Python 3.12+ and Textual 8+. The left panel lists numbered records;
|
|
32
|
+
the right panel shows the selected record as indented JSON. Select a string
|
|
33
|
+
to read its decoded contents in a scrollable, word-wrapped inspector.
|
|
34
|
+
|
|
35
|
+
## Screenshots
|
|
36
|
+
|
|
37
|
+
Browse records alongside their formatted JSON:
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
Open a string to read its decoded, word-wrapped contents:
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
## Controls
|
|
46
|
+
|
|
47
|
+
| Key / action | Effect |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| Up / Down | Select the previous / next record or JSON line |
|
|
50
|
+
| Page Up / Page Down | Move by a page |
|
|
51
|
+
| Home / End | Move to the first / last row |
|
|
52
|
+
| Tab / Shift+Tab | Switch panels |
|
|
53
|
+
| Left / Right | Scroll horizontally |
|
|
54
|
+
| Enter or click | Inspect a selected string |
|
|
55
|
+
| Mouse wheel / scrollbars | Scroll the panel under the pointer |
|
|
56
|
+
| Escape | Close the string inspector |
|
|
57
|
+
| q | Quit, including from the inspector |
|
|
58
|
+
|
|
59
|
+
The inspector is read-only and supports text selection and Ctrl+C to copy.
|
|
60
|
+
The main panels also support text selection. The left-hand preview is capped
|
|
61
|
+
at 1,000 characters, but the right-hand document and decoded strings are not
|
|
62
|
+
truncated.
|
|
63
|
+
|
|
64
|
+
## Large files
|
|
65
|
+
|
|
66
|
+
Both main panels render only visible rows, rather than creating a widget for
|
|
67
|
+
every record or JSON field. The loader validates the entire file in one buffered
|
|
68
|
+
pass and builds a compact byte-offset index. Records are read and parsed on
|
|
69
|
+
demand; small bounded caches retain recent previews and formatted documents.
|
|
70
|
+
The string inspector also renders a viewport, and defers wrapping until its
|
|
71
|
+
actual display width is known.
|
|
72
|
+
|
|
73
|
+
Validation still happens before the viewer opens: malformed JSON, invalid UTF-8,
|
|
74
|
+
and interior blank lines are reported with a line number. Trailing blank lines
|
|
75
|
+
are permitted. This is a read-only viewer, not a live file follower; do not edit
|
|
76
|
+
the input file while viewing it.
|
|
77
|
+
|
|
78
|
+
## Sample file
|
|
79
|
+
|
|
80
|
+
The repository includes `stress-test.jsonl.gz`, a compressed synthetic dataset
|
|
81
|
+
with 30,000 records. Extract it before opening it:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gzip -dk stress-test.jsonl.gz
|
|
85
|
+
uv run jlv stress-test.jsonl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The extracted file is 300 MB and is Git-ignored. Records range from approximately
|
|
89
|
+
1-100 KB and contain nested structures and strings of varying lengths. Some
|
|
90
|
+
strings intentionally end mid-sentence to fit the generated record sizes.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# jlv
|
|
2
|
+
|
|
3
|
+
A terminal viewer for JSONL files (one JSON value per line).
|
|
4
|
+
|
|
5
|
+
Install the `jsonl-viewer` package; the command is still `jlv`:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install jsonl-viewer
|
|
9
|
+
jlv your-file.jsonl
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Alternatively, use `pip install jsonl-viewer`. From a source checkout:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv run jlv your-file.jsonl
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Python 3.12+ and Textual 8+. The left panel lists numbered records;
|
|
19
|
+
the right panel shows the selected record as indented JSON. Select a string
|
|
20
|
+
to read its decoded contents in a scrollable, word-wrapped inspector.
|
|
21
|
+
|
|
22
|
+
## Screenshots
|
|
23
|
+
|
|
24
|
+
Browse records alongside their formatted JSON:
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
Open a string to read its decoded, word-wrapped contents:
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Controls
|
|
33
|
+
|
|
34
|
+
| Key / action | Effect |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Up / Down | Select the previous / next record or JSON line |
|
|
37
|
+
| Page Up / Page Down | Move by a page |
|
|
38
|
+
| Home / End | Move to the first / last row |
|
|
39
|
+
| Tab / Shift+Tab | Switch panels |
|
|
40
|
+
| Left / Right | Scroll horizontally |
|
|
41
|
+
| Enter or click | Inspect a selected string |
|
|
42
|
+
| Mouse wheel / scrollbars | Scroll the panel under the pointer |
|
|
43
|
+
| Escape | Close the string inspector |
|
|
44
|
+
| q | Quit, including from the inspector |
|
|
45
|
+
|
|
46
|
+
The inspector is read-only and supports text selection and Ctrl+C to copy.
|
|
47
|
+
The main panels also support text selection. The left-hand preview is capped
|
|
48
|
+
at 1,000 characters, but the right-hand document and decoded strings are not
|
|
49
|
+
truncated.
|
|
50
|
+
|
|
51
|
+
## Large files
|
|
52
|
+
|
|
53
|
+
Both main panels render only visible rows, rather than creating a widget for
|
|
54
|
+
every record or JSON field. The loader validates the entire file in one buffered
|
|
55
|
+
pass and builds a compact byte-offset index. Records are read and parsed on
|
|
56
|
+
demand; small bounded caches retain recent previews and formatted documents.
|
|
57
|
+
The string inspector also renders a viewport, and defers wrapping until its
|
|
58
|
+
actual display width is known.
|
|
59
|
+
|
|
60
|
+
Validation still happens before the viewer opens: malformed JSON, invalid UTF-8,
|
|
61
|
+
and interior blank lines are reported with a line number. Trailing blank lines
|
|
62
|
+
are permitted. This is a read-only viewer, not a live file follower; do not edit
|
|
63
|
+
the input file while viewing it.
|
|
64
|
+
|
|
65
|
+
## Sample file
|
|
66
|
+
|
|
67
|
+
The repository includes `stress-test.jsonl.gz`, a compressed synthetic dataset
|
|
68
|
+
with 30,000 records. Extract it before opening it:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
gzip -dk stress-test.jsonl.gz
|
|
72
|
+
uv run jlv stress-test.jsonl
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The extracted file is 300 MB and is Git-ignored. Records range from approximately
|
|
76
|
+
1-100 KB and contain nested structures and strings of varying lengths. Some
|
|
77
|
+
strings intentionally end mid-sentence to fit the generated record sizes.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jsonl-viewer"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A terminal viewer for JSONL files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["textual>=8.0.0"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Adam Fourney"
|
|
11
|
+
email = "adam.fourney@gmail.com"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
jlv = "jlv:main"
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/afourney/jlv"
|
|
18
|
+
Repository = "https://github.com/afourney/jlv"
|
|
19
|
+
Issues = "https://github.com/afourney/jlv/issues"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.10.2,<0.11.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "jlv"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jsonl-viewer"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A terminal viewer for JSONL files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Adam Fourney", email = "adam.fourney@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = ["textual>=8.0.0"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
jlv = "jlv:main"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/afourney/jlv"
|
|
17
|
+
Repository = "https://github.com/afourney/jlv"
|
|
18
|
+
Issues = "https://github.com/afourney/jlv/issues"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.10.2,<0.11.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.build-backend]
|
|
25
|
+
module-name = "jlv"
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from rich.cells import cell_len
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual import events
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import TextArea
|
|
15
|
+
|
|
16
|
+
from jlv.rows import RowView
|
|
17
|
+
from jlv.source import JsonlFile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PrettyDocument:
|
|
22
|
+
lines: list[tuple[str, str | None]]
|
|
23
|
+
strings: dict[int, str]
|
|
24
|
+
width: int
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_value(cls, value: object) -> PrettyDocument:
|
|
28
|
+
lines: list[tuple[str, str | None]] = []
|
|
29
|
+
strings: dict[int, str] = {}
|
|
30
|
+
|
|
31
|
+
def append(value: object, depth: int, prefix: str = "", suffix: str = "") -> None:
|
|
32
|
+
indent = " " * depth
|
|
33
|
+
if isinstance(value, (dict, list)) and value:
|
|
34
|
+
opening, closing = ("{", "}") if isinstance(value, dict) else ("[", "]")
|
|
35
|
+
lines.append((indent + prefix + opening, None))
|
|
36
|
+
entries = value.items() if isinstance(value, dict) else enumerate(value)
|
|
37
|
+
for i, (key, child) in enumerate(entries):
|
|
38
|
+
child_prefix = json.dumps(key, ensure_ascii=False) + ": " if isinstance(value, dict) else ""
|
|
39
|
+
append(child, depth + 1, child_prefix, "," if i < len(value) - 1 else "")
|
|
40
|
+
lines.append((indent + closing + suffix, None))
|
|
41
|
+
else:
|
|
42
|
+
kind = None
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
kind = "string"
|
|
45
|
+
strings[len(lines)] = value
|
|
46
|
+
elif value is None:
|
|
47
|
+
kind = "null"
|
|
48
|
+
elif isinstance(value, bool):
|
|
49
|
+
kind = "bool"
|
|
50
|
+
elif isinstance(value, (int, float)):
|
|
51
|
+
kind = "number"
|
|
52
|
+
lines.append((indent + prefix + json.dumps(value, ensure_ascii=False) + suffix, kind))
|
|
53
|
+
|
|
54
|
+
append(value, 0)
|
|
55
|
+
return cls(
|
|
56
|
+
lines, strings,
|
|
57
|
+
max(
|
|
58
|
+
len(line) if line.isascii() and "\x7f" not in line else cell_len(line)
|
|
59
|
+
for line, _ in lines
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StringView(TextArea):
|
|
65
|
+
def __init__(self, value: str) -> None:
|
|
66
|
+
self._pending_value: str | None = value
|
|
67
|
+
super().__init__(
|
|
68
|
+
read_only=True,
|
|
69
|
+
show_cursor=False,
|
|
70
|
+
highlight_cursor_line=False,
|
|
71
|
+
soft_wrap=True,
|
|
72
|
+
id="modal-container",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
76
|
+
if self._pending_value is not None and self.content_size.width:
|
|
77
|
+
# Wrap once at the actual viewport width, not at each startup size.
|
|
78
|
+
value, self._pending_value = self._pending_value, None
|
|
79
|
+
self.load_text(value)
|
|
80
|
+
event.prevent_default()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StringModal(ModalScreen[None]):
|
|
84
|
+
"""A read-only, wrapped document with viewport-based rendering."""
|
|
85
|
+
|
|
86
|
+
BINDINGS = [
|
|
87
|
+
Binding("escape", "dismiss", "Close"),
|
|
88
|
+
Binding("q", "app.quit", "Quit"),
|
|
89
|
+
]
|
|
90
|
+
DEFAULT_CSS = """
|
|
91
|
+
StringModal {
|
|
92
|
+
align: center middle;
|
|
93
|
+
}
|
|
94
|
+
StringModal #modal-container {
|
|
95
|
+
width: 90%;
|
|
96
|
+
height: 90%;
|
|
97
|
+
border: thick $accent;
|
|
98
|
+
background: $surface;
|
|
99
|
+
padding: 1 2;
|
|
100
|
+
overflow-y: scroll;
|
|
101
|
+
scrollbar-size: 1 1;
|
|
102
|
+
}
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, value: str) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self.value = value
|
|
108
|
+
|
|
109
|
+
def compose(self) -> ComposeResult:
|
|
110
|
+
yield StringView(self.value)
|
|
111
|
+
|
|
112
|
+
def action_dismiss(self) -> None:
|
|
113
|
+
self.app.pop_screen()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class JlvApp(App):
|
|
117
|
+
"""JSONL Log Viewer."""
|
|
118
|
+
|
|
119
|
+
TITLE = "jlv"
|
|
120
|
+
BINDINGS = [
|
|
121
|
+
Binding("q", "quit", "Quit"),
|
|
122
|
+
Binding("tab", "focus_next", "Switch Panel"),
|
|
123
|
+
Binding("shift+tab", "focus_previous", "Switch Panel"),
|
|
124
|
+
]
|
|
125
|
+
DEFAULT_CSS = """
|
|
126
|
+
Horizontal {
|
|
127
|
+
height: 1fr;
|
|
128
|
+
}
|
|
129
|
+
#lines-list {
|
|
130
|
+
width: 2fr;
|
|
131
|
+
}
|
|
132
|
+
#detail-list {
|
|
133
|
+
width: 3fr;
|
|
134
|
+
border-left: solid $accent;
|
|
135
|
+
}
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, source: JsonlFile) -> None:
|
|
139
|
+
super().__init__()
|
|
140
|
+
self.source = source
|
|
141
|
+
self._documents: OrderedDict[int, PrettyDocument] = OrderedDict()
|
|
142
|
+
self._document: PrettyDocument | None = None
|
|
143
|
+
self._detail_idx: int | None = None
|
|
144
|
+
self._pretty_lines: list[tuple[str, str | None]] = []
|
|
145
|
+
|
|
146
|
+
def compose(self) -> ComposeResult:
|
|
147
|
+
with Horizontal():
|
|
148
|
+
yield RowView(id="lines-list")
|
|
149
|
+
yield RowView(id="detail-list")
|
|
150
|
+
|
|
151
|
+
def on_mount(self) -> None:
|
|
152
|
+
left = self.query_one("#lines-list", RowView)
|
|
153
|
+
left.set_rows(len(self.source), self.source.preview_width, self.source.preview)
|
|
154
|
+
left.focus()
|
|
155
|
+
self._update_detail(0)
|
|
156
|
+
|
|
157
|
+
def on_row_view_highlighted(self, event: RowView.Highlighted) -> None:
|
|
158
|
+
if event.view.id == "lines-list":
|
|
159
|
+
# Ignore queued highlights superseded by newer input in the same frame.
|
|
160
|
+
if event.index == event.view.index:
|
|
161
|
+
self._update_detail(event.index)
|
|
162
|
+
|
|
163
|
+
def on_row_view_selected(self, event: RowView.Selected) -> None:
|
|
164
|
+
if event.view.id == "detail-list" and self._document is not None:
|
|
165
|
+
value = self._document.strings.get(event.index)
|
|
166
|
+
if value is not None:
|
|
167
|
+
self.push_screen(StringModal(value))
|
|
168
|
+
return
|
|
169
|
+
self.bell()
|
|
170
|
+
|
|
171
|
+
def _update_detail(self, index: int) -> None:
|
|
172
|
+
if index == self._detail_idx:
|
|
173
|
+
return
|
|
174
|
+
if index in self._documents:
|
|
175
|
+
document = self._documents[index]
|
|
176
|
+
self._documents.move_to_end(index)
|
|
177
|
+
else:
|
|
178
|
+
document = PrettyDocument.from_value(self.source.value(index))
|
|
179
|
+
self._documents[index] = document
|
|
180
|
+
if len(self._documents) > 8:
|
|
181
|
+
self._documents.popitem(last=False)
|
|
182
|
+
self._document = document
|
|
183
|
+
self._detail_idx = index
|
|
184
|
+
self._pretty_lines = document.lines
|
|
185
|
+
self.query_one("#detail-list", RowView).set_rows(
|
|
186
|
+
len(document.lines), document.width, lambda row: document.lines[row][0]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main() -> None:
|
|
191
|
+
if len(sys.argv) < 2:
|
|
192
|
+
print("Usage: jlv <file.jsonl>", file=sys.stderr)
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
try:
|
|
195
|
+
source = JsonlFile(sys.argv[1])
|
|
196
|
+
except (OSError, ValueError) as error:
|
|
197
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
with source:
|
|
200
|
+
JlvApp(source).run()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from rich.cells import cell_len
|
|
7
|
+
from rich.segment import Segment
|
|
8
|
+
from textual import events
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.geometry import Offset, Region, Size
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.scroll_view import ScrollView
|
|
14
|
+
from textual.selection import Selection
|
|
15
|
+
from textual.strip import Strip
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RowView(ScrollView, can_focus=True):
|
|
19
|
+
"""Fixed-height selectable rows, rendered only for the visible viewport."""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
Binding("up", "cursor_up", show=False),
|
|
23
|
+
Binding("down", "cursor_down", show=False),
|
|
24
|
+
Binding("pageup", "cursor_page_up", show=False),
|
|
25
|
+
Binding("pagedown", "cursor_page_down", show=False),
|
|
26
|
+
Binding("home", "cursor_home", show=False),
|
|
27
|
+
Binding("end", "cursor_end", show=False),
|
|
28
|
+
Binding("enter", "select", show=False),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DEFAULT_CSS = """
|
|
32
|
+
RowView {
|
|
33
|
+
height: 1fr;
|
|
34
|
+
scrollbar-size: 1 1;
|
|
35
|
+
}
|
|
36
|
+
RowView > .rowview--highlight {
|
|
37
|
+
background: $boost;
|
|
38
|
+
}
|
|
39
|
+
RowView:focus > .rowview--highlight {
|
|
40
|
+
background: $accent;
|
|
41
|
+
color: $text;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
COMPONENT_CLASSES = {"rowview--highlight"}
|
|
45
|
+
index: reactive[int] = reactive(0, init=False)
|
|
46
|
+
|
|
47
|
+
class Highlighted(Message):
|
|
48
|
+
def __init__(self, view: RowView, index: int) -> None:
|
|
49
|
+
self.view = view
|
|
50
|
+
self.index = index
|
|
51
|
+
super().__init__()
|
|
52
|
+
|
|
53
|
+
class Selected(Message):
|
|
54
|
+
def __init__(self, view: RowView, index: int) -> None:
|
|
55
|
+
self.view = view
|
|
56
|
+
self.index = index
|
|
57
|
+
super().__init__()
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, id: str) -> None:
|
|
60
|
+
super().__init__(id=id)
|
|
61
|
+
self.row_count = 0
|
|
62
|
+
self._line: Callable[[int], str] = lambda index: ""
|
|
63
|
+
self._unicode_cache: OrderedDict[tuple[int, int, int], tuple[Strip, int]] = OrderedDict()
|
|
64
|
+
|
|
65
|
+
def set_rows(self, count: int, width: int, line: Callable[[int], str]) -> None:
|
|
66
|
+
if self.is_mounted and self.text_selection is not None:
|
|
67
|
+
self.screen.selections = {
|
|
68
|
+
widget: selection for widget, selection in self.screen.selections.items()
|
|
69
|
+
if widget is not self
|
|
70
|
+
}
|
|
71
|
+
self.row_count = count
|
|
72
|
+
self._line = line
|
|
73
|
+
self._unicode_cache.clear()
|
|
74
|
+
self.virtual_size = Size(width, count)
|
|
75
|
+
self.index = 0
|
|
76
|
+
self.scroll_to(x=0, y=0, animate=False, force=True)
|
|
77
|
+
self.refresh()
|
|
78
|
+
|
|
79
|
+
def validate_index(self, index: int) -> int:
|
|
80
|
+
return max(0, min(index, self.row_count - 1))
|
|
81
|
+
|
|
82
|
+
def watch_index(self, old: int, new: int) -> None:
|
|
83
|
+
self.refresh_line(old)
|
|
84
|
+
self.refresh_line(new)
|
|
85
|
+
self.scroll_to_region(
|
|
86
|
+
Region(0, new, 1, 1), animate=False, x_axis=False, force=True
|
|
87
|
+
)
|
|
88
|
+
self.post_message(self.Highlighted(self, new))
|
|
89
|
+
|
|
90
|
+
def render_line(self, y: int) -> Strip:
|
|
91
|
+
x, offset = self.scroll_offset
|
|
92
|
+
row = y + offset
|
|
93
|
+
width = self.size.width
|
|
94
|
+
style = self.rich_style
|
|
95
|
+
if row >= self.row_count:
|
|
96
|
+
return Strip.blank(width, style)
|
|
97
|
+
if row == self.index:
|
|
98
|
+
style += self.get_component_rich_style("rowview--highlight")
|
|
99
|
+
text = self._line(row)
|
|
100
|
+
single_cell = text.isascii() and "\x7f" not in text
|
|
101
|
+
# Avoid constructing/cropping a giant Rich segment for long ASCII strings.
|
|
102
|
+
if single_cell:
|
|
103
|
+
visible = text[x:x + width].ljust(width)
|
|
104
|
+
strip = Strip([Segment(visible)], width)
|
|
105
|
+
character_offset = x
|
|
106
|
+
else:
|
|
107
|
+
key = (row, x, width)
|
|
108
|
+
if key not in self._unicode_cache:
|
|
109
|
+
strip = Strip([Segment(text)], cell_len(text))
|
|
110
|
+
_, remainder = Segment(text).split_cells(x)
|
|
111
|
+
character_offset = len(text) - len(remainder.text)
|
|
112
|
+
self._unicode_cache[key] = (
|
|
113
|
+
strip.crop_extend(x, x + width, None), character_offset
|
|
114
|
+
)
|
|
115
|
+
if len(self._unicode_cache) > 256:
|
|
116
|
+
self._unicode_cache.popitem(last=False)
|
|
117
|
+
self._unicode_cache.move_to_end(key)
|
|
118
|
+
strip, character_offset = self._unicode_cache[key]
|
|
119
|
+
strip = strip.apply_style(style)
|
|
120
|
+
if self.text_selection is not None:
|
|
121
|
+
span = self.text_selection.get_span(row)
|
|
122
|
+
if span is not None:
|
|
123
|
+
start, end = span
|
|
124
|
+
start = min(start, len(text)) if single_cell else cell_len(text[:start])
|
|
125
|
+
end = len(text) if end == -1 else end
|
|
126
|
+
end = min(end, len(text)) if single_cell else cell_len(text[:end])
|
|
127
|
+
start, end = max(0, start - x), min(width, end - x)
|
|
128
|
+
if start < end:
|
|
129
|
+
before, selected, after = strip.divide([start, end, width])
|
|
130
|
+
strip = Strip.join([
|
|
131
|
+
before,
|
|
132
|
+
selected.apply_style(self.screen.get_component_rich_style("screen--selection")),
|
|
133
|
+
after,
|
|
134
|
+
])
|
|
135
|
+
return strip.apply_offsets(character_offset, row)
|
|
136
|
+
|
|
137
|
+
def get_selection(self, selection: Selection) -> tuple[str, str]:
|
|
138
|
+
start = selection.start or Offset(0, 0)
|
|
139
|
+
last = min(
|
|
140
|
+
self.row_count - 1,
|
|
141
|
+
selection.end.y if selection.end is not None else self.row_count - 1,
|
|
142
|
+
)
|
|
143
|
+
if last < start.y:
|
|
144
|
+
return "", "\n"
|
|
145
|
+
lines = [self._line(row) for row in range(start.y, last + 1)]
|
|
146
|
+
end_x = selection.end.x if selection.end is not None else len(lines[-1])
|
|
147
|
+
relative = Selection(Offset(start.x, 0), Offset(end_x, len(lines) - 1))
|
|
148
|
+
return relative.extract("\n".join(lines)), "\n"
|
|
149
|
+
|
|
150
|
+
def selection_updated(self, selection: Selection | None) -> None:
|
|
151
|
+
self.refresh()
|
|
152
|
+
|
|
153
|
+
def on_focus(self) -> None:
|
|
154
|
+
self.refresh_line(self.index)
|
|
155
|
+
|
|
156
|
+
def on_blur(self) -> None:
|
|
157
|
+
self.refresh_line(self.index)
|
|
158
|
+
|
|
159
|
+
def on_click(self, event: events.Click) -> None:
|
|
160
|
+
if event.widget is not self:
|
|
161
|
+
return
|
|
162
|
+
offset = event.get_content_offset(self)
|
|
163
|
+
if offset is None:
|
|
164
|
+
return
|
|
165
|
+
row = offset.y + self.scroll_offset.y
|
|
166
|
+
if 0 <= row < self.row_count:
|
|
167
|
+
self.focus()
|
|
168
|
+
self.index = row
|
|
169
|
+
self.post_message(self.Selected(self, row))
|
|
170
|
+
event.stop()
|
|
171
|
+
|
|
172
|
+
def action_cursor_up(self) -> None:
|
|
173
|
+
self.index -= 1
|
|
174
|
+
|
|
175
|
+
def action_cursor_down(self) -> None:
|
|
176
|
+
self.index += 1
|
|
177
|
+
|
|
178
|
+
def action_cursor_page_up(self) -> None:
|
|
179
|
+
self.index -= max(1, self.scrollable_content_region.height)
|
|
180
|
+
|
|
181
|
+
def action_cursor_page_down(self) -> None:
|
|
182
|
+
self.index += max(1, self.scrollable_content_region.height)
|
|
183
|
+
|
|
184
|
+
def action_cursor_home(self) -> None:
|
|
185
|
+
self.index = 0
|
|
186
|
+
|
|
187
|
+
def action_cursor_end(self) -> None:
|
|
188
|
+
self.index = self.row_count - 1
|
|
189
|
+
|
|
190
|
+
def action_select(self) -> None:
|
|
191
|
+
if self.row_count:
|
|
192
|
+
self.post_message(self.Selected(self, self.index))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from array import array
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import BinaryIO
|
|
13
|
+
|
|
14
|
+
from rich.cells import cell_len
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonlFile:
|
|
18
|
+
"""Validated, indexed JSONL without retaining every decoded record in memory."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: str | Path) -> None:
|
|
21
|
+
self._file: BinaryIO = open(path, "rb", buffering=1024 * 1024)
|
|
22
|
+
self._previews: OrderedDict[int, str] = OrderedDict()
|
|
23
|
+
self.offsets = array("Q", [0])
|
|
24
|
+
self.preview_width = 0
|
|
25
|
+
try:
|
|
26
|
+
if not stat.S_ISREG(os.fstat(self._file.fileno()).st_mode):
|
|
27
|
+
with self._file as stream:
|
|
28
|
+
self._file = tempfile.TemporaryFile("w+b")
|
|
29
|
+
shutil.copyfileobj(stream, self._file, length=1024 * 1024)
|
|
30
|
+
self._file.seek(0)
|
|
31
|
+
first_blank: int | None = None
|
|
32
|
+
offset = 0
|
|
33
|
+
decoder = json.JSONDecoder()
|
|
34
|
+
for line_number, raw in enumerate(self._lines(), 1):
|
|
35
|
+
offset += len(raw)
|
|
36
|
+
try:
|
|
37
|
+
text = raw.decode("utf-8").strip()
|
|
38
|
+
except UnicodeDecodeError as error:
|
|
39
|
+
raise ValueError(f"line {line_number} is not valid UTF-8: {error}") from error
|
|
40
|
+
if not text:
|
|
41
|
+
if first_blank is None:
|
|
42
|
+
first_blank = line_number
|
|
43
|
+
continue
|
|
44
|
+
if first_blank is not None:
|
|
45
|
+
raise ValueError(f"line {first_blank} is empty — not valid JSONL")
|
|
46
|
+
try:
|
|
47
|
+
decoder.decode(text)
|
|
48
|
+
except (ValueError, RecursionError) as error:
|
|
49
|
+
raise ValueError(f"line {line_number} is not valid JSON: {error}") from error
|
|
50
|
+
self.offsets.append(offset)
|
|
51
|
+
preview = self._preview_text(text, line_number)
|
|
52
|
+
width = len(preview) if preview.isascii() and "\x7f" not in preview else cell_len(preview)
|
|
53
|
+
self.preview_width = max(self.preview_width, width)
|
|
54
|
+
if not len(self):
|
|
55
|
+
raise ValueError("file is empty")
|
|
56
|
+
except BaseException:
|
|
57
|
+
self.close()
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _preview_text(text: str, line_number: int) -> str:
|
|
62
|
+
if len(text) > 1000:
|
|
63
|
+
text = text[:1000] + "..."
|
|
64
|
+
preview = f"{line_number:>5} {text}"
|
|
65
|
+
return preview.expandtabs(4) if "\t" in preview else preview
|
|
66
|
+
|
|
67
|
+
def _lines(self) -> Iterator[bytes]:
|
|
68
|
+
for raw in self._file:
|
|
69
|
+
if b"\r" in raw:
|
|
70
|
+
yield from raw.splitlines(keepends=True)
|
|
71
|
+
else:
|
|
72
|
+
yield raw
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self.offsets) - 1
|
|
76
|
+
|
|
77
|
+
def raw(self, index: int) -> str:
|
|
78
|
+
if not 0 <= index < len(self):
|
|
79
|
+
raise IndexError(index)
|
|
80
|
+
if self._file.closed:
|
|
81
|
+
raise ValueError("file is closed")
|
|
82
|
+
start, end = self.offsets[index:index + 2]
|
|
83
|
+
self._file.seek(start)
|
|
84
|
+
raw = self._file.read(end - start)
|
|
85
|
+
if len(raw) != end - start:
|
|
86
|
+
raise ValueError("file was truncated while viewing; reopen it")
|
|
87
|
+
return raw.decode("utf-8")
|
|
88
|
+
|
|
89
|
+
def value(self, index: int) -> object:
|
|
90
|
+
return json.loads(self.raw(index).strip())
|
|
91
|
+
|
|
92
|
+
def preview(self, index: int) -> str:
|
|
93
|
+
if index in self._previews:
|
|
94
|
+
self._previews.move_to_end(index)
|
|
95
|
+
return self._previews[index]
|
|
96
|
+
text = self._preview_text(self.raw(index).strip(), index + 1)
|
|
97
|
+
self._previews[index] = text
|
|
98
|
+
if len(self._previews) > 256:
|
|
99
|
+
self._previews.popitem(last=False)
|
|
100
|
+
return text
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._file.close()
|
|
104
|
+
self._previews.clear()
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> JsonlFile:
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, *args: object) -> None:
|
|
110
|
+
self.close()
|