From 9197dda3570700dbb2ac943d0498521c7bd3bb9f Mon Sep 17 00:00:00 2001 From: Agil Mammadov Date: Wed, 6 May 2026 17:46:02 +0400 Subject: [PATCH 1/2] feat: add shell autocompletion for titles and dirs --- docs/commands/completion.rst | 45 ++++++++++++++++ docs/index.rst | 1 + nnote/cli.py | 2 + nnote/commands/backup.py | 3 +- nnote/commands/completion.py | 15 ++++++ nnote/commands/drop.py | 5 +- nnote/commands/edit.py | 5 +- nnote/commands/list.py | 3 +- nnote/commands/move.py | 9 ++-- nnote/commands/new.py | 3 +- nnote/commands/search.py | 3 +- nnote/commands/view.py | 5 +- nnote/completions.py | 33 ++++++++++++ tests/test_completions.py | 100 +++++++++++++++++++++++++++++++++++ 14 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 docs/commands/completion.rst create mode 100644 nnote/commands/completion.py create mode 100644 nnote/completions.py create mode 100644 tests/test_completions.py diff --git a/docs/commands/completion.rst b/docs/commands/completion.rst new file mode 100644 index 0000000..6f12156 --- /dev/null +++ b/docs/commands/completion.rst @@ -0,0 +1,45 @@ +completion +========== + +.. code-block:: none + + nnote completion SHELL + +Print the shell activation line needed to enable tab completion for ``nnote``. + +``SHELL`` must be one of ``bash``, ``zsh``, or ``fish``. + +Once the line is added to your shell config and the shell is restarted (or the +config is sourced), pressing :kbd:`Tab` after a command will complete note +titles and subdirectory names dynamically. + +Examples +-------- + +**bash** — add to ``~/.bashrc``: + +.. code-block:: bash + + eval "$(_NNOTE_COMPLETE=bash_source nnote)" + +**zsh** — add to ``~/.zshrc``: + +.. code-block:: zsh + + eval "$(_NNOTE_COMPLETE=zsh_source nnote)" + +**fish** — add to ``~/.config/fish/config.fish``: + +.. code-block:: fish + + eval (env _NNOTE_COMPLETE=fish_source nnote) + +What gets completed +------------------- + +- **Note titles** — for ``edit``, ``view``, ``drop``, and ``move`` (both + source and destination title arguments). Completion respects the ``-d`` + option: if ``-d mydir`` is already on the command line, only notes inside + ``mydir/`` are suggested. +- **Subdirectory names** — for the ``-d`` / ``--directory`` option on all + commands, and ``--dest-dir`` on ``move``. diff --git a/docs/index.rst b/docs/index.rst index 99b9aaf..078590f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ your environment, it will be used as the editor default. commands/move commands/backup commands/search + commands/completion .. toctree:: :maxdepth: 1 diff --git a/nnote/cli.py b/nnote/cli.py index f3aea77..a79f44a 100644 --- a/nnote/cli.py +++ b/nnote/cli.py @@ -9,6 +9,7 @@ from .commands.search import search from .commands.move import move from .commands.backup import backup +from .commands.completion import completion @click.group() @@ -27,3 +28,4 @@ def cli(): cli.add_command(search) cli.add_command(move) cli.add_command(backup) +cli.add_command(completion) diff --git a/nnote/commands/backup.py b/nnote/commands/backup.py index ed5229d..3f3964d 100644 --- a/nnote/commands/backup.py +++ b/nnote/commands/backup.py @@ -5,11 +5,12 @@ import click from ..config import Config +from ..completions import complete_directories @click.command() @click.argument("output_path", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Subdirectory to back up") +@click.option("-d", "--directory", default=None, help="Subdirectory to back up", shell_complete=complete_directories) @click.option( "--include-config", is_flag=True, diff --git a/nnote/commands/completion.py b/nnote/commands/completion.py new file mode 100644 index 0000000..d612918 --- /dev/null +++ b/nnote/commands/completion.py @@ -0,0 +1,15 @@ +import click + + +_ACTIVATION = { + "bash": 'eval "$(_NNOTE_COMPLETE=bash_source nnote)"', + "zsh": 'eval "$(_NNOTE_COMPLETE=zsh_source nnote)"', + "fish": "eval (env _NNOTE_COMPLETE=fish_source nnote)", +} + + +@click.command() +@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) +def completion(shell): + """Print shell completion setup instructions.""" + click.echo(f"Add this to your shell config:\n\n {_ACTIVATION[shell]}") diff --git a/nnote/commands/drop.py b/nnote/commands/drop.py index bacc81a..b4aaf0c 100644 --- a/nnote/commands/drop.py +++ b/nnote/commands/drop.py @@ -2,11 +2,12 @@ import shutil from ..config import Config from ..notes import resolve_note_path +from ..completions import complete_note_titles, complete_directories @click.command() -@click.argument("title", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir") +@click.argument("title", required=False, default=None, shell_complete=complete_note_titles) +@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) def drop(title, directory): """Remove a note or a directory.""" config = Config.load() diff --git a/nnote/commands/edit.py b/nnote/commands/edit.py index fb14bb6..19e997a 100644 --- a/nnote/commands/edit.py +++ b/nnote/commands/edit.py @@ -1,11 +1,12 @@ import click from ..config import Config from ..notes import resolve_note_path, open_in_editor +from ..completions import complete_note_titles, complete_directories @click.command() -@click.argument("title") -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir") +@click.argument("title", shell_complete=complete_note_titles) +@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) def edit(title, directory): """Open an existing note in the configured editor.""" config = Config.load() diff --git a/nnote/commands/list.py b/nnote/commands/list.py index 6f8ea46..e83dd86 100644 --- a/nnote/commands/list.py +++ b/nnote/commands/list.py @@ -1,6 +1,7 @@ import click from pathlib import Path from ..config import Config +from ..completions import complete_directories def _print_tree(root: Path, prefix: str = "") -> int: @@ -19,7 +20,7 @@ def _print_tree(root: Path, prefix: str = "") -> int: @click.command(name="list") -@click.option("-d", "--directory", default=None, help="Subdirectory to list") +@click.option("-d", "--directory", default=None, help="Subdirectory to list", shell_complete=complete_directories) def list_notes(directory): """List notes and directories.""" config = Config.load() diff --git a/nnote/commands/move.py b/nnote/commands/move.py index 22c33dc..08ac20e 100644 --- a/nnote/commands/move.py +++ b/nnote/commands/move.py @@ -1,13 +1,14 @@ import click from ..config import Config from ..notes import resolve_note_path +from ..completions import complete_note_titles, complete_directories @click.command() -@click.argument("title") -@click.argument("dest_title", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Source subdirectory") -@click.option("--dest-dir", default=None, help="Destination subdirectory") +@click.argument("title", shell_complete=complete_note_titles) +@click.argument("dest_title", required=False, default=None, shell_complete=complete_note_titles) +@click.option("-d", "--directory", default=None, help="Source subdirectory", shell_complete=complete_directories) +@click.option("--dest-dir", default=None, help="Destination subdirectory", shell_complete=complete_directories) def move(title, dest_title, directory, dest_dir): """Move or rename a note.""" config = Config.load() diff --git a/nnote/commands/new.py b/nnote/commands/new.py index 2a7c269..c6538d0 100644 --- a/nnote/commands/new.py +++ b/nnote/commands/new.py @@ -1,11 +1,12 @@ import click from ..config import Config from ..notes import resolve_note_path, open_in_editor +from ..completions import complete_directories @click.command() @click.argument("title", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir") +@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) def new(title, directory): """Create a new note and open it in the configured editor.""" config = Config.load() diff --git a/nnote/commands/search.py b/nnote/commands/search.py index 53d8430..0080e20 100644 --- a/nnote/commands/search.py +++ b/nnote/commands/search.py @@ -1,11 +1,12 @@ import click from ..config import Config from ..search import search_notes, highlight +from ..completions import complete_directories @click.command() @click.argument("query") -@click.option("-d", "--directory", default=None, help="Scope search to a subdirectory") +@click.option("-d", "--directory", default=None, help="Scope search to a subdirectory", shell_complete=complete_directories) def search(query, directory): """Search notes by title and content.""" config = Config.load() diff --git a/nnote/commands/view.py b/nnote/commands/view.py index 5a0c160..6616b46 100644 --- a/nnote/commands/view.py +++ b/nnote/commands/view.py @@ -1,11 +1,12 @@ import click from ..config import Config from ..notes import resolve_note_path +from ..completions import complete_note_titles, complete_directories @click.command() -@click.argument("title") -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir") +@click.argument("title", shell_complete=complete_note_titles) +@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) def view(title, directory): """Print the contents of a note.""" config = Config.load() diff --git a/nnote/completions.py b/nnote/completions.py new file mode 100644 index 0000000..dae3d1d --- /dev/null +++ b/nnote/completions.py @@ -0,0 +1,33 @@ +from click.shell_completion import CompletionItem +from .config import Config + + +def complete_note_titles(ctx, param, incomplete): + try: + config = Config.load() + directory = ctx.params.get("directory") + root = config.notes_dir / directory if directory else config.notes_dir + if not root or not root.exists(): + return [] + return [ + CompletionItem(p.name) + for p in sorted(root.iterdir()) + if p.is_file() and p.name.startswith(incomplete) + ] + except Exception: + return [] + + +def complete_directories(ctx, param, incomplete): + try: + config = Config.load() + root = config.notes_dir + if not root or not root.exists(): + return [] + return [ + CompletionItem(p.name) + for p in sorted(root.iterdir()) + if p.is_dir() and p.name.startswith(incomplete) + ] + except Exception: + return [] diff --git a/tests/test_completions.py b/tests/test_completions.py new file mode 100644 index 0000000..693da34 --- /dev/null +++ b/tests/test_completions.py @@ -0,0 +1,100 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.shell_completion import CompletionItem + +from nnote.completions import complete_note_titles, complete_directories +from nnote.config import Config + + +class _FakeCtx: + def __init__(self, params=None): + self.params = params or {} + + +def _config_with_notes_dir(tmp_path) -> Config: + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text(f"notes_dir: {tmp_path / 'notes'}\n") + return Config.load(cfg_file) + + +def _make_notes(notes_dir: Path, files, dirs=()): + notes_dir.mkdir(parents=True, exist_ok=True) + for name in files: + (notes_dir / name).touch() + for name in dirs: + (notes_dir / name).mkdir() + + +@pytest.fixture +def cfg(tmp_path): + return _config_with_notes_dir(tmp_path) + + +def test_complete_note_titles_returns_matching_files(cfg): + _make_notes(cfg.notes_dir, ["alpha", "beta", "another"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_note_titles(_FakeCtx(), None, "a") + names = [r.value for r in results] + assert "alpha" in names + assert "another" in names + assert "beta" not in names + + +def test_complete_note_titles_empty_prefix_returns_all(cfg): + _make_notes(cfg.notes_dir, ["x", "y"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_note_titles(_FakeCtx(), None, "") + assert {r.value for r in results} == {"x", "y"} + + +def test_complete_note_titles_respects_directory(cfg): + subdir = cfg.notes_dir / "work" + _make_notes(subdir, ["report", "review"]) + _make_notes(cfg.notes_dir, ["readme"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_note_titles(_FakeCtx({"directory": "work"}), None, "") + names = {r.value for r in results} + assert names == {"report", "review"} + assert "readme" not in names + + +def test_complete_note_titles_no_dirs_in_results(cfg): + _make_notes(cfg.notes_dir, ["note"], dirs=["subdir"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_note_titles(_FakeCtx(), None, "") + names = [r.value for r in results] + assert "subdir" not in names + assert "note" in names + + +def test_complete_directories_returns_subdirs(cfg): + _make_notes(cfg.notes_dir, ["note"], dirs=["work", "personal"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_directories(_FakeCtx(), None, "") + names = {r.value for r in results} + assert names == {"work", "personal"} + assert "note" not in names + + +def test_complete_directories_filters_by_prefix(cfg): + _make_notes(cfg.notes_dir, [], dirs=["work", "personal", "projects"]) + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_directories(_FakeCtx(), None, "p") + names = {r.value for r in results} + assert names == {"personal", "projects"} + assert "work" not in names + + +def test_complete_note_titles_missing_notes_dir_returns_empty(tmp_path): + cfg = Config.load(tmp_path / "config.yaml") + with patch("nnote.completions.Config.load", return_value=cfg): + results = complete_note_titles(_FakeCtx(), None, "") + assert results == [] + + +def test_complete_note_titles_exception_returns_empty(): + with patch("nnote.completions.Config.load", side_effect=RuntimeError("boom")): + results = complete_note_titles(_FakeCtx(), None, "") + assert results == [] From 31bc4952a54da8ab3fbacf54ef81ececf3af6c33 Mon Sep 17 00:00:00 2001 From: Agil Mammadov Date: Wed, 6 May 2026 17:52:09 +0400 Subject: [PATCH 2/2] refactor: format --- nnote/commands/backup.py | 8 +++++++- nnote/commands/drop.py | 12 ++++++++++-- nnote/commands/edit.py | 8 +++++++- nnote/commands/list.py | 8 +++++++- nnote/commands/move.py | 19 ++++++++++++++++--- nnote/commands/new.py | 8 +++++++- nnote/commands/search.py | 8 +++++++- nnote/commands/view.py | 8 +++++++- 8 files changed, 68 insertions(+), 11 deletions(-) diff --git a/nnote/commands/backup.py b/nnote/commands/backup.py index 3f3964d..b4f33e6 100644 --- a/nnote/commands/backup.py +++ b/nnote/commands/backup.py @@ -10,7 +10,13 @@ @click.command() @click.argument("output_path", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Subdirectory to back up", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory to back up", + shell_complete=complete_directories, +) @click.option( "--include-config", is_flag=True, diff --git a/nnote/commands/drop.py b/nnote/commands/drop.py index b4aaf0c..0c0e21c 100644 --- a/nnote/commands/drop.py +++ b/nnote/commands/drop.py @@ -6,8 +6,16 @@ @click.command() -@click.argument("title", required=False, default=None, shell_complete=complete_note_titles) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) +@click.argument( + "title", required=False, default=None, shell_complete=complete_note_titles +) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory within notes dir", + shell_complete=complete_directories, +) def drop(title, directory): """Remove a note or a directory.""" config = Config.load() diff --git a/nnote/commands/edit.py b/nnote/commands/edit.py index 19e997a..dfb3fa8 100644 --- a/nnote/commands/edit.py +++ b/nnote/commands/edit.py @@ -6,7 +6,13 @@ @click.command() @click.argument("title", shell_complete=complete_note_titles) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory within notes dir", + shell_complete=complete_directories, +) def edit(title, directory): """Open an existing note in the configured editor.""" config = Config.load() diff --git a/nnote/commands/list.py b/nnote/commands/list.py index e83dd86..a49543f 100644 --- a/nnote/commands/list.py +++ b/nnote/commands/list.py @@ -20,7 +20,13 @@ def _print_tree(root: Path, prefix: str = "") -> int: @click.command(name="list") -@click.option("-d", "--directory", default=None, help="Subdirectory to list", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory to list", + shell_complete=complete_directories, +) def list_notes(directory): """List notes and directories.""" config = Config.load() diff --git a/nnote/commands/move.py b/nnote/commands/move.py index 08ac20e..cc28c87 100644 --- a/nnote/commands/move.py +++ b/nnote/commands/move.py @@ -6,9 +6,22 @@ @click.command() @click.argument("title", shell_complete=complete_note_titles) -@click.argument("dest_title", required=False, default=None, shell_complete=complete_note_titles) -@click.option("-d", "--directory", default=None, help="Source subdirectory", shell_complete=complete_directories) -@click.option("--dest-dir", default=None, help="Destination subdirectory", shell_complete=complete_directories) +@click.argument( + "dest_title", required=False, default=None, shell_complete=complete_note_titles +) +@click.option( + "-d", + "--directory", + default=None, + help="Source subdirectory", + shell_complete=complete_directories, +) +@click.option( + "--dest-dir", + default=None, + help="Destination subdirectory", + shell_complete=complete_directories, +) def move(title, dest_title, directory, dest_dir): """Move or rename a note.""" config = Config.load() diff --git a/nnote/commands/new.py b/nnote/commands/new.py index c6538d0..684648d 100644 --- a/nnote/commands/new.py +++ b/nnote/commands/new.py @@ -6,7 +6,13 @@ @click.command() @click.argument("title", required=False, default=None) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory within notes dir", + shell_complete=complete_directories, +) def new(title, directory): """Create a new note and open it in the configured editor.""" config = Config.load() diff --git a/nnote/commands/search.py b/nnote/commands/search.py index 0080e20..bd19db1 100644 --- a/nnote/commands/search.py +++ b/nnote/commands/search.py @@ -6,7 +6,13 @@ @click.command() @click.argument("query") -@click.option("-d", "--directory", default=None, help="Scope search to a subdirectory", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Scope search to a subdirectory", + shell_complete=complete_directories, +) def search(query, directory): """Search notes by title and content.""" config = Config.load() diff --git a/nnote/commands/view.py b/nnote/commands/view.py index 6616b46..ab7cc78 100644 --- a/nnote/commands/view.py +++ b/nnote/commands/view.py @@ -6,7 +6,13 @@ @click.command() @click.argument("title", shell_complete=complete_note_titles) -@click.option("-d", "--directory", default=None, help="Subdirectory within notes dir", shell_complete=complete_directories) +@click.option( + "-d", + "--directory", + default=None, + help="Subdirectory within notes dir", + shell_complete=complete_directories, +) def view(title, directory): """Print the contents of a note.""" config = Config.load()