ncview 0.1.0__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.
- ncview-0.1.0/.gitignore +7 -0
- ncview-0.1.0/PKG-INFO +11 -0
- ncview-0.1.0/README.md +84 -0
- ncview-0.1.0/pyproject.toml +26 -0
- ncview-0.1.0/src/ncview/__init__.py +1 -0
- ncview-0.1.0/src/ncview/__main__.py +11 -0
- ncview-0.1.0/src/ncview/app.py +177 -0
- ncview-0.1.0/src/ncview/ncview.tcss +30 -0
- ncview-0.1.0/src/ncview/utils/__init__.py +0 -0
- ncview-0.1.0/src/ncview/utils/file_info.py +87 -0
- ncview-0.1.0/src/ncview/utils/file_types.py +68 -0
- ncview-0.1.0/src/ncview/viewers/__init__.py +0 -0
- ncview-0.1.0/src/ncview/viewers/base.py +43 -0
- ncview-0.1.0/src/ncview/viewers/fallback_viewer.py +42 -0
- ncview-0.1.0/src/ncview/viewers/json_viewer.py +177 -0
- ncview-0.1.0/src/ncview/viewers/parquet_viewer.py +182 -0
- ncview-0.1.0/src/ncview/viewers/text_viewer.py +133 -0
- ncview-0.1.0/src/ncview/widgets/__init__.py +0 -0
- ncview-0.1.0/src/ncview/widgets/file_browser.py +328 -0
- ncview-0.1.0/src/ncview/widgets/path_bar.py +51 -0
- ncview-0.1.0/src/ncview/widgets/preview_panel.py +60 -0
- ncview-0.1.0/test_data/numeric_series.parquet +0 -0
- ncview-0.1.0/test_data/sales.parquet +0 -0
- ncview-0.1.0/test_data/sample.json +63 -0
- ncview-0.1.0/test_data/users.parquet +0 -0
- ncview-0.1.0/uv.lock +848 -0
ncview-0.1.0/.gitignore
ADDED
ncview-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ncview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal file browser with vim keybindings
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: polars>=1.0.0
|
|
7
|
+
Requires-Dist: pyarrow>=14.0.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: textual>=0.85.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: textual-dev>=1.0.0; extra == 'dev'
|
ncview-0.1.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# ncview
|
|
2
|
+
|
|
3
|
+
Terminal file browser with vim keybindings, inspired by [ncdu](https://dev.yorhel.nl/ncdu). Built with [Textual](https://textual.textualize.io/), [Polars](https://pola.rs/), and [PyArrow](https://arrow.apache.org/docs/python/).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Vim-style navigation** — `j`/`k`/`h`/`l`, arrow keys, and more
|
|
10
|
+
- **Parquet viewer** — schema, scrollable data table (first 1K rows), and statistics via Polars. Handles 50GB+ files efficiently using PyArrow metadata reads (O(1), no full scan)
|
|
11
|
+
- **JSON viewer** — collapsible/expandable tree with color-coded values
|
|
12
|
+
- **Text viewer** — syntax highlighting for 50+ languages via Rich
|
|
13
|
+
- **Fallback viewer** — file metadata for unknown/binary types
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Clone and install
|
|
19
|
+
git clone git@github.com:dannyfriar/ncview.git
|
|
20
|
+
cd ncview
|
|
21
|
+
uv venv --python 3.12
|
|
22
|
+
uv pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
ncview # browse current directory
|
|
29
|
+
ncview /some/path # browse a specific directory
|
|
30
|
+
ncview --help # show usage
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Keybindings
|
|
34
|
+
|
|
35
|
+
### File browser
|
|
36
|
+
|
|
37
|
+
| Key | Action |
|
|
38
|
+
|-----|--------|
|
|
39
|
+
| `j` / `k` / `↑` / `↓` | Move cursor up/down |
|
|
40
|
+
| `l` / `Enter` / `→` | Enter directory or open file preview |
|
|
41
|
+
| `h` / `Backspace` / `←` | Go to parent directory |
|
|
42
|
+
| `g` / `G` | Jump to top/bottom |
|
|
43
|
+
| `.` | Toggle hidden files |
|
|
44
|
+
| `s` | Cycle sort (name → size → modified) |
|
|
45
|
+
| `/` | Search in current directory |
|
|
46
|
+
| `e` | Open file in `$EDITOR` (default: `vim`) |
|
|
47
|
+
| `q` / `Ctrl+C` | Quit |
|
|
48
|
+
|
|
49
|
+
### Preview (all file types)
|
|
50
|
+
|
|
51
|
+
| Key | Action |
|
|
52
|
+
|-----|--------|
|
|
53
|
+
| `h` / `Backspace` / `←` / `Escape` | Close preview, return to browser |
|
|
54
|
+
| `j` / `k` | Scroll up/down |
|
|
55
|
+
| `Ctrl+D` / `Ctrl+U` | Page down/up |
|
|
56
|
+
| `q` / `Ctrl+C` | Quit |
|
|
57
|
+
|
|
58
|
+
### Parquet viewer
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| `1` | Data tab (default) |
|
|
63
|
+
| `2` | Schema tab |
|
|
64
|
+
| `3` | Stats tab (computed on first visit) |
|
|
65
|
+
| `↑` / `↓` | Scroll through rows in data table |
|
|
66
|
+
|
|
67
|
+
### JSON viewer
|
|
68
|
+
|
|
69
|
+
| Key | Action |
|
|
70
|
+
|-----|--------|
|
|
71
|
+
| `j` / `k` | Move cursor through visible nodes |
|
|
72
|
+
| `l` | Expand node (or move to first child if already expanded) |
|
|
73
|
+
| `h` | Collapse node (or move to parent if leaf/already collapsed) |
|
|
74
|
+
| `Space` / `Enter` | Toggle expand/collapse |
|
|
75
|
+
| `g` / `G` | Jump to top/bottom |
|
|
76
|
+
|
|
77
|
+
## Test data
|
|
78
|
+
|
|
79
|
+
The `test_data/` directory contains sample files for testing:
|
|
80
|
+
|
|
81
|
+
- `users.parquet` — 500 rows, 7 columns
|
|
82
|
+
- `sales.parquet` — 2,000 rows, 7 columns (with nulls)
|
|
83
|
+
- `numeric_series.parquet` — 5,000 rows, 6 columns
|
|
84
|
+
- `sample.json` — nested JSON with arrays and objects
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ncview"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terminal file browser with vim keybindings"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"textual>=0.85.0",
|
|
12
|
+
"polars>=1.0.0",
|
|
13
|
+
"pyarrow>=14.0.0",
|
|
14
|
+
"rich>=13.0.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"textual-dev>=1.0.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
ncview = "ncview.__main__:main"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/ncview"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ncview — Terminal file browser with vim keybindings."""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Main Textual application for ncview."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import VerticalScroll
|
|
10
|
+
from textual.widgets import Footer, Header
|
|
11
|
+
|
|
12
|
+
from ncview.utils.file_types import registry
|
|
13
|
+
from ncview.viewers.fallback_viewer import FallbackViewer
|
|
14
|
+
from ncview.viewers.json_viewer import JsonViewer
|
|
15
|
+
from ncview.viewers.parquet_viewer import ParquetViewer
|
|
16
|
+
from ncview.viewers.text_viewer import TextViewer
|
|
17
|
+
from ncview.widgets.file_browser import DirectoryChanged, FileBrowser, FileSelected
|
|
18
|
+
from ncview.widgets.path_bar import PathBar
|
|
19
|
+
from ncview.widgets.preview_panel import PreviewPanel
|
|
20
|
+
|
|
21
|
+
# Register viewers
|
|
22
|
+
registry.register(TextViewer)
|
|
23
|
+
registry.register(ParquetViewer)
|
|
24
|
+
registry.register(JsonViewer)
|
|
25
|
+
registry.register(FallbackViewer)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NcviewApp(App):
|
|
29
|
+
"""Terminal file browser with vim keybindings."""
|
|
30
|
+
|
|
31
|
+
TITLE = "ncview"
|
|
32
|
+
CSS_PATH = "ncview.tcss"
|
|
33
|
+
|
|
34
|
+
BINDINGS = [
|
|
35
|
+
("q", "quit", "Quit"),
|
|
36
|
+
("ctrl+c", "quit", "Quit"),
|
|
37
|
+
("escape", "close_preview", "Back"),
|
|
38
|
+
("h", "close_preview_or_parent", "Back"),
|
|
39
|
+
("backspace", "close_preview_or_parent", "Back"),
|
|
40
|
+
("left", "close_preview_or_parent", "Back"),
|
|
41
|
+
("j", "preview_scroll_down", "Down"),
|
|
42
|
+
("k", "preview_scroll_up", "Up"),
|
|
43
|
+
("ctrl+d", "preview_page_down", "Page down"),
|
|
44
|
+
("ctrl+u", "preview_page_up", "Page up"),
|
|
45
|
+
("1", "viewer_tab('1')", "Tab 1"),
|
|
46
|
+
("2", "viewer_tab('2')", "Tab 2"),
|
|
47
|
+
("3", "viewer_tab('3')", "Tab 3"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def __init__(self, start_path: Path | None = None, **kwargs) -> None:
|
|
51
|
+
super().__init__(**kwargs)
|
|
52
|
+
self._start_path = start_path or Path.cwd()
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
yield Header()
|
|
56
|
+
yield PathBar(self._start_path, id="path-bar")
|
|
57
|
+
yield FileBrowser(self._start_path, id="browser")
|
|
58
|
+
yield PreviewPanel(id="preview")
|
|
59
|
+
yield Footer()
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
browser = self.query_one("#browser", FileBrowser)
|
|
63
|
+
browser.border_title = "Files"
|
|
64
|
+
browser.focus()
|
|
65
|
+
preview = self.query_one("#preview", PreviewPanel)
|
|
66
|
+
preview.border_title = "Preview"
|
|
67
|
+
|
|
68
|
+
def _preview_is_open(self) -> bool:
|
|
69
|
+
return self.query_one("#preview", PreviewPanel).has_class("visible")
|
|
70
|
+
|
|
71
|
+
@on(FileSelected)
|
|
72
|
+
async def _on_file_selected(self, event: FileSelected) -> None:
|
|
73
|
+
"""User pressed Enter/l on a file — show preview."""
|
|
74
|
+
if not event.path.is_file():
|
|
75
|
+
return
|
|
76
|
+
preview = self.query_one("#preview", PreviewPanel)
|
|
77
|
+
browser = self.query_one("#browser", FileBrowser)
|
|
78
|
+
|
|
79
|
+
await preview.show_file(event.path)
|
|
80
|
+
|
|
81
|
+
# Hide browser, show preview full-width
|
|
82
|
+
browser.styles.display = "none"
|
|
83
|
+
preview.add_class("visible")
|
|
84
|
+
|
|
85
|
+
# Auto-focus the interactive widget inside the viewer
|
|
86
|
+
from textual.widgets import DataTable
|
|
87
|
+
from ncview.viewers.json_viewer import JsonTree
|
|
88
|
+
try:
|
|
89
|
+
dt = preview.query_one(DataTable)
|
|
90
|
+
dt.focus()
|
|
91
|
+
except Exception:
|
|
92
|
+
try:
|
|
93
|
+
jt = preview.query_one(JsonTree)
|
|
94
|
+
jt.focus()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
async def _close_preview(self) -> None:
|
|
99
|
+
"""Return from preview to browser."""
|
|
100
|
+
preview = self.query_one("#preview", PreviewPanel)
|
|
101
|
+
browser = self.query_one("#browser", FileBrowser)
|
|
102
|
+
|
|
103
|
+
await preview.clear()
|
|
104
|
+
|
|
105
|
+
preview.remove_class("visible")
|
|
106
|
+
browser.styles.display = "block"
|
|
107
|
+
browser.query_one("#file-list").focus()
|
|
108
|
+
|
|
109
|
+
async def action_close_preview(self) -> None:
|
|
110
|
+
if self._preview_is_open():
|
|
111
|
+
await self._close_preview()
|
|
112
|
+
|
|
113
|
+
async def action_close_preview_or_parent(self) -> None:
|
|
114
|
+
"""h/backspace: close preview if open, otherwise go to parent dir."""
|
|
115
|
+
if self._preview_is_open():
|
|
116
|
+
await self._close_preview()
|
|
117
|
+
# If preview is not open, let the FileBrowser handle h/backspace itself
|
|
118
|
+
|
|
119
|
+
def action_preview_scroll_down(self) -> None:
|
|
120
|
+
if self._preview_is_open():
|
|
121
|
+
self.query_one("#preview-scroll", VerticalScroll).scroll_down()
|
|
122
|
+
|
|
123
|
+
def action_preview_scroll_up(self) -> None:
|
|
124
|
+
if self._preview_is_open():
|
|
125
|
+
self.query_one("#preview-scroll", VerticalScroll).scroll_up()
|
|
126
|
+
|
|
127
|
+
def action_preview_page_down(self) -> None:
|
|
128
|
+
if self._preview_is_open():
|
|
129
|
+
self.query_one("#preview-scroll", VerticalScroll).scroll_page_down()
|
|
130
|
+
|
|
131
|
+
def action_preview_page_up(self) -> None:
|
|
132
|
+
if self._preview_is_open():
|
|
133
|
+
self.query_one("#preview-scroll", VerticalScroll).scroll_page_up()
|
|
134
|
+
|
|
135
|
+
def action_viewer_tab(self, tab_num: str) -> None:
|
|
136
|
+
"""Switch parquet viewer tabs with 1/2/3 keys."""
|
|
137
|
+
if not self._preview_is_open():
|
|
138
|
+
return
|
|
139
|
+
from ncview.viewers.parquet_viewer import ParquetViewer
|
|
140
|
+
from textual.widgets import TabbedContent, DataTable
|
|
141
|
+
try:
|
|
142
|
+
pv = self.query_one(ParquetViewer)
|
|
143
|
+
except Exception:
|
|
144
|
+
return
|
|
145
|
+
tc = pv.query_one(TabbedContent)
|
|
146
|
+
tab_map = {"1": "data-tab", "2": "schema-tab", "3": "stats-tab"}
|
|
147
|
+
tab_id = tab_map.get(tab_num)
|
|
148
|
+
if tab_id:
|
|
149
|
+
tc.active = tab_id
|
|
150
|
+
# Refocus DataTable when switching to data tab
|
|
151
|
+
if tab_id == "data-tab":
|
|
152
|
+
try:
|
|
153
|
+
pv.query_one(DataTable).focus()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
@on(DirectoryChanged)
|
|
158
|
+
def _on_directory_changed(self, event: DirectoryChanged) -> None:
|
|
159
|
+
path_bar = self.query_one("#path-bar", PathBar)
|
|
160
|
+
path_bar.update_path(event.path)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run() -> None:
|
|
164
|
+
"""Run the ncview app."""
|
|
165
|
+
import argparse
|
|
166
|
+
|
|
167
|
+
parser = argparse.ArgumentParser(prog="ncview", description="Terminal file browser with vim keybindings")
|
|
168
|
+
parser.add_argument("path", nargs="?", default=".", help="Directory to browse (default: current directory)")
|
|
169
|
+
args = parser.parse_args()
|
|
170
|
+
|
|
171
|
+
path = Path(args.path).resolve()
|
|
172
|
+
if not path.exists():
|
|
173
|
+
parser.error(f"{path} does not exist")
|
|
174
|
+
if path.is_file():
|
|
175
|
+
path = path.parent
|
|
176
|
+
app = NcviewApp(start_path=path)
|
|
177
|
+
app.run()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Header {
|
|
2
|
+
dock: top;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
Footer {
|
|
6
|
+
dock: bottom;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
PathBar {
|
|
10
|
+
dock: top;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
FileBrowser {
|
|
14
|
+
height: 1fr;
|
|
15
|
+
width: 1fr;
|
|
16
|
+
border: solid $primary;
|
|
17
|
+
border-title-color: $text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
PreviewPanel {
|
|
21
|
+
height: 1fr;
|
|
22
|
+
width: 1fr;
|
|
23
|
+
border: solid $secondary;
|
|
24
|
+
border-title-color: $text;
|
|
25
|
+
display: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
PreviewPanel.visible {
|
|
29
|
+
display: block;
|
|
30
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""File metadata extraction and size formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def human_size(size: int | float) -> str:
|
|
12
|
+
"""Convert bytes to human-readable string."""
|
|
13
|
+
for unit in ("B", "K", "M", "G", "T"):
|
|
14
|
+
if abs(size) < 1024:
|
|
15
|
+
if unit == "B":
|
|
16
|
+
return f"{int(size)}{unit}"
|
|
17
|
+
return f"{size:.1f}{unit}"
|
|
18
|
+
size = size / 1024
|
|
19
|
+
return f"{size:.1f}P"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def file_icon(path: Path) -> str:
|
|
23
|
+
"""Return a simple icon character for a path."""
|
|
24
|
+
if path.is_dir():
|
|
25
|
+
return "\U0001f4c1" # folder
|
|
26
|
+
ext = path.suffix.lower()
|
|
27
|
+
if ext in {".py", ".js", ".ts", ".rs", ".go", ".c", ".cpp", ".java"}:
|
|
28
|
+
return "\U0001f4c4" # code
|
|
29
|
+
if ext in {".parquet", ".csv", ".tsv", ".xlsx"}:
|
|
30
|
+
return "\U0001f4ca" # data
|
|
31
|
+
if ext in {".json", ".yaml", ".yml", ".toml", ".xml"}:
|
|
32
|
+
return "\u2699" # config
|
|
33
|
+
if ext in {".md", ".txt", ".rst", ".log"}:
|
|
34
|
+
return "\U0001f4dd" # text
|
|
35
|
+
if ext in {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}:
|
|
36
|
+
return "\U0001f5bc" # image
|
|
37
|
+
return "\U0001f4c3" # generic file
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def file_metadata(path: Path) -> dict[str, str]:
|
|
41
|
+
"""Extract metadata dict for display in fallback viewer."""
|
|
42
|
+
info: dict[str, str] = {
|
|
43
|
+
"Name": path.name,
|
|
44
|
+
"Path": str(path.resolve()) if not path.is_symlink() else str(path),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Check for broken symlinks first (lstat doesn't follow the link)
|
|
48
|
+
if path.is_symlink():
|
|
49
|
+
try:
|
|
50
|
+
target = os.readlink(path)
|
|
51
|
+
info["Link target"] = target
|
|
52
|
+
if not path.exists():
|
|
53
|
+
info["Link status"] = "broken"
|
|
54
|
+
return info
|
|
55
|
+
except OSError:
|
|
56
|
+
info["Link target"] = "(unreadable)"
|
|
57
|
+
return info
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
st = path.stat()
|
|
61
|
+
except OSError:
|
|
62
|
+
info["error"] = "Cannot read file metadata"
|
|
63
|
+
return info
|
|
64
|
+
|
|
65
|
+
info.update({
|
|
66
|
+
"Size": human_size(st.st_size),
|
|
67
|
+
"Size (bytes)": f"{st.st_size:,}",
|
|
68
|
+
"Modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
|
69
|
+
.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
70
|
+
"Created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc)
|
|
71
|
+
.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
72
|
+
"Permissions": stat.filemode(st.st_mode),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
import pwd
|
|
77
|
+
info["Owner"] = pwd.getpwuid(st.st_uid).pw_name
|
|
78
|
+
except (ImportError, KeyError, AttributeError):
|
|
79
|
+
info["Owner"] = str(st.st_uid)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
import grp
|
|
83
|
+
info["Group"] = grp.getgrgid(st.st_gid).gr_name
|
|
84
|
+
except (ImportError, KeyError, AttributeError):
|
|
85
|
+
info["Group"] = str(st.st_gid)
|
|
86
|
+
|
|
87
|
+
return info
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Viewer registry — maps file extensions to viewer classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ncview.viewers.base import BaseViewer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ViewerRegistry:
|
|
13
|
+
"""Maps file extensions to viewer widget classes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._viewers: list[type[BaseViewer]] = []
|
|
17
|
+
|
|
18
|
+
def register(self, viewer_cls: type[BaseViewer]) -> type[BaseViewer]:
|
|
19
|
+
"""Register a viewer class."""
|
|
20
|
+
self._viewers.append(viewer_cls)
|
|
21
|
+
return viewer_cls
|
|
22
|
+
|
|
23
|
+
def get_viewer(self, path: Path) -> type[BaseViewer]:
|
|
24
|
+
"""Return the best viewer class for a given file path."""
|
|
25
|
+
ext = path.suffix.lower()
|
|
26
|
+
best: type[BaseViewer] | None = None
|
|
27
|
+
best_priority = float("-inf")
|
|
28
|
+
|
|
29
|
+
for viewer_cls in self._viewers:
|
|
30
|
+
if ext in viewer_cls.supported_extensions():
|
|
31
|
+
p = viewer_cls.priority()
|
|
32
|
+
if p > best_priority:
|
|
33
|
+
best = viewer_cls
|
|
34
|
+
best_priority = p
|
|
35
|
+
|
|
36
|
+
if best is None:
|
|
37
|
+
# Try text viewer for extensionless files that look like text
|
|
38
|
+
if not ext and _is_likely_text(path):
|
|
39
|
+
from ncview.viewers.text_viewer import TextViewer
|
|
40
|
+
return TextViewer
|
|
41
|
+
from ncview.viewers.fallback_viewer import FallbackViewer
|
|
42
|
+
return FallbackViewer
|
|
43
|
+
|
|
44
|
+
return best
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Common extensionless text files
|
|
48
|
+
_TEXT_FILENAMES = {
|
|
49
|
+
"makefile", "dockerfile", "vagrantfile", "gemfile", "rakefile",
|
|
50
|
+
"license", "licence", "readme", "changelog", "authors", "contributors",
|
|
51
|
+
"todo", "news", "install", "copying", "notice",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_likely_text(path: Path) -> bool:
|
|
56
|
+
"""Heuristic check for extensionless text files."""
|
|
57
|
+
if path.name.lower() in _TEXT_FILENAMES:
|
|
58
|
+
return True
|
|
59
|
+
# Peek at first 512 bytes for null bytes (binary indicator)
|
|
60
|
+
try:
|
|
61
|
+
chunk = path.read_bytes()[:512]
|
|
62
|
+
return b"\x00" not in chunk
|
|
63
|
+
except OSError:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Singleton registry
|
|
68
|
+
registry = ViewerRegistry()
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Abstract base viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual.widget import Widget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseViewer(Widget):
|
|
12
|
+
"""Base class for all file viewers."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
BaseViewer {
|
|
16
|
+
height: 1fr;
|
|
17
|
+
width: 1fr;
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: Path, **kwargs) -> None:
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self.path = path
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def supported_extensions() -> set[str]:
|
|
28
|
+
"""Return set of supported file extensions (e.g. {'.txt', '.py'})."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def priority() -> int:
|
|
33
|
+
"""Higher priority wins when multiple viewers match an extension."""
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def load_content(self) -> None:
|
|
38
|
+
"""Load file content. Called after mount."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
async def on_mount(self) -> None:
|
|
42
|
+
"""Trigger content loading after mount."""
|
|
43
|
+
await self.load_content()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Fallback viewer — shows file metadata for unknown/binary files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from ncview.utils.file_info import file_metadata
|
|
11
|
+
from ncview.viewers.base import BaseViewer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FallbackViewer(BaseViewer):
|
|
15
|
+
"""Shows file metadata when no specialized viewer matches."""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
FallbackViewer {
|
|
19
|
+
height: 1fr;
|
|
20
|
+
padding: 1 2;
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def supported_extensions() -> set[str]:
|
|
26
|
+
return set()
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def priority() -> int:
|
|
30
|
+
return -100
|
|
31
|
+
|
|
32
|
+
def compose(self):
|
|
33
|
+
yield Static(id="fallback-content")
|
|
34
|
+
|
|
35
|
+
async def load_content(self) -> None:
|
|
36
|
+
meta = file_metadata(self.path)
|
|
37
|
+
table = Table(title="File Info", show_header=False, expand=True)
|
|
38
|
+
table.add_column("Key", style="bold cyan", ratio=1)
|
|
39
|
+
table.add_column("Value", ratio=3)
|
|
40
|
+
for key, value in meta.items():
|
|
41
|
+
table.add_row(key, value)
|
|
42
|
+
self.query_one("#fallback-content", Static).update(table)
|