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
45 changes: 45 additions & 0 deletions docs/commands/completion.rst
Original file line number Diff line number Diff line change
@@ -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``.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions nnote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -27,3 +28,4 @@ def cli():
cli.add_command(search)
cli.add_command(move)
cli.add_command(backup)
cli.add_command(completion)
9 changes: 8 additions & 1 deletion nnote/commands/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@
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,
Expand Down
15 changes: 15 additions & 0 deletions nnote/commands/completion.py
Original file line number Diff line number Diff line change
@@ -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]}")
13 changes: 11 additions & 2 deletions nnote/commands/drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@
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()
Expand Down
11 changes: 9 additions & 2 deletions nnote/commands/edit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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()
Expand Down
9 changes: 8 additions & 1 deletion nnote/commands/list.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -19,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")
@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()
Expand Down
22 changes: 18 additions & 4 deletions nnote/commands/move.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
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()
Expand Down
9 changes: 8 additions & 1 deletion nnote/commands/new.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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()
Expand Down
9 changes: 8 additions & 1 deletion nnote/commands/search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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()
Expand Down
11 changes: 9 additions & 2 deletions nnote/commands/view.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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()
Expand Down
33 changes: 33 additions & 0 deletions nnote/completions.py
Original file line number Diff line number Diff line change
@@ -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 []
100 changes: 100 additions & 0 deletions tests/test_completions.py
Original file line number Diff line number Diff line change
@@ -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 == []