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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .claude/
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
+ ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
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,11 @@
1
+ """Entry point for `python -m ncview`."""
2
+
3
+ from ncview.app import run
4
+
5
+
6
+ def main() -> None:
7
+ run()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -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)