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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.5.1] - 2026-05-28

### Fixed

- **`click` declared as an explicit dependency** — `dirplot` imports `click` directly in
`_overview.py`, but relied on it being pulled in transitively by `typer`. Starting around
typer 0.12, click became optional in some typer builds, causing `ModuleNotFoundError: No module
named 'click'` when running the installed tool under Python 3.14. `click>=8.0` is now a direct
dependency.

### Added

- **SVG snapshot output for `git` and `hg`** — both commands now accept `--output file.svg` for
single-frame snapshots, producing an interactive SVG (hover tooltips, CSS highlight) identical to
`map`'s SVG output. Animation output (APNG/MP4) is unchanged and still requires `.png`/`.mp4`/`.mov`.

- **Remote URL support for `hg`** — `dirplot hg` now accepts `https://`, `http://`, and `ssh://`
URLs directly (e.g. `dirplot hg https://hg.reportlab.com/hg-public/reportlab --inline`). The
repository is cloned into a temporary directory automatically, mirroring the GitHub URL support
already available in `dirplot git`.

## [0.5.0] - 2026-05-20

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "dirplot"
version = "0.5.0"
version = "0.5.1"
description = "Nested treemap visualizations for directory trees and archives"
readme = "README.md"
license = {text = "MIT"}
Expand Down Expand Up @@ -37,6 +37,7 @@ dependencies = [
"py7zr>=0.20",
"rarfile>=4.0",
"squarify>=0.4",
"click>=8.0",
"typer>=0.9",
"watchdog>=6.0.0",
]
Expand Down
151 changes: 106 additions & 45 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() != ".png":
typer.echo("Error: snapshot --output must be a .png file.", err=True)
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)
raise typer.Exit(1)

period_dt: datetime | None = None
Expand Down Expand Up @@ -706,31 +706,48 @@ def git_cmd(
if logscale > 1:
apply_log_sizes(node, logscale)

from dirplot.render_png import create_treemap

hl: dict[str, str] | None = None
if highlight:
abs_paths = [(repo / rel).as_posix() for rel in files]
hl = resolve_highlight_specs(highlight, abs_paths)

png_buf = create_treemap(
node,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
title_suffix=f"sha:{sha[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}",
dark=dark,
logscale=logscale,
highlights=hl,
)
use_svg = output is not None and output.suffix.lower() == ".svg"
if use_svg:
from dirplot.svg_render import create_treemap_svg

out_buf = create_treemap_svg(
node,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
depth,
dark,
highlights=hl,
)
else:
from dirplot.render_png import create_treemap

out_buf = create_treemap(
node,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
title_suffix=f"sha:{sha[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501
dark=dark,
logscale=logscale,
highlights=hl,
)
if inline:
display_inline(png_buf, cols=inline_cols)
display_inline(out_buf, cols=inline_cols)
else:
assert output is not None
output.write_bytes(png_buf.read())
output.write_bytes(out_buf.read())
if not quiet:
typer.echo(f"Wrote {output}", err=True)

Expand Down Expand Up @@ -865,6 +882,7 @@ def hg_cmd(
"""
import shutil
import subprocess
import tempfile
from datetime import datetime

if not shutil.which("hg"):
Expand Down Expand Up @@ -919,8 +937,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() != ".png":
typer.echo("Error: snapshot --output must be a .png file.", err=True)
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)
raise typer.Exit(1)

period_dt: datetime | None = None
Expand All @@ -932,15 +950,41 @@ def hg_cmd(
raise typer.Exit(1) from exc

_at_ref: str | None = None
if "@" in repo_arg:
repo_path_str, _, _at_ref = repo_arg.partition("@")
_hg_tmpdir: tempfile.TemporaryDirectory[str] | None = None
repo: Path

_is_remote_url = repo_arg.startswith(("https://", "http://", "ssh://"))

if _is_remote_url:
if "@" in repo_arg:
_url, _, _at_ref = repo_arg.partition("@")
else:
_url = repo_arg
_repo_name = _url.rstrip("/").split("/")[-1] or "repo"
_hg_tmpdir = tempfile.TemporaryDirectory(prefix="dirplot-hg-")
_clone_dir = Path(_hg_tmpdir.name) / _repo_name
clone_cmd = ["hg", "clone"]
if _at_ref:
clone_cmd += ["--rev", _at_ref]
clone_cmd += [_url, str(_clone_dir)]
if not quiet:
typer.echo(f"Cloning {_url} ...", err=True)
try:
subprocess.run(clone_cmd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
typer.echo(f"Error cloning repository: {exc.stderr.strip()}", err=True)
raise typer.Exit(1) from exc
repo = _clone_dir
else:
repo_path_str = repo_arg
repo = Path(repo_path_str).resolve()
if "@" in repo_arg:
repo_path_str, _, _at_ref = repo_arg.partition("@")
else:
repo_path_str = repo_arg
repo = Path(repo_path_str).resolve()

if not (repo / ".hg").exists():
typer.echo(f"Error: not a Mercurial repository: {repo}", err=True)
raise typer.Exit(1)
if not (repo / ".hg").exists():
typer.echo(f"Error: not a Mercurial repository: {repo}", err=True)
raise typer.Exit(1)

if canvas is not None:
try:
Expand Down Expand Up @@ -1129,30 +1173,47 @@ def hg_cmd(
if logscale > 1:
apply_log_sizes(node_tree, logscale)

from dirplot.render_png import create_treemap

hl_hg: dict[str, str] | None = None
if highlight:
abs_paths = [(repo / rel).as_posix() for rel in files]
hl_hg = resolve_highlight_specs(highlight, abs_paths)

png_buf = create_treemap(
node_tree,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
title_suffix=f"rev:{node_id[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501
dark=dark,
logscale=logscale,
highlights=hl_hg,
)
use_svg = output is not None and output.suffix.lower() == ".svg"
if use_svg:
from dirplot.svg_render import create_treemap_svg

out_buf = create_treemap_svg(
node_tree,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
depth,
dark,
highlights=hl_hg,
)
else:
from dirplot.render_png import create_treemap

out_buf = create_treemap(
node_tree,
width_px,
height_px,
font_size,
colormap,
None,
cushion,
title_suffix=f"rev:{node_id[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501
dark=dark,
logscale=logscale,
highlights=hl_hg,
)
if inline:
display_inline(png_buf, cols=inline_cols)
display_inline(out_buf, cols=inline_cols)
else:
assert output is not None
output.write_bytes(png_buf.read())
output.write_bytes(out_buf.read())
if not quiet:
typer.echo(f"Wrote {output}", err=True)
25 changes: 25 additions & 0 deletions tests/test_git_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,31 @@ def git(*args: str) -> None:
return repo


def test_git_local_snapshot_svg(local_repo: Path, tmp_path: Path) -> None:
"""dirplot git renders a snapshot as SVG when --output ends in .svg."""
out = tmp_path / "out.svg"
result = runner.invoke(
app,
["git", str(local_repo), "--output", str(out), "--canvas", "200x150"],
)
assert result.exit_code == 0, result.output
assert out.exists()
assert out.stat().st_size > 0
content = out.read_text()
assert "<svg" in content


def test_git_snapshot_invalid_extension_rejected(local_repo: Path, tmp_path: Path) -> None:
"""A snapshot --output with an unsupported extension exits 1."""
out = tmp_path / "out.jpg"
result = runner.invoke(
app,
["git", str(local_repo), "--output", str(out), "--canvas", "200x150"],
)
assert result.exit_code == 1
assert ".png" in result.output and ".svg" in result.output


def test_git_local_at_branch(local_repo: Path, tmp_path: Path) -> None:
"""dirplot git path@branch renders the branch without --range."""
out = tmp_path / "out.png"
Expand Down
25 changes: 25 additions & 0 deletions tests/test_hg_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ def local_hg_repo(tmp_path: Path) -> Path:
return repo


def test_hg_local_static_svg(local_hg_repo: Path, tmp_path: Path) -> None:
"""dirplot hg renders a static SVG for the final changeset."""
out = tmp_path / "out.svg"
result = runner.invoke(
app,
["hg", str(local_hg_repo), "--output", str(out), "--canvas", "200x150"],
)
assert result.exit_code == 0, result.output
assert out.exists()
assert out.stat().st_size > 0
content = out.read_text()
assert "<svg" in content


def test_hg_snapshot_invalid_extension_rejected(local_hg_repo: Path, tmp_path: Path) -> None:
"""A snapshot --output with an unsupported extension exits 1."""
out = tmp_path / "out.jpg"
result = runner.invoke(
app,
["hg", str(local_hg_repo), "--output", str(out), "--canvas", "200x150"],
)
assert result.exit_code == 1
assert ".png" in result.output and ".svg" in result.output


def test_hg_local_static_png(local_hg_repo: Path, tmp_path: Path) -> None:
"""dirplot hg renders a static PNG for the final changeset."""
out = tmp_path / "out.png"
Expand Down
4 changes: 3 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading