Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ tests/animation/
demo/
events.jsonl
NEW-TODO.md
NEW-TODO.md~
work/
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **JPEG XL output** — `map` and `diff` accept `--format jxl` or infer JXL from a `.jxl` output
filename. `git` and `hg` support `.jxl` for static snapshots, and `watch` supports `.jxl`
snapshots. Install the optional encoder with `pip install "dirplot[jxl]"`.

- **Manual reload and auto-reload controls in `serve`** — the web UI gained a toolbar **Reload**
button that rescans the source and updates the treemap in place (preserving the current zoom and
without touching the sidebar). A new **Auto-reload** toggle in the Settings tab connects or
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dirplot map . --inline # display inline in terminal (iTerm2 / Kitty / Ghostty)
## Features

- Squarified treemap layout; file area proportional to size; per-extension colours (GitHub Linguist palette for known types, configurable Matplotlib colormap for the rest).
- PNG, animated PNG (APNG), MP4, and MOV output for single frames and animations; interactive SVG for static maps; renders at terminal pixel size or a custom `WIDTHxHEIGHT`.
- PNG, optional JPEG XL (JXL), animated PNG (APNG), MP4, and MOV output; interactive SVG for static maps; renders at terminal pixel size or a custom `WIDTHxHEIGHT`.
- **Inline terminal display** — renders directly into iTerm2, Kitty, Ghostty, WezTerm, and Warp without opening a separate window; protocol auto-detected.
- **Animate git history** (`dirplot git`), **Mercurial history** (`dirplot hg`), and **replay filesystem event logs** (`dirplot replay`) — output APNG, MP4, or MOV. **Watch live filesystems** (`dirplot watch`) with optional snapshot and event logging.
- **Scan metrics** (`dirplot metrics`) — file/dir counts, total size, depth, top extensions by count or size, largest files and directories with percentage of total; JSON output supported.
Expand Down
1 change: 1 addition & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Install only what you need:
| `ssh` | Scan remote servers via SSH (adds [paramiko](https://www.paramiko.org/)) | `pip install "dirplot[ssh]"` |
| `s3` | Scan AWS S3 buckets (adds [boto3](https://boto3.amazonaws.com/)) | `pip install "dirplot[s3]"` |
| `libarchive` | Additional archive formats: `.tar.zst`, `.iso`, `.dmg`, `.rpm`, `.cab`, … (requires system [libarchive](https://libarchive.org/)) | `pip install "dirplot[libarchive]"` |
| `jxl` | JPEG XL (`.jxl`) output for static treemaps and snapshots | `pip install "dirplot[jxl]"` |

**Remote backend CLIs** — no Python package needed; dirplot shells out to these directly:

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ dev = [
libarchive = [
"libarchive-c>=5.0",
]
jxl = [
"pillow-jxl-plugin>=1.3.8",
]

[project.urls]
Repository = "https://github.com/deeplook/dirplot"
Expand Down
30 changes: 22 additions & 8 deletions src/dirplot/commands/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def diff_cmd(
fmt: str | None = typer.Option(
None,
"--format",
help="Output format: png or svg (inferred from --output extension if omitted)",
help="Output format: png, svg, or jxl (inferred from --output extension if omitted)",
metavar="FORMAT",
),
show: bool = typer.Option(True, "--show/--no-show", help="Display the image after rendering"),
Expand Down Expand Up @@ -469,13 +469,16 @@ def _collect_paths(node: Node) -> list[str]:

# Resolve format
if fmt is not None:
if fmt not in ("png", "svg"):
typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg", err=True)
output_format = fmt.lower()
if output_format not in ("png", "svg", "jxl"):
typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg, jxl", err=True)
raise typer.Exit(1)
use_svg = fmt == "svg"
elif output is not None and output.suffix.lower() == ".svg":
use_svg = True
use_svg = output_format == "svg"
elif output is not None and output.suffix.lower() in (".svg", ".jxl"):
output_format = output.suffix.lower()[1:]
use_svg = output_format == "svg"
else:
output_format = "png"
use_svg = False

label_a = title_a or Path(tree_a).name
Expand Down Expand Up @@ -513,12 +516,23 @@ def _collect_paths(node: Node) -> list[str]:
)
t_render = time.monotonic() - t_render_start

if output_format == "jxl":
from dirplot.image_formats import encode_jxl

try:
output_buf = encode_jxl(buf)
except RuntimeError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from None
else:
output_buf = buf

if output is not None:
if to_stdout:
sys.stdout.buffer.write(buf.read())
sys.stdout.buffer.write(output_buf.read())
buf.seek(0)
else:
output.write_bytes(buf.read())
output.write_bytes(output_buf.read())
_info(f"Saved diff to {output} [{t_render:.1f}s]")
buf.seek(0)

Expand Down
31 changes: 23 additions & 8 deletions src/dirplot/commands/treemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def main(
fmt: str | None = typer.Option(
None,
"--format",
help="Output format: png or svg. Defaults to svg if --output ends in .svg, else png.",
help="Output format: png, svg, or jxl. Defaults from --output extension, otherwise png.",
metavar="FORMAT",
),
show: bool = typer.Option(True, "--show/--no-show", help="Display the image after rendering"),
Expand Down Expand Up @@ -371,14 +371,18 @@ def _collect_paths(node: Node) -> list[str]:
_info(f"Terminal size: {width_px}x{height_px}px")

# Resolve output format: explicit --format > inferred from --output extension > png
output_format: str
if fmt is not None:
if fmt not in ("png", "svg"):
typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg", err=True)
output_format = fmt.lower()
if output_format not in ("png", "svg", "jxl"):
typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg, jxl", err=True)
raise typer.Exit(1)
use_svg = fmt == "svg"
elif output is not None and output.suffix.lower() == ".svg":
use_svg = True
use_svg = output_format == "svg"
elif output is not None and output.suffix.lower() in (".svg", ".jxl"):
output_format = output.suffix.lower()[1:]
use_svg = output_format == "svg"
else:
output_format = "png"
use_svg = False

_stderr_console = _Console(stderr=True)
Expand Down Expand Up @@ -413,12 +417,23 @@ def _collect_paths(node: Node) -> list[str]:
)
t_render = time.monotonic() - t_render_start

if output_format == "jxl":
from dirplot.image_formats import encode_jxl

try:
output_buf = encode_jxl(buf)
except RuntimeError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from None
else:
output_buf = buf

if output is not None:
if to_stdout:
sys.stdout.buffer.write(buf.read())
sys.stdout.buffer.write(output_buf.read())
buf.seek(0)
else:
output.write_bytes(buf.read())
output.write_bytes(output_buf.read())
if header:
_info(f"Saved dirplot to {output} [{t_render:.1f}s]")
buf.seek(0)
Expand Down
24 changes: 20 additions & 4 deletions src/dirplot/commands/vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,8 @@ def git_cmd(
"Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True
)
raise typer.Exit(1)
if not is_animation and output.suffix.lower() not in {".png", ".svg"}:
typer.echo("Error: snapshot --output must be a .png or .svg file.", err=True)
if not is_animation and output.suffix.lower() not in {".png", ".svg", ".jxl"}:
typer.echo("Error: snapshot --output must be a .png, .svg, or .jxl file.", err=True)
raise typer.Exit(1)

period_dt: datetime | None = None
Expand Down Expand Up @@ -747,6 +747,14 @@ def git_cmd(
display_inline(out_buf, cols=inline_cols)
else:
assert output is not None
if output.suffix.lower() == ".jxl":
from dirplot.image_formats import encode_jxl

try:
out_buf = encode_jxl(out_buf)
except RuntimeError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from None
output.write_bytes(out_buf.read())
if not quiet:
typer.echo(f"Wrote {output}", err=True)
Expand Down Expand Up @@ -937,8 +945,8 @@ def hg_cmd(
"Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True
)
raise typer.Exit(1)
if not is_animation and output.suffix.lower() not in {".png", ".svg"}:
typer.echo("Error: snapshot --output must be a .png or .svg file.", err=True)
if not is_animation and output.suffix.lower() not in {".png", ".svg", ".jxl"}:
typer.echo("Error: snapshot --output must be a .png, .svg, or .jxl file.", err=True)
raise typer.Exit(1)

period_dt: datetime | None = None
Expand Down Expand Up @@ -1214,6 +1222,14 @@ def hg_cmd(
display_inline(out_buf, cols=inline_cols)
else:
assert output is not None
if output.suffix.lower() == ".jxl":
from dirplot.image_formats import encode_jxl

try:
out_buf = encode_jxl(out_buf)
except RuntimeError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from None
output.write_bytes(out_buf.read())
if not quiet:
typer.echo(f"Wrote {output}", err=True)
4 changes: 2 additions & 2 deletions src/dirplot/commands/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def watch_cmd(
None,
"--snapshot",
help=(
"Also write the current treemap as a PNG or SVG on each change. "
"Also write the current treemap as a PNG, SVG, or JXL on each change. "
"Convenient for small trees; avoid on large directories as rendering adds latency."
),
metavar="FILE",
Expand Down Expand Up @@ -117,7 +117,7 @@ def watch_cmd(
"""Watch directories and record filesystem events as JSONL for later replay.

Use `dirplot replay` to turn the recording into an animated treemap.
Pass --snapshot to also write a live PNG/SVG on each change (best for small trees).
Pass --snapshot to also write a live PNG/SVG/JXL on each change (best for small trees).

Example: dirplot watch src --output events.jsonl
"""
Expand Down
25 changes: 25 additions & 0 deletions src/dirplot/image_formats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Helpers for optional raster output formats."""

from __future__ import annotations

import io


def encode_jxl(png: io.BytesIO) -> io.BytesIO:
"""Encode a rendered PNG buffer as JPEG XL using the optional Pillow plugin."""
try:
import pillow_jxl # noqa: F401 # Registers JXL with Pillow.
except ImportError:
raise RuntimeError(
"JPEG XL output requires an optional dependency. Install it with: "
"pip install 'dirplot[jxl]'"
) from None

from PIL import Image

png.seek(0)
with Image.open(png) as image:
result = io.BytesIO()
image.save(result, format="JXL", lossless=True)
result.seek(0)
return result
5 changes: 5 additions & 0 deletions src/dirplot/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __init__(
self.cushion = cushion
self.dark = dark
self.use_svg = snapshot is not None and snapshot.suffix.lower() == ".svg"
self.use_jxl = snapshot is not None and snapshot.suffix.lower() == ".jxl"
self.logscale = logscale
self.debounce = debounce
self.output = output
Expand Down Expand Up @@ -140,6 +141,10 @@ def _collect(n: _Node) -> list[str]:
logscale=self.logscale,
)
if self.snapshot is not None:
if self.use_jxl:
from dirplot.image_formats import encode_jxl

buf = encode_jxl(buf)
self.snapshot.write_bytes(buf.read())
print(f"Updated {self.snapshot}", file=sys.stderr)
self._prev_rect_map = rect_map
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,43 @@ def test_cli_stdout_svg(sample_tree: Path) -> None:
assert "<?xml" not in result.stderr


def test_map_jxl_output(sample_tree: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import dirplot.image_formats

monkeypatch.setattr(dirplot.image_formats, "encode_jxl", lambda _buf: BytesIO(b"mock-jxl"))
output = tmp_path / "map.jxl"
result = runner.invoke(
app, ["map", str(sample_tree), "--no-show", "--output", str(output), "--canvas", "100x100"]
)
assert result.exit_code == 0, result.output
assert output.read_bytes() == b"mock-jxl"


def test_map_jxl_format_option(
sample_tree: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import dirplot.image_formats

monkeypatch.setattr(dirplot.image_formats, "encode_jxl", lambda _buf: BytesIO(b"mock-jxl"))
output = tmp_path / "map.data"
result = runner.invoke(
app,
[
"map",
str(sample_tree),
"--no-show",
"--output",
str(output),
"--format",
"jxl",
"--canvas",
"100x100",
],
)
assert result.exit_code == 0, result.output
assert output.read_bytes() == b"mock-jxl"


def test_cli_literal_dash_paths_after_separator(tmp_path: Path) -> None:
for name in ("--no-color", "--legend"):
target = tmp_path / name
Expand Down
14 changes: 11 additions & 3 deletions tests/test_highlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
runner = CliRunner()


def _image_pixels(img: Image.Image) -> list[tuple[int, ...]]:
"""Return pixels without using Pillow's deprecated ``getdata`` on new versions."""
get_flattened_data = getattr(img, "get_flattened_data", None)
if get_flattened_data is not None:
return list(get_flattened_data())
return list(img.getdata())


# ---------------------------------------------------------------------------
# resolve_highlight_specs
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,7 +152,7 @@ def test_png_highlight_border_pixels(tmp_path: Path) -> None:

# Check that at least one red-ish pixel exists in the image
# (the highlight border is drawn in pure red (255, 0, 0))
pixels = list(img.getdata())
pixels = _image_pixels(img)
assert any(r > 200 and g < 50 and b < 50 for r, g, b in pixels), (
"Expected at least one red border pixel from the highlight"
)
Expand All @@ -159,7 +167,7 @@ def test_png_no_highlight_no_red_border(tmp_path: Path) -> None:
buf = create_treemap(root, width_px=400, height_px=300, cushion=False)
img = Image.open(buf).convert("RGB")

pixels = list(img.getdata())
pixels = _image_pixels(img)
# The Linguist palette for .py and .md does not use pure red
pure_red = sum(1 for r, g, b in pixels if r > 240 and g < 20 and b < 20)
assert pure_red == 0, f"Unexpected {pure_red} pure-red pixels without highlights"
Expand All @@ -181,7 +189,7 @@ def test_png_custom_color_highlight(tmp_path: Path) -> None:
highlights={target: "green"},
)
img = Image.open(buf).convert("RGB")
pixels = list(img.getdata())
pixels = _image_pixels(img)
# Expect at least one clearly greenish pixel
assert any(g > 100 and r < 50 and b < 50 for r, g, b in pixels), "Expected green border pixels"

Expand Down
24 changes: 24 additions & 0 deletions tests/test_image_formats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Tests for optional raster image encoders."""

from __future__ import annotations

from io import BytesIO

import pytest
from PIL import Image

from dirplot.image_formats import encode_jxl


def test_encode_jxl_round_trip() -> None:
pytest.importorskip("pillow_jxl")

source = BytesIO()
Image.new("RGB", (3, 2), (12, 34, 56)).save(source, format="PNG")

result = encode_jxl(source)

with Image.open(result) as decoded:
assert decoded.format == "JXL"
assert decoded.size == (3, 2)
assert decoded.convert("RGB").getpixel((0, 0)) == (12, 34, 56)
Loading
Loading