From 71899b99caa95497ea2018538be1aab3090f4e35 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:12:31 +0200
Subject: [PATCH 01/25] Add a module to open an editor if defined via env
variable
---
src/inkflow/edit.py | 67 +++++++++++++++++++++++++++++++
tests/test_edit.py | 96 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 163 insertions(+)
create mode 100644 src/inkflow/edit.py
create mode 100644 tests/test_edit.py
diff --git a/src/inkflow/edit.py b/src/inkflow/edit.py
new file mode 100644
index 00000000..fd85ec04
--- /dev/null
+++ b/src/inkflow/edit.py
@@ -0,0 +1,67 @@
+"""Launching an external editor on a slide's source file, from the presenter.
+
+Resolution is env-var only (``INKFLOW_EDIT_CMD_SVG`` / ``INKFLOW_EDIT_CMD_MD``): no
+command is bundled by default because "jump an already-open editor to this file"
+is inherently editor- and machine-specific (see the two env vars' docs). When
+unset, the presenter falls back to copying the path to the clipboard instead.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+from inkflow.logging import logger
+
+
+@dataclass(frozen=True)
+class EditCommands:
+ svg: str | None
+ md: str | None
+
+
+NO_EDIT_COMMANDS = EditCommands(svg=None, md=None)
+"""Shared "nothing configured" value, so callers with no real commands to pass
+(export.py's build_html call, default handler args) don't each construct their own
+equal-but-distinct instance — and so it can be used as a default argument without
+ruff's B008 (no function call in a default value)."""
+
+
+def resolve_edit_commands() -> EditCommands:
+ return EditCommands(
+ svg=os.environ.get("INKFLOW_EDIT_CMD_SVG"),
+ md=os.environ.get("INKFLOW_EDIT_CMD_MD"),
+ )
+
+
+def command_for(path: Path, commands: EditCommands) -> str | None:
+ return commands.svg if path.suffix.lower() == ".svg" else commands.md
+
+
+def open_in_editor(path: Path, template: str) -> None:
+ """Launch ``template`` with ``path`` substituted, detached from this process.
+
+ ``{path}`` is substituted into every token that contains it; if no token does,
+ the path is appended as a final argument (the ``$EDITOR file`` convention).
+ Never raises: a bad template or missing binary is a warning, not a crash.
+ """
+ args = shlex.split(template)
+ substituted = [a.replace("{path}", str(path)) for a in args]
+ if not any("{path}" in a for a in args):
+ substituted.append(str(path))
+
+ devnull = os.open(os.devnull, os.O_WRONLY)
+ try:
+ subprocess.Popen(
+ substituted,
+ stdout=devnull,
+ stderr=devnull,
+ stdin=subprocess.DEVNULL,
+ )
+ except OSError as e:
+ logger.warning(f"failed to launch edit command {template!r}: {e}")
+ finally:
+ os.close(devnull)
diff --git a/tests/test_edit.py b/tests/test_edit.py
new file mode 100644
index 00000000..61bf02d2
--- /dev/null
+++ b/tests/test_edit.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import logging
+import subprocess
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from inkflow.edit import (
+ EditCommands,
+ command_for,
+ open_in_editor,
+ resolve_edit_commands,
+)
+from inkflow.logging import collect_logs
+
+_ENV_VARS = ("INKFLOW_EDIT_CMD_SVG", "INKFLOW_EDIT_CMD_MD")
+
+
+@pytest.fixture(autouse=True)
+def _isolated_env(monkeypatch: pytest.MonkeyPatch): # pyright: ignore[reportUnusedFunction]
+ for name in _ENV_VARS:
+ monkeypatch.delenv(name, raising=False)
+ yield
+
+
+# ── resolve_edit_commands ────────────────────────────────────────────────────
+
+
+def test_resolve_edit_commands_unset() -> None:
+ assert resolve_edit_commands() == EditCommands(svg=None, md=None)
+
+
+def test_resolve_edit_commands_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("INKFLOW_EDIT_CMD_SVG", "code -r {path}")
+ monkeypatch.setenv("INKFLOW_EDIT_CMD_MD", "nvim {path}")
+ assert resolve_edit_commands() == EditCommands(
+ svg="code -r {path}", md="nvim {path}"
+ )
+
+
+# ── command_for ───────────────────────────────────────────────────────────────
+
+
+def test_command_for_svg_suffix() -> None:
+ commands = EditCommands(svg="edit-svg", md="edit-md")
+ assert command_for(Path("slide.svg"), commands) == "edit-svg"
+
+
+def test_command_for_svg_suffix_case_insensitive() -> None:
+ commands = EditCommands(svg="edit-svg", md="edit-md")
+ assert command_for(Path("slide.SVG"), commands) == "edit-svg"
+
+
+def test_command_for_other_suffix_uses_md() -> None:
+ commands = EditCommands(svg="edit-svg", md="edit-md")
+ assert command_for(Path("notes.md"), commands) == "edit-md"
+
+
+def test_command_for_none_configured() -> None:
+ commands = EditCommands(svg=None, md=None)
+ assert command_for(Path("slide.svg"), commands) is None
+
+
+# ── open_in_editor ────────────────────────────────────────────────────────────
+
+
+def test_open_in_editor_substitutes_placeholder(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ popen = MagicMock()
+ monkeypatch.setattr(subprocess, "Popen", popen)
+ open_in_editor(Path("/tmp/slide.svg"), "code -r --goto {path}")
+ args = popen.call_args[0][0] # pyright: ignore[reportAny]
+ assert args == ["code", "-r", "--goto", "/tmp/slide.svg"]
+
+
+def test_open_in_editor_appends_path_when_no_placeholder(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ popen = MagicMock()
+ monkeypatch.setattr(subprocess, "Popen", popen)
+ open_in_editor(Path("/tmp/notes.md"), "nvim")
+ args = popen.call_args[0][0] # pyright: ignore[reportAny]
+ assert args == ["nvim", "/tmp/notes.md"]
+
+
+def test_open_in_editor_launch_failure_warns(monkeypatch: pytest.MonkeyPatch) -> None:
+ def _raise(*_args: object, **_kwargs: object) -> None:
+ raise OSError("no such file or directory")
+
+ monkeypatch.setattr(subprocess, "Popen", _raise)
+ with collect_logs(logging.WARNING) as warnings:
+ open_in_editor(Path("/tmp/slide.svg"), "not-a-real-editor {path}")
+ assert any("failed to launch edit command" in w.message for w in warnings)
From 004de0ee3a98fdfe66b1dbce701bbdf28a270fb7 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:27:17 +0200
Subject: [PATCH 02/25] Store dict of editable files in the pipeline
---
src/inkflow/pipeline.py | 26 +++++++++++++++-
tests/test_pipeline.py | 68 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/src/inkflow/pipeline.py b/src/inkflow/pipeline.py
index 0f869c96..3ec76448 100644
--- a/src/inkflow/pipeline.py
+++ b/src/inkflow/pipeline.py
@@ -45,11 +45,17 @@
# ── Slide wire format ────────────────────────────────────────────────────────
+class EditableFile(TypedDict):
+ label: str
+ path: str
+
+
class SlideData(TypedDict):
id: str
svg: str
title: str
notes: str
+ editableFiles: list[EditableFile]
# ── Path conventions ─────────────────────────────────────────────────────────
@@ -657,6 +663,24 @@ def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]:
md_source = _source_for(assets, md.path if md is not None else None)
svg, md_notes = process_slide(slide, ctx, i + 1, parsed, md_source, slide_id)
notes = "\n".join(filter(None, [explicit_notes, md_notes]))
- results.append({"id": slide_id, "svg": svg, "title": title, "notes": notes})
+
+ svg_path = resolve_slide_src(slide.src, ctx.project_dir, ctx.theme)
+ editable_files: list[EditableFile] = [
+ {"label": "Layout", "path": str(svg_path)}
+ ]
+ if md is not None and md.path is not None:
+ editable_files.append({"label": "Content", "path": str(md.path)})
+ if loaded_notes.path is not None:
+ editable_files.append({"label": "Notes", "path": str(loaded_notes.path)})
+
+ results.append(
+ {
+ "id": slide_id,
+ "svg": svg,
+ "title": title,
+ "notes": notes,
+ "editableFiles": editable_files,
+ }
+ )
logger.info(f"processed {len(results)} slide(s)")
return results
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index a82f77d8..ed6ab92d 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -686,6 +686,74 @@ def test_process_deck_id_collision_resolved(self, tmp_path: Path) -> None:
assert results[1]["id"] == "plain-2"
+class TestEditableFiles:
+ def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None:
+ (tmp_path / "slides").mkdir()
+ slide_path = tmp_path / "slides" / "plain.svg"
+ slide_path.write_text(_PLAIN_SVG, encoding="utf-8")
+ deck = Deck(slides=[Slide("slides/plain.svg")])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "path": str(slide_path)}
+ ]
+
+ def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None:
+ layout = tmp_path / "layouts" / "layout.svg"
+ layout.parent.mkdir(parents=True, exist_ok=True)
+ layout.write_text(_LAYOUT_SVG, encoding="utf-8")
+ slides_dir = tmp_path / "slides"
+ slides_dir.mkdir()
+ md_path = slides_dir / "content.md"
+ md_path.write_text("# Hello\n", encoding="utf-8")
+ deck = Deck(slides=[Slide("layout", md="content")])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "path": str(layout)},
+ {"label": "Content", "path": str(md_path)},
+ ]
+
+ def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None:
+ layout = tmp_path / "layouts" / "layout.svg"
+ layout.parent.mkdir(parents=True, exist_ok=True)
+ layout.write_text(_LAYOUT_SVG, encoding="utf-8")
+ deck = Deck(slides=[Slide("layout", md=Inline("# Hello"))])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [{"label": "Layout", "path": str(layout)}]
+
+ def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None:
+ (tmp_path / "slides").mkdir()
+ slide_path = tmp_path / "slides" / "plain.svg"
+ slide_path.write_text(_PLAIN_SVG, encoding="utf-8")
+ notes_path = tmp_path / "slides" / "plain-notes.md"
+ notes_path.write_text("Speak slowly.", encoding="utf-8")
+ deck = Deck(slides=[Slide("slides/plain.svg", notes="slides/plain-notes.md")])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "path": str(slide_path)},
+ {"label": "Notes", "path": str(notes_path)},
+ ]
+
+ def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None:
+ layout = tmp_path / "layouts" / "layout.svg"
+ layout.parent.mkdir(parents=True, exist_ok=True)
+ layout.write_text(_LAYOUT_SVG, encoding="utf-8")
+ slides_dir = tmp_path / "slides"
+ slides_dir.mkdir()
+ md_path = slides_dir / "content.md"
+ md_path.write_text("# Hello\n", encoding="utf-8")
+ notes_path = slides_dir / "content-notes.md"
+ notes_path.write_text("Notes.", encoding="utf-8")
+ deck = Deck(
+ slides=[Slide("layout", md="content", notes="slides/content-notes.md")]
+ )
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "path": str(layout)},
+ {"label": "Content", "path": str(md_path)},
+ {"label": "Notes", "path": str(notes_path)},
+ ]
+
+
class TestParseMarkdownOnce:
def test_markdown_parsed_once_per_md_slide(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
From c533e3392d9ad575feb5bcaf858e2426b11f66e7 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:32:28 +0200
Subject: [PATCH 03/25] Set edit commands in html and handle edit requests on
the server side
---
src/inkflow/server.py | 63 ++++++++++++++++++++++++++++++++++++----
src/ts/presenter/main.ts | 1 +
tests/test_fonts.py | 2 +-
tests/test_server.py | 56 +++++++++++++++++++++++++++++++++++
4 files changed, 115 insertions(+), 7 deletions(-)
diff --git a/src/inkflow/server.py b/src/inkflow/server.py
index 7eaf9502..6159d4ea 100644
--- a/src/inkflow/server.py
+++ b/src/inkflow/server.py
@@ -27,6 +27,13 @@
from websockets.asyncio.server import serve as ws_serve
from inkflow.assets import MIME_TYPES, AssetRoots
+from inkflow.edit import (
+ NO_EDIT_COMMANDS,
+ EditCommands,
+ command_for,
+ open_in_editor,
+ resolve_edit_commands,
+)
from inkflow.enums import ColorMode
from inkflow.fonts import embed_fonts_css
from inkflow.loaders import load_deck_scripts, load_deck_styles
@@ -209,7 +216,32 @@ def _coerce_nav_position(
return {"slideIndex": slide_index, "step": step}
-def make_ws_handler(ui: LiveUI) -> Callable[[ServerConnection], Awaitable[None]]:
+def _resolve_edit_request(
+ msg: dict[str, object], slides: list[SlideData], edit_commands: EditCommands
+) -> tuple[Path, str] | None:
+ """Validate an `edit` payload and resolve its launch command, if any.
+
+ The path must belong to the *current* build's own `editableFiles` (not just be
+ a well-formed path) — this guards against a stale client message surviving a
+ deck rebuild, the same spirit as `_coerce_nav_position`'s clamping. Returns
+ None when the path is missing/unknown or no command is configured for its kind
+ (the client already handled that case as a clipboard copy; nothing to launch).
+ """
+ path_str = msg.get("path")
+ if not isinstance(path_str, str):
+ return None
+ valid_paths = {f["path"] for slide in slides for f in slide["editableFiles"]}
+ if path_str not in valid_paths:
+ return None
+ template = command_for(Path(path_str), edit_commands)
+ if template is None:
+ return None
+ return Path(path_str), template
+
+
+def make_ws_handler(
+ ui: LiveUI, edit_commands: EditCommands
+) -> Callable[[ServerConnection], Awaitable[None]]:
async def handler(websocket: ServerConnection) -> None:
_state["ws_clients"].add(websocket)
logger.debug(f"client connected ({len(_state['ws_clients'])} total)")
@@ -263,6 +295,12 @@ async def handler(websocket: ServerConnection) -> None:
if msg.get("snap"):
position_msg["snap"] = True
await broadcast(json.dumps(position_msg), sender=websocket)
+ elif msg_type == "edit":
+ request = _resolve_edit_request(
+ msg, _state["slides"], edit_commands
+ )
+ if request is not None:
+ open_in_editor(*request)
finally:
_state["ws_clients"].discard(websocket)
logger.debug(f"client disconnected ({len(_state['ws_clients'])} total)")
@@ -285,13 +323,20 @@ def favicon_data_uri() -> str:
return f"data:image/svg+xml;base64,{b64}"
-def build_html(state: State, ws_port: int | None) -> bytes:
+def build_html(
+ state: State,
+ ws_port: int | None,
+ edit_commands: EditCommands = NO_EDIT_COMMANDS,
+) -> bytes:
pkg = importlib.resources.files("inkflow")
template = pkg.joinpath("presenter.html").read_text(encoding="utf-8")
css = pkg.joinpath("bundles", "presenter.css").read_text(encoding="utf-8")
js = pkg.joinpath("bundles", "presenter.js").read_text(encoding="utf-8")
data_theme = "" if state["mode"] == ColorMode.DARK else "light"
ws_port_js = "null" if ws_port is None else str(ws_port)
+ edit_commands_json = json.dumps(
+ {"svg": edit_commands.svg is not None, "md": edit_commands.md is not None}
+ )
html = (
template.replace("/* __CSS__ */", css)
.replace("/* __JS__ */", js)
@@ -301,6 +346,7 @@ def build_html(state: State, ws_port: int | None) -> bytes:
.replace("__TRANSITIONS_JSON__", json.dumps(state["transitions"]))
.replace("/* __SCRIPTS__ */", state["scripts_js"])
.replace("__WS_PORT__", ws_port_js)
+ .replace("__EDIT_COMMANDS_JSON__", edit_commands_json)
.replace("__ERROR_JSON__", json.dumps(state["error"]))
.replace("__LOGS_JSON__", json.dumps(state["logs"]))
.replace("__FAVICON__", favicon_data_uri())
@@ -331,7 +377,11 @@ def _resolve_asset(roots: AssetRoots, request_path: str) -> Path | None:
return resolved
-def make_http_handler(ws_port: int, project_dir: Path | None = None) -> _StreamHandler:
+def make_http_handler(
+ ws_port: int,
+ project_dir: Path | None = None,
+ edit_commands: EditCommands = NO_EDIT_COMMANDS,
+) -> _StreamHandler:
async def handler(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
@@ -360,7 +410,7 @@ async def handler(
await writer.drain()
return
- body = build_html(_state, ws_port)
+ body = build_html(_state, ws_port, edit_commands)
header = (
b"HTTP/1.1 200 OK\r\n"
+ b"Content-Type: text/html; charset=utf-8\r\n"
@@ -474,7 +524,8 @@ async def serve(
uninstall_shutdown_handler = install_shutdown_handler(loop, shutdown)
try:
- http_handler = make_http_handler(ws_port, deck_path.parent)
+ edit_commands = resolve_edit_commands()
+ http_handler = make_http_handler(ws_port, deck_path.parent, edit_commands)
# Bind before the Live UI so port conflicts fail fast with a clean message
try:
http_server = await asyncio.start_server(http_handler, host, http_port)
@@ -499,7 +550,7 @@ async def serve(
try:
async with (
http_server,
- ws_serve(make_ws_handler(ui), host, ws_port),
+ ws_serve(make_ws_handler(ui, edit_commands), host, ws_port),
):
await rebuild(deck_path, ui, levels)
tasks = [
diff --git a/src/ts/presenter/main.ts b/src/ts/presenter/main.ts
index 83f0971b..f1e6e465 100644
--- a/src/ts/presenter/main.ts
+++ b/src/ts/presenter/main.ts
@@ -17,6 +17,7 @@ import "./keyboard";
const INITIAL_SLIDES = __SLIDES_JSON__;
const INITIAL_TRANSITIONS = __TRANSITIONS_JSON__;
const WS_PORT = __WS_PORT__;
+const EDIT_COMMANDS = __EDIT_COMMANDS_JSON__;
const INITIAL_ERROR = __ERROR_JSON__;
const INITIAL_LOGS = __LOGS_JSON__;
diff --git a/tests/test_fonts.py b/tests/test_fonts.py
index 7e5a9057..5814d8b4 100644
--- a/tests/test_fonts.py
+++ b/tests/test_fonts.py
@@ -32,7 +32,7 @@
def _slide(svg: str) -> SlideData:
- return {"svg": svg, "title": "", "id": "", "notes": ""}
+ return {"svg": svg, "title": "", "id": "", "notes": "", "editableFiles": []}
def _svg(body: str) -> str:
diff --git a/tests/test_server.py b/tests/test_server.py
index a026332d..ea52b1d5 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -8,11 +8,14 @@
import pytest
from inkflow.assets import AssetRoots
+from inkflow.edit import EditCommands
from inkflow.enums import ColorMode
+from inkflow.pipeline import EditableFile, SlideData
from inkflow.server import (
State,
_coerce_nav_position, # pyright: ignore[reportPrivateUsage]
_resolve_asset, # pyright: ignore[reportPrivateUsage]
+ _resolve_edit_request, # pyright: ignore[reportPrivateUsage]
build_html,
)
@@ -44,6 +47,7 @@ def _state(**overrides: object) -> State:
"__DATA_THEME__",
"__SLIDES_JSON__",
"__WS_PORT__",
+ "__EDIT_COMMANDS_JSON__",
"__ERROR_JSON__",
"__TRANSITIONS_JSON__",
"__LOGS_JSON__",
@@ -112,6 +116,18 @@ def test_build_html_transitions_json_embedded() -> None:
assert json.dumps([{"type": "fade"}]) in html
+def test_build_html_edit_commands_default_both_false() -> None:
+ html = build_html(_state(), ws_port=7778).decode()
+ assert json.dumps({"svg": False, "md": False}) in html
+
+
+def test_build_html_edit_commands_reflects_configured() -> None:
+ html = build_html(
+ _state(), ws_port=7778, edit_commands=EditCommands(svg="code {path}", md=None)
+ ).decode()
+ assert json.dumps({"svg": True, "md": False}) in html
+
+
def test_build_html_logs_json_embedded() -> None:
logs = [
{"level": "warning", "message": 'font "Inter" not found in any font directory'}
@@ -224,3 +240,43 @@ def test_coerce_nav_empty_deck_pins_index_to_zero() -> None:
def test_coerce_nav_defaults_when_fields_absent() -> None:
assert _coerce_nav_position({}, 5) == {"slideIndex": 0, "step": 0}
+
+
+# ── _resolve_edit_request ──────────────────────────────────────────────────────
+
+
+def _slide_with_files(*files: EditableFile) -> SlideData:
+ return {
+ "id": "s",
+ "svg": "",
+ "title": "",
+ "notes": "",
+ "editableFiles": list(files),
+ }
+
+
+def test_resolve_edit_request_valid_path_and_configured_command() -> None:
+ slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ commands = EditCommands(svg="code -r {path}", md=None)
+ assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) == (
+ Path("/deck/slide.svg"),
+ "code -r {path}",
+ )
+
+
+def test_resolve_edit_request_unknown_path_dropped() -> None:
+ slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ commands = EditCommands(svg="code -r {path}", md=None)
+ assert _resolve_edit_request({"path": "/etc/passwd"}, slides, commands) is None
+
+
+def test_resolve_edit_request_missing_path_field_dropped() -> None:
+ slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ commands = EditCommands(svg="code -r {path}", md=None)
+ assert _resolve_edit_request({}, slides, commands) is None
+
+
+def test_resolve_edit_request_no_command_configured_dropped() -> None:
+ slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ commands = EditCommands(svg=None, md=None)
+ assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) is None
From c340a47df45f68b00fb757f99cb8951b2c69dc3f Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:54:04 +0200
Subject: [PATCH 04/25] Add the editable files and commands to the type
definitions
---
src/ts/shared/types.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts
index 92102d65..d9c72010 100644
--- a/src/ts/shared/types.ts
+++ b/src/ts/shared/types.ts
@@ -1,11 +1,24 @@
// Every field is always emitted by the Python side (pipeline.py process_deck),
// so all are required here. Consumers that still guard with `|| ""` are being
// defensive, not handling a real absent case.
+export interface EditableFile {
+ label: string;
+ path: string;
+}
+
export interface SlideData {
id: string;
svg: string;
title: string;
notes: string;
+ editableFiles: EditableFile[];
+}
+
+// Whether the server has a configured edit command for each file kind (env vars
+// INKFLOW_EDIT_CMD_SVG / INKFLOW_EDIT_CMD_MD), baked in at page load — see edit.ts.
+export interface EditCommandsConfig {
+ svg: boolean;
+ md: boolean;
}
// Per-client position-sync mode. Never sent to the server: it only decides,
From ad56bdef71fcf283ca181f21178e143463b82ed1 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:51:05 +0200
Subject: [PATCH 05/25] Add a button to the status bar
---
src/inkflow/presenter.html | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html
index cb22a5de..8fac6e09 100644
--- a/src/inkflow/presenter.html
+++ b/src/inkflow/presenter.html
@@ -232,6 +232,14 @@
KEYBINDINGS
+
+
+
+
1.0×
From 09eaa4600eda367f63ee9aa8d7aacf36fd9d42c6 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:51:46 +0200
Subject: [PATCH 06/25] Style the edit button
---
src/css/presenter/edit.css | 55 +++++++++++++++++++++++++++++++++++++
src/css/presenter/index.css | 1 +
2 files changed, 56 insertions(+)
create mode 100644 src/css/presenter/edit.css
diff --git a/src/css/presenter/edit.css b/src/css/presenter/edit.css
new file mode 100644
index 00000000..c592a30f
--- /dev/null
+++ b/src/css/presenter/edit.css
@@ -0,0 +1,55 @@
+/* ── Edit-source control ────────────────────────────────────────────────────── */
+
+.edit-wrap {
+ position: relative;
+ display: inline-flex;
+}
+
+/* Dropdown: anchored above the button (status bar sits at the bottom). */
+#edit-menu {
+ display: none;
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 0;
+ z-index: 30;
+ min-width: 9rem;
+ padding: 0.25rem;
+ background: var(--surface);
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
+#edit-menu.open {
+ display: block;
+}
+
+.edit-row {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--subtext);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.78rem;
+ padding: 0.35rem 0.5rem;
+ text-align: left;
+ transition:
+ background 0.12s,
+ color 0.12s;
+}
+.edit-row:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+}
+[data-theme="light"] .edit-row:hover {
+ background: rgba(0, 0, 0, 0.08);
+}
+
+/* Brief confirmation flash after a clipboard copy (button or menu row). */
+#btn-edit.copied,
+.edit-row.copied {
+ color: var(--accent);
+}
diff --git a/src/css/presenter/index.css b/src/css/presenter/index.css
index a446dcc3..7a5b2f3e 100644
--- a/src/css/presenter/index.css
+++ b/src/css/presenter/index.css
@@ -4,6 +4,7 @@
@import "./overlays.css";
@import "./picker.css";
@import "./sync.css";
+@import "./edit.css";
@import "./overview.css";
@import "./pv.css";
@import "../shared/animations.css";
From caeb1d099a36220bae678e379f39ea56438147ec Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:53:00 +0200
Subject: [PATCH 07/25] Add logic behind the edit button
---
src/ts/presenter/edit.test.ts | 142 ++++++++++++++++++++++++++++++++++
src/ts/presenter/edit.ts | 121 +++++++++++++++++++++++++++++
2 files changed, 263 insertions(+)
create mode 100644 src/ts/presenter/edit.test.ts
create mode 100644 src/ts/presenter/edit.ts
diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts
new file mode 100644
index 00000000..28c184e8
--- /dev/null
+++ b/src/ts/presenter/edit.test.ts
@@ -0,0 +1,142 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+
+// edit.ts captures DOM element references at module evaluation time via
+// getElementById, and initEditMenu() is not idempotent (each call adds another
+// click listener to the same button) — production code only ever calls it once
+// per page load. So each test gets a fresh DOM fragment *and* a fresh module
+// instance via vi.resetModules() + dynamic import, mirroring transitions.test.ts,
+// rather than reusing one initEditMenu() call across tests.
+
+let initEditMenu: typeof import("./edit").initEditMenu;
+let renderEditButton: typeof import("./edit").renderEditButton;
+let state: typeof import("./state").state;
+let btnEdit: HTMLButtonElement;
+let editMenu: HTMLElement;
+let editWrap: HTMLElement;
+
+beforeEach(async () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ vi.resetModules();
+ ({ initEditMenu, renderEditButton } = await import("./edit"));
+ ({ state } = await import("./state"));
+ btnEdit = document.getElementById("btn-edit") as HTMLButtonElement;
+ editMenu = document.getElementById("edit-menu")!;
+ editWrap = btnEdit.closest(".edit-wrap") as HTMLElement;
+ state.slides = [];
+ state.slideIndex = 0;
+ state.ws = null;
+ vi.stubGlobal("navigator", {
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function slideWith(...editableFiles: { label: string; path: string }[]) {
+ return { id: "s", svg: "", title: "", notes: "", editableFiles };
+}
+
+test("hides the wrap entirely when there is no WS port (static export)", () => {
+ initEditMenu({ svg: false, md: false }, null);
+ expect(editWrap.style.display).toBe("none");
+});
+
+test("renderEditButton populates one row per editable file", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", path: "/deck/slide.svg" },
+ { label: "Content", path: "/deck/slide.md" },
+ ),
+ ];
+ renderEditButton();
+ const rows = editMenu.querySelectorAll(".edit-row");
+ expect(rows.length).toBe(2);
+ expect(rows[0].textContent).toBe("Layout");
+ expect(rows[1].textContent).toBe("Content");
+});
+
+test("a single editable file acts directly on click, no dropdown", () => {
+ state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+ expect(editMenu.classList.contains("open")).toBe(false);
+});
+
+test("multiple editable files open a dropdown instead of acting directly", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", path: "/deck/slide.svg" },
+ { label: "Content", path: "/deck/slide.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ expect(editMenu.classList.contains("open")).toBe(true);
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+});
+
+test("clicking a dropdown row acts on that file and closes the menu", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", path: "/deck/slide.svg" },
+ { label: "Content", path: "/deck/slide.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ const contentRow = Array.from(
+ editMenu.querySelectorAll(".edit-row"),
+ ).find((r) => r.textContent === "Content")!;
+ contentRow.click();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.md",
+ );
+ expect(editMenu.classList.contains("open")).toBe(false);
+});
+
+test("a configured command with a live WS connection sends an edit message instead of copying", () => {
+ state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: true, md: false }, 7778);
+ btnEdit.click();
+ expect(send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "edit", path: "/deck/slide.svg" }),
+ );
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+});
+
+test("a configured command falls back to clipboard when the WS is not open", () => {
+ state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.CLOSED } as unknown as WebSocket;
+ initEditMenu({ svg: true, md: false }, 7778);
+ btnEdit.click();
+ expect(send).not.toHaveBeenCalled();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+});
+
+test("an unconfigured file kind falls back to clipboard even with a live WS", () => {
+ state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ expect(send).not.toHaveBeenCalled();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+});
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
new file mode 100644
index 00000000..8d80e96e
--- /dev/null
+++ b/src/ts/presenter/edit.ts
@@ -0,0 +1,121 @@
+import type { EditableFile, EditCommandsConfig } from "../shared/types";
+import { state } from "./state";
+
+// Status-bar control for editing the current slide's source file(s). Owns the
+// button + dropdown DOM (structurally mirrors syncmenu.ts).
+//
+// Default action is copying the resolved path to the clipboard — universal,
+// works with any editor via paste-navigate. INKFLOW_EDIT_CMD_SVG /
+// INKFLOW_EDIT_CMD_MD (server env vars, baked into EditCommandsConfig at page
+// load) override that per file kind: when configured *and* there's a live server
+// connection, the path is sent to the server to launch instead. The live-connection
+// check (not just the boot-time config flag) mirrors websocket.ts's postToPeer
+// reasoning — check the real transport, not a static flag — so a disconnected
+// server degrades to clipboard-copy instead of silently dropping the click.
+//
+// Hidden entirely when wsPort is null (a static build/export): there is no server
+// to run a command, and copying the deck author's local dev-machine path to an
+// arbitrary viewer's clipboard is not a sensible default there.
+
+const btnEdit = document.getElementById("btn-edit")!;
+const editMenu = document.getElementById("edit-menu")!;
+const editWrap = btnEdit.closest(".edit-wrap")!;
+
+let config: EditCommandsConfig = { svg: false, md: false };
+
+function isConfigured(file: EditableFile): boolean {
+ return file.path.toLowerCase().endsWith(".svg") ? config.svg : config.md;
+}
+
+function flashCopied(el: HTMLElement): void {
+ el.classList.add("copied");
+ setTimeout(() => el.classList.remove("copied"), 1200);
+}
+
+function actOn(file: EditableFile, flashTarget: HTMLElement): void {
+ if (
+ isConfigured(file) &&
+ state.ws &&
+ state.ws.readyState === WebSocket.OPEN
+ ) {
+ state.ws.send(JSON.stringify({ type: "edit", path: file.path }));
+ return;
+ }
+ try {
+ void navigator.clipboard.writeText(file.path);
+ flashCopied(flashTarget);
+ } catch (_) {}
+}
+
+// ── Button / menu rendering ──────────────────────────────────────────────────
+
+export function renderEditButton(): void {
+ const files = state.slides[state.slideIndex]?.editableFiles ?? [];
+ editMenu.innerHTML = "";
+ for (const file of files) {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "edit-row";
+ row.textContent = file.label;
+ row.addEventListener("click", () => {
+ actOn(file, row);
+ closeMenu();
+ });
+ editMenu.appendChild(row);
+ }
+}
+
+// ── Menu open/close ────────────────────────────────────────────────────────────
+
+function onDocClick(e: MouseEvent): void {
+ const t = e.target as Node;
+ if (!btnEdit.contains(t) && !editMenu.contains(t)) closeMenu();
+}
+
+function onKeydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") {
+ closeMenu();
+ btnEdit.focus();
+ }
+}
+
+function openMenu(): void {
+ editMenu.classList.add("open");
+ btnEdit.setAttribute("aria-expanded", "true");
+ document.addEventListener("click", onDocClick);
+ document.addEventListener("keydown", onKeydown);
+}
+
+function closeMenu(): void {
+ if (!editMenu.classList.contains("open")) return;
+ editMenu.classList.remove("open");
+ btnEdit.setAttribute("aria-expanded", "false");
+ document.removeEventListener("click", onDocClick);
+ document.removeEventListener("keydown", onKeydown);
+}
+
+function toggleMenu(): void {
+ if (editMenu.classList.contains("open")) closeMenu();
+ else openMenu();
+}
+
+export function initEditMenu(
+ cfg: EditCommandsConfig,
+ wsPort: number | null,
+): void {
+ if (!wsPort) {
+ editWrap.style.display = "none";
+ return;
+ }
+ config = cfg;
+ btnEdit.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const files = state.slides[state.slideIndex]?.editableFiles ?? [];
+ if (files.length <= 1) {
+ if (files.length === 1) actOn(files[0], btnEdit);
+ return;
+ }
+ toggleMenu();
+ });
+ renderEditButton();
+}
From 44fcdb310a6ce5d62011aae05aa84a0872021113 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:53:40 +0200
Subject: [PATCH 08/25] Adjust existing tests that now need to carry editable
files or the edit menu
---
src/ts/presenter/overview.test.ts | 6 ++++++
src/ts/presenter/picker.test.ts | 14 +++++++++++++-
src/ts/presenter/transitions.test.ts | 18 +++++++++++++++++-
src/ts/presenter/windowsync.test.ts | 4 ++++
4 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/src/ts/presenter/overview.test.ts b/src/ts/presenter/overview.test.ts
index 9f2b2739..e0eecf28 100644
--- a/src/ts/presenter/overview.test.ts
+++ b/src/ts/presenter/overview.test.ts
@@ -45,6 +45,10 @@ beforeAll(async () => {
+
+
+
+
`;
// computeStageFlip() divides stage/thumbnail rect sizes; happy-dom has no
@@ -87,12 +91,14 @@ beforeEach(() => {
svg: '',
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: '',
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.slideIndex = 0;
diff --git a/src/ts/presenter/picker.test.ts b/src/ts/presenter/picker.test.ts
index 5be2b0a7..8cccda63 100644
--- a/src/ts/presenter/picker.test.ts
+++ b/src/ts/presenter/picker.test.ts
@@ -39,6 +39,10 @@ beforeAll(async () => {
+
+
+
+
`;
vi.resetModules();
({ filterPicker } = await import("./picker"));
@@ -53,7 +57,15 @@ beforeEach(() => {
describe("picker title escaping (F-018)", () => {
test("a malicious title renders inert instead of injecting a node", () => {
const evil = `
`;
- state.slides = [{ id: "a", svg: "", title: evil, notes: "" }];
+ state.slides = [
+ {
+ id: "a",
+ svg: "",
+ title: evil,
+ notes: "",
+ editableFiles: [],
+ },
+ ];
filterPicker("");
diff --git a/src/ts/presenter/transitions.test.ts b/src/ts/presenter/transitions.test.ts
index fd4bfa53..348fe714 100644
--- a/src/ts/presenter/transitions.test.ts
+++ b/src/ts/presenter/transitions.test.ts
@@ -42,7 +42,13 @@ beforeAll(async () => {
beforeEach(() => {
state.slides = [
- { id: "a", svg: "", title: "S1", notes: "" },
+ {
+ id: "a",
+ svg: "",
+ title: "S1",
+ notes: "",
+ editableFiles: [],
+ },
];
state.slideIndex = 0;
state.step = 0;
@@ -61,12 +67,14 @@ describe("reverse", () => {
svg: "",
title: "S1",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "S2",
notes: "",
+ editableFiles: [],
},
];
state.slideIndex = 1;
@@ -110,12 +118,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: ``,
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -154,12 +164,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -201,12 +213,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -244,12 +258,14 @@ describe("snap in-flight", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
});
diff --git a/src/ts/presenter/windowsync.test.ts b/src/ts/presenter/windowsync.test.ts
index 2ce194bc..c3d7d901 100644
--- a/src/ts/presenter/windowsync.test.ts
+++ b/src/ts/presenter/windowsync.test.ts
@@ -37,6 +37,10 @@ beforeAll(async () => {
+
+
+
+
`;
({ isSyncPayload } = await import("./windowsync"));
});
From eacb88c0a72d11edded71add71c16b12a6c26907 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:54:28 +0200
Subject: [PATCH 09/25] Wire up the edit button
---
src/ts/globals.d.ts | 2 ++
src/ts/presenter/main.ts | 2 ++
src/ts/presenter/pv.ts | 2 ++
3 files changed, 6 insertions(+)
diff --git a/src/ts/globals.d.ts b/src/ts/globals.d.ts
index e4cae0bf..ff5f55e3 100644
--- a/src/ts/globals.d.ts
+++ b/src/ts/globals.d.ts
@@ -1,5 +1,6 @@
import type { Render, TransitionFactory } from "./presenter/transitions";
import type {
+ EditCommandsConfig,
LogEntry,
SlideData,
SyncMode,
@@ -10,6 +11,7 @@ declare global {
const __SLIDES_JSON__: SlideData[];
const __TRANSITIONS_JSON__: TransitionData[];
const __WS_PORT__: number | null;
+ const __EDIT_COMMANDS_JSON__: EditCommandsConfig;
const __ERROR_JSON__: string | null;
const __LOGS_JSON__: LogEntry[];
diff --git a/src/ts/presenter/main.ts b/src/ts/presenter/main.ts
index f1e6e465..3ab3125c 100644
--- a/src/ts/presenter/main.ts
+++ b/src/ts/presenter/main.ts
@@ -1,3 +1,4 @@
+import { initEditMenu } from "./edit";
import { renderPv, updatePvClock } from "./pv";
import { state } from "./state";
import { readURL } from "./status";
@@ -41,6 +42,7 @@ window.addEventListener("popstate", () => {
loadSyncMode();
initSyncMenu();
initWindowSync(WS_PORT);
+initEditMenu(EDIT_COMMANDS, WS_PORT);
// Capture deep-link authority before loadSlide()/syncURL() rewrites the URL.
const deepLinked = readURL();
loadSlide();
diff --git a/src/ts/presenter/pv.ts b/src/ts/presenter/pv.ts
index 3a74a832..662c16cb 100644
--- a/src/ts/presenter/pv.ts
+++ b/src/ts/presenter/pv.ts
@@ -1,6 +1,7 @@
import { buildStepRing } from "../shared/ring";
import { applyStepInstant } from "../shared/step";
import { parseViewBox } from "../shared/viewbox";
+import { renderEditButton } from "./edit";
import { state } from "./state";
import { maxStep } from "./status";
@@ -124,6 +125,7 @@ export function renderPv(): void {
updatePvInfo();
renderPvNext();
renderPvNotes();
+ renderEditButton();
}
export function togglePv(): void {
From 5c1cfb92a19f57c5b690e2322f178eed4d6783e1 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Fri, 18 Sep 2026 15:21:09 +0200
Subject: [PATCH 10/25] Add a test deck that varies the utilized source files
---
tests/decks/edit_button.py | 38 ++++++++++++++++++++
tests/decks/slides/edit-content-and-notes.md | 7 ++++
tests/decks/slides/edit-content.md | 6 ++++
tests/decks/slides/edit-notes.md | 2 ++
4 files changed, 53 insertions(+)
create mode 100644 tests/decks/edit_button.py
create mode 100644 tests/decks/slides/edit-content-and-notes.md
create mode 100644 tests/decks/slides/edit-content.md
create mode 100644 tests/decks/slides/edit-notes.md
diff --git a/tests/decks/edit_button.py b/tests/decks/edit_button.py
new file mode 100644
index 00000000..c3fcbeef
--- /dev/null
+++ b/tests/decks/edit_button.py
@@ -0,0 +1,38 @@
+"""Feature-test deck for the presenter's Edit button.
+
+Serve it with ``uv run inkflow serve tests/decks/edit_button.py`` and click the
+Edit button (or its dropdown, on slides with more than one entry) in the status
+bar on each slide:
+
+1. **SVG only** — no markdown, no notes. Edit should act directly (no dropdown):
+ with no ``INKFLOW_EDIT_CMD_SVG`` set, it copies the slide's absolute path to
+ the clipboard; with it set (e.g. ``INKFLOW_EDIT_CMD_SVG="code -r {path}"``),
+ it launches that command instead.
+2. **Content file** — ``md=`` points at a real file. Edit should offer a
+ two-item dropdown: Layout, Content.
+3. **Content and notes files** — both ``md=`` and ``notes=`` are file-backed.
+ Edit should offer a three-item dropdown: Layout, Content, Notes.
+
+Also built (not served) by ``tests/test_decks.py`` as a compilation smoke test.
+"""
+
+from inkflow import Deck, Slide
+
+
+def main() -> Deck:
+ return Deck(
+ slides=[
+ Slide(
+ "builtin:title",
+ id="svg-only",
+ zones={"title": "SVG only: Edit acts directly, no dropdown"},
+ ),
+ Slide("builtin:content", id="content-file", md="edit-content"),
+ Slide(
+ "builtin:content",
+ id="content-and-notes",
+ md="edit-content-and-notes",
+ notes="slides/edit-notes.md",
+ ),
+ ],
+ )
diff --git a/tests/decks/slides/edit-content-and-notes.md b/tests/decks/slides/edit-content-and-notes.md
new file mode 100644
index 00000000..c16222e4
--- /dev/null
+++ b/tests/decks/slides/edit-content-and-notes.md
@@ -0,0 +1,7 @@
+# Edit: content and notes
+
+This slide has both a file-backed `md=` content file and a file-backed
+`notes=` file.
+
+Clicking Edit should offer three entries: **Layout**, **Content** (this
+file), and **Notes** (`slides/edit-notes.md`).
diff --git a/tests/decks/slides/edit-content.md b/tests/decks/slides/edit-content.md
new file mode 100644
index 00000000..d1a67036
--- /dev/null
+++ b/tests/decks/slides/edit-content.md
@@ -0,0 +1,6 @@
+# Edit: content file
+
+This slide's content comes from `slides/edit-content.md`.
+
+Clicking Edit should offer two entries: **Layout** (the SVG) and **Content**
+(this file).
diff --git a/tests/decks/slides/edit-notes.md b/tests/decks/slides/edit-notes.md
new file mode 100644
index 00000000..0a1aefec
--- /dev/null
+++ b/tests/decks/slides/edit-notes.md
@@ -0,0 +1,2 @@
+Speaker notes for the "content and notes" slide, living in their own file.
+Open the presenter panel (`p`) to see this rendered in the Notes pane.
From 3b4dac604c0b545b6918e415b266d33dd8bb73ec Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:04:09 +0200
Subject: [PATCH 11/25] Find and pass also in-project parent layers to the
frontend
---
src/inkflow/pipeline.py | 44 ++++++++++++++++++++-----
src/ts/shared/types.ts | 1 +
tests/test_pipeline.py | 71 ++++++++++++++++++++++++++++++++++-------
tests/test_server.py | 24 +++++++++++---
4 files changed, 116 insertions(+), 24 deletions(-)
diff --git a/src/inkflow/pipeline.py b/src/inkflow/pipeline.py
index 3ec76448..fd75d521 100644
--- a/src/inkflow/pipeline.py
+++ b/src/inkflow/pipeline.py
@@ -23,7 +23,7 @@
resolve_default_zone,
resolve_parent_path,
)
-from inkflow.loaders import load_md, load_notes, load_style
+from inkflow.loaders import LoadedText, load_md, load_notes, load_style
from inkflow.logging import logger
from inkflow.manifest import (
Deck,
@@ -47,6 +47,7 @@
class EditableFile(TypedDict):
label: str
+ name: str
path: str
@@ -637,6 +638,39 @@ def _source_for(roots: AssetRoots, path: Path | None) -> AssetSource:
return AssetSource.for_file(roots, path)
+def _editable_files(
+ ctx: DeckContext,
+ svg_path: Path,
+ md: LoadedText | None,
+ loaded_notes: LoadedText,
+) -> list[EditableFile]:
+ """The slide's in-project ancestor layouts (root first, so the immediate
+ parent sits next to the slide's own SVG), that SVG itself, and any
+ file-backed content/notes: what the presenter's Edit button offers.
+
+ A theme/built-in ancestor is skipped: it lives outside the project (often
+ inside an installed package), so editing it is unlikely to be wanted and may
+ not even be writable.
+ """
+ files: list[EditableFile] = []
+ chain = resolve_chain(svg_path, ctx.project_dir, ctx.theme)
+ for parent in chain:
+ if parent.is_relative_to(ctx.project_dir):
+ files.append({"label": "Parent", "name": parent.name, "path": str(parent)})
+ files.append({"label": "Layout", "name": svg_path.name, "path": str(svg_path)})
+ if md is not None and md.path is not None:
+ files.append({"label": "Content", "name": md.path.name, "path": str(md.path)})
+ if loaded_notes.path is not None:
+ files.append(
+ {
+ "label": "Notes",
+ "name": loaded_notes.path.name,
+ "path": str(loaded_notes.path),
+ }
+ )
+ return files
+
+
def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]:
visible_slides = [s for s in deck.slides if s.visible]
assets = AssetRoots(project_dir, deck.theme.asset_dir())
@@ -665,13 +699,7 @@ def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]:
notes = "\n".join(filter(None, [explicit_notes, md_notes]))
svg_path = resolve_slide_src(slide.src, ctx.project_dir, ctx.theme)
- editable_files: list[EditableFile] = [
- {"label": "Layout", "path": str(svg_path)}
- ]
- if md is not None and md.path is not None:
- editable_files.append({"label": "Content", "path": str(md.path)})
- if loaded_notes.path is not None:
- editable_files.append({"label": "Notes", "path": str(loaded_notes.path)})
+ editable_files = _editable_files(ctx, svg_path, md, loaded_notes)
results.append(
{
diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts
index d9c72010..80724292 100644
--- a/src/ts/shared/types.ts
+++ b/src/ts/shared/types.ts
@@ -3,6 +3,7 @@
// defensive, not handling a real absent case.
export interface EditableFile {
label: string;
+ name: string;
path: string;
}
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index ed6ab92d..7c5ec235 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -686,6 +686,16 @@ def test_process_deck_id_collision_resolved(self, tmp_path: Path) -> None:
assert results[1]["id"] == "plain-2"
+def _svg_with_parent(parent: str) -> str:
+ return (
+ '\n"
+ )
+
+
class TestEditableFiles:
def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None:
(tmp_path / "slides").mkdir()
@@ -694,7 +704,7 @@ def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None:
deck = Deck(slides=[Slide("slides/plain.svg")])
results = process_deck(deck, tmp_path)
assert results[0]["editableFiles"] == [
- {"label": "Layout", "path": str(slide_path)}
+ {"label": "Layout", "name": "plain.svg", "path": str(slide_path)}
]
def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None:
@@ -708,8 +718,8 @@ def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None:
deck = Deck(slides=[Slide("layout", md="content")])
results = process_deck(deck, tmp_path)
assert results[0]["editableFiles"] == [
- {"label": "Layout", "path": str(layout)},
- {"label": "Content", "path": str(md_path)},
+ {"label": "Layout", "name": "layout.svg", "path": str(layout)},
+ {"label": "Content", "name": "content.md", "path": str(md_path)},
]
def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None:
@@ -718,7 +728,9 @@ def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None:
layout.write_text(_LAYOUT_SVG, encoding="utf-8")
deck = Deck(slides=[Slide("layout", md=Inline("# Hello"))])
results = process_deck(deck, tmp_path)
- assert results[0]["editableFiles"] == [{"label": "Layout", "path": str(layout)}]
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "name": "layout.svg", "path": str(layout)}
+ ]
def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None:
(tmp_path / "slides").mkdir()
@@ -729,14 +741,16 @@ def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None:
deck = Deck(slides=[Slide("slides/plain.svg", notes="slides/plain-notes.md")])
results = process_deck(deck, tmp_path)
assert results[0]["editableFiles"] == [
- {"label": "Layout", "path": str(slide_path)},
- {"label": "Notes", "path": str(notes_path)},
+ {"label": "Layout", "name": "plain.svg", "path": str(slide_path)},
+ {"label": "Notes", "name": "plain-notes.md", "path": str(notes_path)},
]
- def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None:
+ def test_all_four_editable_files_in_order(self, tmp_path: Path) -> None:
+ parent = tmp_path / "layouts" / "parent.svg"
+ parent.parent.mkdir(parents=True, exist_ok=True)
+ parent.write_text(_LAYOUT_SVG, encoding="utf-8")
layout = tmp_path / "layouts" / "layout.svg"
- layout.parent.mkdir(parents=True, exist_ok=True)
- layout.write_text(_LAYOUT_SVG, encoding="utf-8")
+ layout.write_text(_svg_with_parent("parent"), encoding="utf-8")
slides_dir = tmp_path / "slides"
slides_dir.mkdir()
md_path = slides_dir / "content.md"
@@ -748,9 +762,42 @@ def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None:
)
results = process_deck(deck, tmp_path)
assert results[0]["editableFiles"] == [
- {"label": "Layout", "path": str(layout)},
- {"label": "Content", "path": str(md_path)},
- {"label": "Notes", "path": str(notes_path)},
+ {"label": "Parent", "name": "parent.svg", "path": str(parent)},
+ {"label": "Layout", "name": "layout.svg", "path": str(layout)},
+ {"label": "Content", "name": "content.md", "path": str(md_path)},
+ {"label": "Notes", "name": "content-notes.md", "path": str(notes_path)},
+ ]
+
+ def test_ancestor_chain_listed_root_first_next_to_layout(
+ self, tmp_path: Path
+ ) -> None:
+ # Root-first, so the immediate parent sits right next to Layout: reading
+ # top to bottom is root -> ... -> immediate parent -> layout -> content.
+ grandparent = tmp_path / "layouts" / "grandparent.svg"
+ grandparent.parent.mkdir(parents=True, exist_ok=True)
+ grandparent.write_text(_LAYOUT_SVG, encoding="utf-8")
+ parent = tmp_path / "layouts" / "parent.svg"
+ parent.write_text(_svg_with_parent("grandparent"), encoding="utf-8")
+ layout = tmp_path / "layouts" / "layout.svg"
+ layout.write_text(_svg_with_parent("parent"), encoding="utf-8")
+ deck = Deck(slides=[Slide("layout")])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Parent", "name": "grandparent.svg", "path": str(grandparent)},
+ {"label": "Parent", "name": "parent.svg", "path": str(parent)},
+ {"label": "Layout", "name": "layout.svg", "path": str(layout)},
+ ]
+
+ def test_theme_ancestor_excluded(self, tmp_path: Path) -> None:
+ # A layout built on a built-in/theme ancestor: that ancestor lives outside
+ # the project (often inside an installed package), so it's not offered.
+ layout = tmp_path / "layouts" / "layout.svg"
+ layout.parent.mkdir(parents=True, exist_ok=True)
+ layout.write_text(_svg_with_parent("builtin:base"), encoding="utf-8")
+ deck = Deck(slides=[Slide("layout")])
+ results = process_deck(deck, tmp_path)
+ assert results[0]["editableFiles"] == [
+ {"label": "Layout", "name": "layout.svg", "path": str(layout)}
]
diff --git a/tests/test_server.py b/tests/test_server.py
index ea52b1d5..8185f81d 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -256,7 +256,11 @@ def _slide_with_files(*files: EditableFile) -> SlideData:
def test_resolve_edit_request_valid_path_and_configured_command() -> None:
- slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ slides = [
+ _slide_with_files(
+ {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
+ )
+ ]
commands = EditCommands(svg="code -r {path}", md=None)
assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) == (
Path("/deck/slide.svg"),
@@ -265,18 +269,30 @@ def test_resolve_edit_request_valid_path_and_configured_command() -> None:
def test_resolve_edit_request_unknown_path_dropped() -> None:
- slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ slides = [
+ _slide_with_files(
+ {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
+ )
+ ]
commands = EditCommands(svg="code -r {path}", md=None)
assert _resolve_edit_request({"path": "/etc/passwd"}, slides, commands) is None
def test_resolve_edit_request_missing_path_field_dropped() -> None:
- slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ slides = [
+ _slide_with_files(
+ {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
+ )
+ ]
commands = EditCommands(svg="code -r {path}", md=None)
assert _resolve_edit_request({}, slides, commands) is None
def test_resolve_edit_request_no_command_configured_dropped() -> None:
- slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})]
+ slides = [
+ _slide_with_files(
+ {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
+ )
+ ]
commands = EditCommands(svg=None, md=None)
assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) is None
From e845fc633105769bc15449a2878e2e425e85abb9 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:05:29 +0200
Subject: [PATCH 12/25] Add a menu coordinator to prevent multiple drop downs
opened at once
---
src/ts/presenter/edit.ts | 2 ++
src/ts/presenter/menus.test.ts | 45 ++++++++++++++++++++++++++++++++++
src/ts/presenter/menus.ts | 16 ++++++++++++
src/ts/presenter/syncmenu.ts | 3 +++
4 files changed, 66 insertions(+)
create mode 100644 src/ts/presenter/menus.test.ts
create mode 100644 src/ts/presenter/menus.ts
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
index 8d80e96e..0042ed56 100644
--- a/src/ts/presenter/edit.ts
+++ b/src/ts/presenter/edit.ts
@@ -84,6 +84,7 @@ function openMenu(): void {
btnEdit.setAttribute("aria-expanded", "true");
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKeydown);
+ menuOpened(closeMenu);
}
function closeMenu(): void {
@@ -92,6 +93,7 @@ function closeMenu(): void {
btnEdit.setAttribute("aria-expanded", "false");
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKeydown);
+ menuClosed(closeMenu);
}
function toggleMenu(): void {
diff --git a/src/ts/presenter/menus.test.ts b/src/ts/presenter/menus.test.ts
new file mode 100644
index 00000000..66f79522
--- /dev/null
+++ b/src/ts/presenter/menus.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, expect, test, vi } from "vitest";
+import { menuClosed, menuOpened } from "./menus";
+
+// menus.ts holds one bit of module-level state (which close fn is "active"), so
+// tests must reset it between runs rather than relying on module isolation.
+beforeEach(() => {
+ // Drain any leftover active closer from a previous test without invoking it.
+ const noop = () => {};
+ menuOpened(noop);
+ menuClosed(noop);
+});
+
+test("opening a second menu closes the first", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuOpened(closeB);
+ expect(closeA).toHaveBeenCalledOnce();
+ expect(closeB).not.toHaveBeenCalled();
+});
+
+test("re-opening the same menu does not close itself", () => {
+ const close = vi.fn();
+ menuOpened(close);
+ menuOpened(close);
+ expect(close).not.toHaveBeenCalled();
+});
+
+test("menuClosed clears the active menu so the next open closes nothing", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuClosed(closeA);
+ menuOpened(closeB);
+ expect(closeA).not.toHaveBeenCalled();
+});
+
+test("menuClosed is a no-op when it isn't the active menu", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuClosed(closeB); // closeB was never the active one
+ menuOpened(closeB);
+ expect(closeA).toHaveBeenCalledOnce(); // still closed normally by B's open
+});
diff --git a/src/ts/presenter/menus.ts b/src/ts/presenter/menus.ts
new file mode 100644
index 00000000..41ec390b
--- /dev/null
+++ b/src/ts/presenter/menus.ts
@@ -0,0 +1,16 @@
+// Coordinates status-bar dropdown menus (sync, edit, …) so opening one closes
+// any other that's open. Each menu owns its own DOM/open-close logic (syncmenu.ts,
+// edit.ts) and just reports through here — this module holds no menu state of its
+// own beyond "which close function is currently active", so two menus never import
+// each other directly.
+
+let activeClose: (() => void) | null = null;
+
+export function menuOpened(close: () => void): void {
+ if (activeClose && activeClose !== close) activeClose();
+ activeClose = close;
+}
+
+export function menuClosed(close: () => void): void {
+ if (activeClose === close) activeClose = null;
+}
diff --git a/src/ts/presenter/syncmenu.ts b/src/ts/presenter/syncmenu.ts
index 7afe0eae..ac62501b 100644
--- a/src/ts/presenter/syncmenu.ts
+++ b/src/ts/presenter/syncmenu.ts
@@ -1,4 +1,5 @@
import type { SyncMode } from "../shared/types";
+import { menuClosed, menuOpened } from "./menus";
import { state } from "./state";
import { applySyncMode } from "./websocket";
@@ -59,6 +60,7 @@ function openMenu(): void {
btnSync.setAttribute("aria-expanded", "true");
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKeydown);
+ menuOpened(closeMenu);
}
function closeMenu(): void {
@@ -67,6 +69,7 @@ function closeMenu(): void {
btnSync.setAttribute("aria-expanded", "false");
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKeydown);
+ menuClosed(closeMenu);
}
function toggleMenu(): void {
From 857831d720b01f2e83599391f1320ecc4448d4ac Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:06:20 +0200
Subject: [PATCH 13/25] Use a bigger icon on the edit button
---
src/inkflow/presenter.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html
index 8fac6e09..29a52da3 100644
--- a/src/inkflow/presenter.html
+++ b/src/inkflow/presenter.html
@@ -235,7 +235,7 @@ KEYBINDINGS
From 6275e7aff712ae460ab41e677ff00ca12c556720 Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:07:40 +0200
Subject: [PATCH 14/25] Add a toast-style pop-up confirmation when pressing
edit
---
src/css/presenter/overlays.css | 35 ++++++++++++++++++++++++++++++++++
src/inkflow/presenter.html | 7 +++++++
src/ts/presenter/edit.ts | 22 ++++++++++++++++-----
3 files changed, 59 insertions(+), 5 deletions(-)
diff --git a/src/css/presenter/overlays.css b/src/css/presenter/overlays.css
index a0ed13db..ae408c9e 100644
--- a/src/css/presenter/overlays.css
+++ b/src/css/presenter/overlays.css
@@ -205,3 +205,38 @@ kbd {
#log-list li.log-error {
color: var(--red);
}
+
+/* ── Edit-action toast (non-modal, self-dismissing) ──
+ Same corner and box treatment as #log-banner (surface/border/shadow), an
+ accent colour instead of its warning yellow, and no close button — it always
+ times itself out (edit.ts) rather than needing a dismiss. */
+#edit-toast {
+ display: none;
+ position: fixed;
+ right: 1rem;
+ bottom: 1rem;
+ z-index: 40;
+ align-items: center;
+ gap: 0.4rem;
+ max-width: 20rem;
+ background: var(--surface);
+ border: 1px solid var(--green);
+ border-radius: 8px;
+ padding: 0.5rem 0.75rem;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
+ color: var(--text);
+ font-size: 0.8rem;
+ white-space: nowrap;
+ pointer-events: none;
+}
+#edit-toast.visible {
+ display: flex;
+}
+#edit-toast svg {
+ color: var(--green);
+ flex-shrink: 0;
+}
+#edit-toast-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html
index 29a52da3..71f81317 100644
--- a/src/inkflow/presenter.html
+++ b/src/inkflow/presenter.html
@@ -23,6 +23,12 @@ BUILD ERROR
+
@@ -232,6 +238,7 @@
KEYBINDINGS
+
diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts
index 15cccdd6..b14d5a5a 100644
--- a/src/ts/presenter/edit.test.ts
+++ b/src/ts/presenter/edit.test.ts
@@ -17,14 +17,21 @@ let editMenu: HTMLElement;
let editWrap: HTMLElement;
let editToast: HTMLElement;
let editToastText: HTMLElement;
+let editToastClose: HTMLButtonElement;
beforeEach(async () => {
document.body.innerHTML = `
-
+
`;
vi.resetModules();
({ initEditMenu, renderEditButton } = await import("./edit"));
@@ -35,6 +42,9 @@ beforeEach(async () => {
editWrap = btnEdit.closest(".edit-wrap") as HTMLElement;
editToast = document.getElementById("edit-toast")!;
editToastText = document.getElementById("edit-toast-text")!;
+ editToastClose = document.getElementById(
+ "edit-toast-close",
+ ) as HTMLButtonElement;
state.slides = [];
state.slideIndex = 0;
state.ws = null;
@@ -102,7 +112,10 @@ test("distinct Parent rows still read apart via their filename", () => {
expect(names).toEqual(["child.svg", "parent.svg", "grandparent.svg"]);
});
-test("a single editable file acts directly on click, no dropdown, and flashes a copy confirmation", () => {
+test("a single editable file still opens a (one-row) dropdown", () => {
+ // Never happens in production (pipeline._editable_files always appends the
+ // deck script, so the real floor is two entries) but edit.ts makes no
+ // assumption about that — it just renders whatever it's given.
state.slides = [
slideWith({
label: "Layout",
@@ -112,12 +125,74 @@ test("a single editable file acts directly on click, no dropdown, and flashes a
];
initEditMenu({ svg: false, md: false }, 7778);
btnEdit.click();
+ expect(editMenu.classList.contains("open")).toBe(true);
+ expect(editMenu.querySelectorAll(".edit-row").length).toBe(1);
+});
+
+test("clicking the lone row copies the full path and flashes a confirmation", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector
(".edit-row")!.click();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"/deck/slide.svg",
);
expect(editMenu.classList.contains("open")).toBe(false);
expect(editToast.classList.contains("visible")).toBe(true);
- expect(editToastText.textContent).toBe("Copied slide.svg");
+ // The full path, not just the filename — a bare "slide.svg" would read as
+ // though only the name had been copied.
+ expect(editToastText.textContent).toBe("Copied /deck/slide.svg");
+});
+
+test("clicking the toast's close button dismisses it early", () => {
+ vi.useFakeTimers();
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(editToast.classList.contains("visible")).toBe(true);
+ editToastClose.click();
+ expect(editToast.classList.contains("visible")).toBe(false);
+ // The pending auto-hide timeout must also be cleared, not just overridden by
+ // the manual dismiss, otherwise it could re-fire (harmlessly, but sloppily)
+ // or clobber a *later* toast's own timer.
+ vi.advanceTimersByTime(3000);
+ expect(editToast.classList.contains("visible")).toBe(false);
+ vi.useRealTimers();
+});
+
+test("a second copy in quick succession restarts the auto-hide timer", () => {
+ vi.useFakeTimers();
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "a.svg", path: "/deck/a.svg" },
+ { label: "Content", name: "b.md", path: "/deck/b.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, md: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelectorAll(".edit-row")[0].click();
+ vi.advanceTimersByTime(2000); // most of the way to the first toast's timeout
+ btnEdit.click();
+ editMenu.querySelectorAll(".edit-row")[1].click();
+ vi.advanceTimersByTime(2000); // would have closed the first toast by now
+ expect(editToast.classList.contains("visible")).toBe(true);
+ expect(editToastText.textContent).toBe("Copied /deck/b.md");
+ vi.advanceTimersByTime(1000); // completes the second toast's own 3000ms
+ expect(editToast.classList.contains("visible")).toBe(false);
+ vi.useRealTimers();
});
test("the copy confirmation times itself out", () => {
@@ -131,8 +206,9 @@ test("the copy confirmation times itself out", () => {
];
initEditMenu({ svg: false, md: false }, 7778);
btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
expect(editToast.classList.contains("visible")).toBe(true);
- vi.advanceTimersByTime(1600);
+ vi.advanceTimersByTime(3000);
expect(editToast.classList.contains("visible")).toBe(false);
vi.useRealTimers();
});
@@ -198,6 +274,7 @@ test("a configured command with a live WS connection sends an edit message inste
state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
initEditMenu({ svg: true, md: false }, 7778);
btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
expect(send).toHaveBeenCalledWith(
JSON.stringify({ type: "edit", path: "/deck/slide.svg" }),
);
@@ -218,6 +295,7 @@ test("a configured command falls back to clipboard when the WS is not open", ()
state.ws = { send, readyState: WebSocket.CLOSED } as unknown as WebSocket;
initEditMenu({ svg: true, md: false }, 7778);
btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
expect(send).not.toHaveBeenCalled();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"/deck/slide.svg",
@@ -236,6 +314,7 @@ test("an unconfigured file kind falls back to clipboard even with a live WS", ()
state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
initEditMenu({ svg: false, md: false }, 7778);
btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
expect(send).not.toHaveBeenCalled();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"/deck/slide.svg",
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
index 6a5f6679..7f9b1080 100644
--- a/src/ts/presenter/edit.ts
+++ b/src/ts/presenter/edit.ts
@@ -23,10 +23,16 @@ const editMenu = document.getElementById("edit-menu")!;
const editWrap = btnEdit.closest(".edit-wrap")!;
const editToast = document.getElementById("edit-toast")!;
const editToastText = document.getElementById("edit-toast-text")!;
+const editToastClose = document.getElementById("edit-toast-close")!;
+
+// Must match the CSS animation duration on #edit-toast-progress (overlays.css).
+const TOAST_DURATION_MS = 3000;
let config: EditCommandsConfig = { svg: false, md: false };
let toastTimeout: ReturnType | null = null;
+editToastClose.addEventListener("click", () => hideToast());
+
// One small icon per editableFiles label — a plain signifier, not decoration, so
// entries with the same generic label (several "Parent" rows) still read apart at
// a glance alongside their filename. Trusted, fixed markup (never file/user data).
@@ -42,17 +48,26 @@ function isConfigured(file: EditableFile): boolean {
return file.path.toLowerCase().endsWith(".svg") ? config.svg : config.md;
}
+function hideToast(): void {
+ if (toastTimeout) clearTimeout(toastTimeout);
+ editToast.classList.remove("visible");
+ toastTimeout = null;
+}
+
// Flashes a confirmation styled like the #log-banner message boxes (same
// surface/border/shadow treatment, an accent colour instead of its warning
-// yellow), but with no dismiss button — it always times itself out.
+// yellow), with its own close button and a shrinking progress bar (pure CSS,
+// see overlays.css) showing time left before it dismisses itself.
function flashToast(message: string): void {
- if (toastTimeout) clearTimeout(toastTimeout);
editToastText.textContent = message;
+ // Drop .visible and force a reflow before re-adding it, even if the toast is
+ // already showing (two edits in quick succession) — otherwise the browser
+ // never sees the class go away and won't restart the progress-bar animation.
+ editToast.classList.remove("visible");
+ void editToast.offsetWidth;
editToast.classList.add("visible");
- toastTimeout = setTimeout(() => {
- editToast.classList.remove("visible");
- toastTimeout = null;
- }, 1600);
+ if (toastTimeout) clearTimeout(toastTimeout);
+ toastTimeout = setTimeout(hideToast, TOAST_DURATION_MS);
}
function actOn(file: EditableFile): void {
@@ -67,7 +82,9 @@ function actOn(file: EditableFile): void {
}
try {
void navigator.clipboard.writeText(file.path);
- flashToast(`Copied ${file.name}`);
+ // The full path, not just file.name: a bare filename here would read as
+ // though only the name (not the whole path) had been copied.
+ flashToast(`Copied ${file.path}`);
} catch (_) {}
}
diff --git a/src/ts/presenter/overview.test.ts b/src/ts/presenter/overview.test.ts
index e0eecf28..8463db36 100644
--- a/src/ts/presenter/overview.test.ts
+++ b/src/ts/presenter/overview.test.ts
@@ -49,6 +49,7 @@ beforeAll(async () => {
+
`;
// computeStageFlip() divides stage/thumbnail rect sizes; happy-dom has no
diff --git a/src/ts/presenter/picker.test.ts b/src/ts/presenter/picker.test.ts
index 8cccda63..1c6e6833 100644
--- a/src/ts/presenter/picker.test.ts
+++ b/src/ts/presenter/picker.test.ts
@@ -43,6 +43,7 @@ beforeAll(async () => {
+
`;
vi.resetModules();
({ filterPicker } = await import("./picker"));
diff --git a/src/ts/presenter/windowsync.test.ts b/src/ts/presenter/windowsync.test.ts
index c3d7d901..e1ae46d2 100644
--- a/src/ts/presenter/windowsync.test.ts
+++ b/src/ts/presenter/windowsync.test.ts
@@ -41,6 +41,7 @@ beforeAll(async () => {
+
`;
({ isSyncPayload } = await import("./windowsync"));
});
From 4468de6ddf26cff6a1a30f770890af7b86a7d96a Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:45:05 +0200
Subject: [PATCH 22/25] Rename INKFLOW_EDIT_CMD_MD to INKFLOW_EDIT_CMD and use
it on all file types except SVG if that's overridden
---
docs/guides/presenter-view.md | 16 ++++-----
docs/reference/cli.md | 65 +++++++++++++++++++++++++++++++----
src/inkflow/edit.py | 14 +++++---
src/inkflow/server.py | 5 ++-
src/ts/presenter/edit.test.ts | 43 ++++++++++++++++-------
src/ts/presenter/edit.ts | 20 +++++++----
src/ts/shared/types.ts | 2 +-
tests/decks/edit_button.py | 32 +++++++++--------
tests/test_edit.py | 29 ++++++++++------
tests/test_server.py | 16 +++++----
10 files changed, 169 insertions(+), 73 deletions(-)
diff --git a/docs/guides/presenter-view.md b/docs/guides/presenter-view.md
index 70b2f012..10a2dd60 100644
--- a/docs/guides/presenter-view.md
+++ b/docs/guides/presenter-view.md
@@ -71,19 +71,19 @@ See [Authoring slides](slides.md#speaker-notes) and
## Editing a slide
Click the pencil-icon button in the status bar to edit the current slide's source.
-A slide with only one editable file acts immediately.
-One with more (a file-backed `md=` and/or `notes=`,
-or a layout built on a project-local `inkflow:parent`)
-opens a small dropdown listing, top to bottom:
-any parent layouts, the layout itself, then Content and Notes.
+It opens a small dropdown listing, top to bottom: any parent layouts
+(a layout built on a project-local `inkflow:parent`), the layout itself,
+a file-backed `md=` and/or `notes=` as Content and Notes,
+and finally the deck script.
Each entry shows its kind and filename.
A parent layout that lives in the active theme or an installed package
is never listed, since it isn't yours to edit.
By default this copies the file's absolute path to the clipboard.
-With `INKFLOW_EDIT_CMD_SVG` and/or `INKFLOW_EDIT_CMD_MD` set,
-it launches that command instead.
-See [CLI reference](../reference/cli.md#editing-from-the-presenter) for details.
+With `INKFLOW_EDIT_CMD` set, it launches that command instead
+(`INKFLOW_EDIT_CMD_SVG` overrides it just for SVG files).
+See [CLI reference](../reference/cli.md#editing-from-the-presenter) for details,
+including suggested commands for VS Code, Neovim, and Inkscape.
This button only appears under `inkflow serve`, not in a static build.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index b1728062..bdead89b 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -61,16 +61,67 @@ separate from these sinks; enabling the file sink also captures its traceback.
The presenter's Edit button (see [Presenter panel](../guides/presenter-view.md))
copies the current slide's source path to the clipboard by default.
-Set one or both of these environment variables to launch an editor instead,
+Set `INKFLOW_EDIT_CMD` to launch an editor instead, for every file kind,
with `{path}` substituted (appended as a final argument if the template has no
-`{path}` placeholder):
+`{path}` placeholder).
+`INKFLOW_EDIT_CMD_SVG` overrides it specifically for SVG files,
+if you want a different command there
+(Inkscape instead of a text editor, say) without losing the general one
+for content, notes, and the deck script itself.
-| File kind | Environment variable |
-| --- | --- |
-| SVG layout | `INKFLOW_EDIT_CMD_SVG` |
-| Markdown (content or notes) | `INKFLOW_EDIT_CMD_MD` |
+```bash
+INKFLOW_EDIT_CMD="code -r --goto {path}" inkflow serve deck.py
+```
+
+### Suggested editor commands
+
+These are starting points to copy and adjust, not built-in behavior.
+
+**VS Code** reuses an already-open window natively:
```bash
-INKFLOW_EDIT_CMD_SVG="code -r --goto {path}" inkflow serve deck.py
+export INKFLOW_EDIT_CMD="code -r --goto {path}"
```
+**Neovim** needs a fixed socket to connect to,
+since its own address is otherwise random per instance.
+Launch nvim with that socket, for example via a shell alias:
+
+```bash
+alias vim='nvim --listen /tmp/nvim.sock'
+```
+
+```bash
+export INKFLOW_EDIT_CMD="nvim --server /tmp/nvim.sock --remote {path}"
+```
+
+If no nvim with that listen address is running,
+the command fails silently (logged as a warning server-side).
+
+**Inkscape** has no comparable built-in remote-control flag,
+so this uses its D-Bus interface directly.
+Set it as `INKFLOW_EDIT_CMD_SVG`, not the general `INKFLOW_EDIT_CMD`.
+Save this as an executable script on your `PATH`:
+
+```bash
+#!/bin/sh
+# Reuses a running Inkscape's window (a new tab on 1.5+, a new window on
+# older versions) instead of spawning a second process; falls back to a
+# plain `inkscape` launch if no instance is running yet.
+if gdbus call --session --dest org.inkscape.Inkscape \
+ --object-path /org/inkscape/Inkscape \
+ --method org.freedesktop.Application.ActivateAction \
+ "file-open-window" "[<'$1'>]" "{}" >/dev/null 2>&1; then
+ exit 0
+fi
+exec inkscape "$1"
+```
+
+```bash
+export INKFLOW_EDIT_CMD_SVG="inkflow-edit-svg {path}"
+```
+
+This is Linux-specific (D-Bus) and depends on Inkscape's own D-Bus interface,
+which isn't part of inkflow and could change between Inkscape releases.
+It degrades safely on any version, though.
+
diff --git a/src/inkflow/edit.py b/src/inkflow/edit.py
index fd85ec04..71630f10 100644
--- a/src/inkflow/edit.py
+++ b/src/inkflow/edit.py
@@ -1,6 +1,6 @@
"""Launching an external editor on a slide's source file, from the presenter.
-Resolution is env-var only (``INKFLOW_EDIT_CMD_SVG`` / ``INKFLOW_EDIT_CMD_MD``): no
+Resolution is env-var only (``INKFLOW_EDIT_CMD`` / ``INKFLOW_EDIT_CMD_SVG``): no
command is bundled by default because "jump an already-open editor to this file"
is inherently editor- and machine-specific (see the two env vars' docs). When
unset, the presenter falls back to copying the path to the clipboard instead.
@@ -19,11 +19,11 @@
@dataclass(frozen=True)
class EditCommands:
+ default: str | None
svg: str | None
- md: str | None
-NO_EDIT_COMMANDS = EditCommands(svg=None, md=None)
+NO_EDIT_COMMANDS = EditCommands(default=None, svg=None)
"""Shared "nothing configured" value, so callers with no real commands to pass
(export.py's build_html call, default handler args) don't each construct their own
equal-but-distinct instance — and so it can be used as a default argument without
@@ -32,13 +32,17 @@ class EditCommands:
def resolve_edit_commands() -> EditCommands:
return EditCommands(
+ default=os.environ.get("INKFLOW_EDIT_CMD"),
svg=os.environ.get("INKFLOW_EDIT_CMD_SVG"),
- md=os.environ.get("INKFLOW_EDIT_CMD_MD"),
)
def command_for(path: Path, commands: EditCommands) -> str | None:
- return commands.svg if path.suffix.lower() == ".svg" else commands.md
+ """``INKFLOW_EDIT_CMD_SVG`` overrides ``INKFLOW_EDIT_CMD`` for SVG files;
+ every other file kind always uses the general command."""
+ if path.suffix.lower() == ".svg" and commands.svg is not None:
+ return commands.svg
+ return commands.default
def open_in_editor(path: Path, template: str) -> None:
diff --git a/src/inkflow/server.py b/src/inkflow/server.py
index 489777be..9ea3530e 100644
--- a/src/inkflow/server.py
+++ b/src/inkflow/server.py
@@ -335,7 +335,10 @@ def build_html(
data_theme = "" if state["mode"] == ColorMode.DARK else "light"
ws_port_js = "null" if ws_port is None else str(ws_port)
edit_commands_json = json.dumps(
- {"svg": edit_commands.svg is not None, "md": edit_commands.md is not None}
+ {
+ "default": edit_commands.default is not None,
+ "svg": edit_commands.svg is not None,
+ }
)
html = (
template.replace("/* __CSS__ */", css)
diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts
index b14d5a5a..be252894 100644
--- a/src/ts/presenter/edit.test.ts
+++ b/src/ts/presenter/edit.test.ts
@@ -64,7 +64,7 @@ function slideWith(
}
test("hides the wrap entirely when there is no WS port (static export)", () => {
- initEditMenu({ svg: false, md: false }, null);
+ initEditMenu({ svg: false, default: false }, null);
expect(editWrap.style.display).toBe("none");
});
@@ -123,7 +123,7 @@ test("a single editable file still opens a (one-row) dropdown", () => {
path: "/deck/slide.svg",
}),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
expect(editMenu.classList.contains("open")).toBe(true);
expect(editMenu.querySelectorAll(".edit-row").length).toBe(1);
@@ -137,7 +137,7 @@ test("clicking the lone row copies the full path and flashes a confirmation", ()
path: "/deck/slide.svg",
}),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
@@ -159,7 +159,7 @@ test("clicking the toast's close button dismisses it early", () => {
path: "/deck/slide.svg",
}),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(editToast.classList.contains("visible")).toBe(true);
@@ -181,7 +181,7 @@ test("a second copy in quick succession restarts the auto-hide timer", () => {
{ label: "Content", name: "b.md", path: "/deck/b.md" },
),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
editMenu.querySelectorAll(".edit-row")[0].click();
vi.advanceTimersByTime(2000); // most of the way to the first toast's timeout
@@ -204,7 +204,7 @@ test("the copy confirmation times itself out", () => {
path: "/deck/slide.svg",
}),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(editToast.classList.contains("visible")).toBe(true);
@@ -220,7 +220,7 @@ test("multiple editable files open a dropdown instead of acting directly", () =>
{ label: "Content", name: "slide.md", path: "/deck/slide.md" },
),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
expect(editMenu.classList.contains("open")).toBe(true);
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
@@ -235,7 +235,7 @@ test("opening the edit dropdown closes another open menu (e.g. sync)", () => {
];
const closeOther = vi.fn();
menuOpened(closeOther);
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
expect(closeOther).toHaveBeenCalledOnce();
expect(editMenu.classList.contains("open")).toBe(true);
@@ -248,7 +248,7 @@ test("clicking a dropdown row acts on that file and closes the menu", () => {
{ label: "Content", name: "slide.md", path: "/deck/slide.md" },
),
];
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
const contentRow = Array.from(
editMenu.querySelectorAll(".edit-row"),
@@ -272,7 +272,7 @@ test("a configured command with a live WS connection sends an edit message inste
];
const send = vi.fn();
state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
- initEditMenu({ svg: true, md: false }, 7778);
+ initEditMenu({ svg: true, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(send).toHaveBeenCalledWith(
@@ -293,7 +293,7 @@ test("a configured command falls back to clipboard when the WS is not open", ()
];
const send = vi.fn();
state.ws = { send, readyState: WebSocket.CLOSED } as unknown as WebSocket;
- initEditMenu({ svg: true, md: false }, 7778);
+ initEditMenu({ svg: true, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(send).not.toHaveBeenCalled();
@@ -312,7 +312,7 @@ test("an unconfigured file kind falls back to clipboard even with a live WS", ()
];
const send = vi.fn();
state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
- initEditMenu({ svg: false, md: false }, 7778);
+ initEditMenu({ svg: false, default: false }, 7778);
btnEdit.click();
editMenu.querySelector(".edit-row")!.click();
expect(send).not.toHaveBeenCalled();
@@ -320,3 +320,22 @@ test("an unconfigured file kind falls back to clipboard even with a live WS", ()
"/deck/slide.svg",
);
});
+
+test("the general command also applies to SVG files when no SVG-specific override is set", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: false, default: true }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "edit", path: "/deck/slide.svg" }),
+ );
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+});
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
index 7f9b1080..20034b2d 100644
--- a/src/ts/presenter/edit.ts
+++ b/src/ts/presenter/edit.ts
@@ -6,11 +6,12 @@ import { state } from "./state";
// button + dropdown DOM (structurally mirrors syncmenu.ts).
//
// Default action is copying the resolved path to the clipboard — universal,
-// works with any editor via paste-navigate. INKFLOW_EDIT_CMD_SVG /
-// INKFLOW_EDIT_CMD_MD (server env vars, baked into EditCommandsConfig at page
-// load) override that per file kind: when configured *and* there's a live server
-// connection, the path is sent to the server to launch instead. The live-connection
-// check (not just the boot-time config flag) mirrors websocket.ts's postToPeer
+// works with any editor via paste-navigate. INKFLOW_EDIT_CMD / INKFLOW_EDIT_CMD_SVG
+// (server env vars, baked into EditCommandsConfig at page load) override that:
+// INKFLOW_EDIT_CMD_SVG overrides INKFLOW_EDIT_CMD for SVG files, which otherwise
+// covers every file kind. When configured *and* there's a live server connection,
+// the path is sent to the server to launch instead. The live-connection check
+// (not just the boot-time config flag) mirrors websocket.ts's postToPeer
// reasoning — check the real transport, not a static flag — so a disconnected
// server degrades to clipboard-copy instead of silently dropping the click.
//
@@ -28,7 +29,7 @@ const editToastClose = document.getElementById("edit-toast-close")!;
// Must match the CSS animation duration on #edit-toast-progress (overlays.css).
const TOAST_DURATION_MS = 3000;
-let config: EditCommandsConfig = { svg: false, md: false };
+let config: EditCommandsConfig = { default: false, svg: false };
let toastTimeout: ReturnType | null = null;
editToastClose.addEventListener("click", () => hideToast());
@@ -45,7 +46,12 @@ const ROW_ICONS: Record = {
};
function isConfigured(file: EditableFile): boolean {
- return file.path.toLowerCase().endsWith(".svg") ? config.svg : config.md;
+ // config.svg overrides config.default for SVG files; every other kind
+ // always uses the general command — mirrors edit.py's command_for.
+ if (file.path.toLowerCase().endsWith(".svg")) {
+ return config.svg || config.default;
+ }
+ return config.default;
}
function hideToast(): void {
diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts
index 80724292..34a50560 100644
--- a/src/ts/shared/types.ts
+++ b/src/ts/shared/types.ts
@@ -18,8 +18,8 @@ export interface SlideData {
// Whether the server has a configured edit command for each file kind (env vars
// INKFLOW_EDIT_CMD_SVG / INKFLOW_EDIT_CMD_MD), baked in at page load — see edit.ts.
export interface EditCommandsConfig {
+ default: boolean;
svg: boolean;
- md: boolean;
}
// Per-client position-sync mode. Never sent to the server: it only decides,
diff --git a/tests/decks/edit_button.py b/tests/decks/edit_button.py
index 0d739356..4456a477 100644
--- a/tests/decks/edit_button.py
+++ b/tests/decks/edit_button.py
@@ -1,27 +1,31 @@
"""Feature-test deck for the presenter's Edit button.
Serve it with ``uv run inkflow serve tests/decks/edit_button.py`` and click the
-Edit button (or its dropdown, on slides with more than one entry) in the status
-bar on each slide:
+Edit button (opens a dropdown on every slide, in the status bar) on each slide.
+Every dropdown ends with a **Deck** entry (this file) — ``pipeline._editable_files``
+always appends the deck script, so no slide ever has just one editable file:
-1. **SVG only** — no markdown, no notes. Edit should act directly (no dropdown):
- with no ``INKFLOW_EDIT_CMD_SVG`` set, it copies the slide's absolute path to
- the clipboard; with it set (e.g. ``INKFLOW_EDIT_CMD_SVG="code -r {path}"``),
- it launches that command instead.
-2. **Content file** — ``md=`` points at a real file. Edit should offer a
- two-item dropdown: Layout, Content.
+1. **SVG only** — no markdown, no notes. Two-item dropdown: Layout, Deck.
+2. **Content file** — ``md=`` points at a real file. Three-item dropdown:
+ Layout, Content, Deck.
3. **Content and notes files** — both ``md=`` and ``notes=`` are file-backed.
- Edit should offer a three-item dropdown: Layout, Content, Notes.
+ Four-item dropdown: Layout, Content, Notes, Deck.
4. **Layout with a parent chain** — ``layouts/edit-parent-child.svg`` has
``inkflow:parent="edit-parent-base"``, which itself has
``inkflow:parent="edit-parent-grandparent"``: two project-local ancestors.
- Edit should offer a three-item dropdown, root ancestor first so the
- immediate parent sits next to the layout it belongs to: Parent
- (edit-parent-grandparent.svg), Parent (edit-parent-base.svg), Layout
- (edit-parent-child.svg) — the two Parent rows told apart by filename since
- the label repeats. A built-in/theme ancestor is never offered this way (see
+ Four-item dropdown, root ancestor first so the immediate parent sits next
+ to the layout it belongs to: Parent (edit-parent-grandparent.svg), Parent
+ (edit-parent-base.svg), Layout (edit-parent-child.svg), Deck — the two
+ Parent rows told apart by filename since the label repeats. A built-in/theme
+ ancestor is never offered this way (see
``TestEditableFiles.test_theme_ancestor_excluded`` in test_pipeline.py).
+With no ``INKFLOW_EDIT_CMD``/``INKFLOW_EDIT_CMD_SVG`` set, every row copies its
+absolute path to the clipboard; with ``INKFLOW_EDIT_CMD`` set (e.g.
+``INKFLOW_EDIT_CMD="code -r {path}"``), every row launches that command
+instead, unless ``INKFLOW_EDIT_CMD_SVG`` overrides it for the Layout/Parent
+rows specifically. Either way a confirmation toast appears bottom-right.
+
Also built (not served) by ``tests/test_decks.py`` as a compilation smoke test.
"""
diff --git a/tests/test_edit.py b/tests/test_edit.py
index 61bf02d2..62aaa780 100644
--- a/tests/test_edit.py
+++ b/tests/test_edit.py
@@ -15,7 +15,7 @@
)
from inkflow.logging import collect_logs
-_ENV_VARS = ("INKFLOW_EDIT_CMD_SVG", "INKFLOW_EDIT_CMD_MD")
+_ENV_VARS = ("INKFLOW_EDIT_CMD", "INKFLOW_EDIT_CMD_SVG")
@pytest.fixture(autouse=True)
@@ -29,37 +29,44 @@ def _isolated_env(monkeypatch: pytest.MonkeyPatch): # pyright: ignore[reportUnu
def test_resolve_edit_commands_unset() -> None:
- assert resolve_edit_commands() == EditCommands(svg=None, md=None)
+ assert resolve_edit_commands() == EditCommands(default=None, svg=None)
def test_resolve_edit_commands_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("INKFLOW_EDIT_CMD", "nvim {path}")
monkeypatch.setenv("INKFLOW_EDIT_CMD_SVG", "code -r {path}")
- monkeypatch.setenv("INKFLOW_EDIT_CMD_MD", "nvim {path}")
assert resolve_edit_commands() == EditCommands(
- svg="code -r {path}", md="nvim {path}"
+ default="nvim {path}", svg="code -r {path}"
)
# ── command_for ───────────────────────────────────────────────────────────────
-def test_command_for_svg_suffix() -> None:
- commands = EditCommands(svg="edit-svg", md="edit-md")
+def test_command_for_svg_suffix_uses_svg_override() -> None:
+ commands = EditCommands(default="edit-default", svg="edit-svg")
assert command_for(Path("slide.svg"), commands) == "edit-svg"
def test_command_for_svg_suffix_case_insensitive() -> None:
- commands = EditCommands(svg="edit-svg", md="edit-md")
+ commands = EditCommands(default="edit-default", svg="edit-svg")
assert command_for(Path("slide.SVG"), commands) == "edit-svg"
-def test_command_for_other_suffix_uses_md() -> None:
- commands = EditCommands(svg="edit-svg", md="edit-md")
- assert command_for(Path("notes.md"), commands) == "edit-md"
+def test_command_for_other_suffix_uses_default() -> None:
+ commands = EditCommands(default="edit-default", svg="edit-svg")
+ assert command_for(Path("notes.md"), commands) == "edit-default"
+
+
+def test_command_for_svg_falls_back_to_default_without_override() -> None:
+ # The general command also applies to SVG files when no SVG-specific
+ # override is set — setting only INKFLOW_EDIT_CMD covers everything.
+ commands = EditCommands(default="edit-default", svg=None)
+ assert command_for(Path("slide.svg"), commands) == "edit-default"
def test_command_for_none_configured() -> None:
- commands = EditCommands(svg=None, md=None)
+ commands = EditCommands(default=None, svg=None)
assert command_for(Path("slide.svg"), commands) is None
diff --git a/tests/test_server.py b/tests/test_server.py
index 8185f81d..e088928b 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -118,14 +118,16 @@ def test_build_html_transitions_json_embedded() -> None:
def test_build_html_edit_commands_default_both_false() -> None:
html = build_html(_state(), ws_port=7778).decode()
- assert json.dumps({"svg": False, "md": False}) in html
+ assert json.dumps({"default": False, "svg": False}) in html
def test_build_html_edit_commands_reflects_configured() -> None:
html = build_html(
- _state(), ws_port=7778, edit_commands=EditCommands(svg="code {path}", md=None)
+ _state(),
+ ws_port=7778,
+ edit_commands=EditCommands(svg="code {path}", default=None),
).decode()
- assert json.dumps({"svg": True, "md": False}) in html
+ assert json.dumps({"default": False, "svg": True}) in html
def test_build_html_logs_json_embedded() -> None:
@@ -261,7 +263,7 @@ def test_resolve_edit_request_valid_path_and_configured_command() -> None:
{"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
)
]
- commands = EditCommands(svg="code -r {path}", md=None)
+ commands = EditCommands(svg="code -r {path}", default=None)
assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) == (
Path("/deck/slide.svg"),
"code -r {path}",
@@ -274,7 +276,7 @@ def test_resolve_edit_request_unknown_path_dropped() -> None:
{"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
)
]
- commands = EditCommands(svg="code -r {path}", md=None)
+ commands = EditCommands(svg="code -r {path}", default=None)
assert _resolve_edit_request({"path": "/etc/passwd"}, slides, commands) is None
@@ -284,7 +286,7 @@ def test_resolve_edit_request_missing_path_field_dropped() -> None:
{"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
)
]
- commands = EditCommands(svg="code -r {path}", md=None)
+ commands = EditCommands(svg="code -r {path}", default=None)
assert _resolve_edit_request({}, slides, commands) is None
@@ -294,5 +296,5 @@ def test_resolve_edit_request_no_command_configured_dropped() -> None:
{"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"}
)
]
- commands = EditCommands(svg=None, md=None)
+ commands = EditCommands(svg=None, default=None)
assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) is None
From c75b7a11106cbaebae8cf3e8579ee25ef9ede04e Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:45:18 +0200
Subject: [PATCH 23/25] Rebuild bundles
---
src/inkflow/bundles/presenter.css | 47 ++++++++++++++++++++++++++-----
src/inkflow/bundles/presenter.js | 34 +++++++++++++---------
2 files changed, 60 insertions(+), 21 deletions(-)
diff --git a/src/inkflow/bundles/presenter.css b/src/inkflow/bundles/presenter.css
index d30e9127..46ad6fa2 100644
--- a/src/inkflow/bundles/presenter.css
+++ b/src/inkflow/bundles/presenter.css
@@ -441,29 +441,62 @@ kbd {
right: 1rem;
bottom: 1rem;
z-index: 40;
- align-items: center;
- gap: 0.4rem;
- max-width: 20rem;
+ flex-direction: column;
+ max-width: 24rem;
background: var(--surface);
border: 1px solid var(--green);
border-radius: 8px;
- padding: 0.5rem 0.75rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
color: var(--text);
font-size: 0.8rem;
- white-space: nowrap;
+ line-height: 1.4;
+ overflow: hidden;
pointer-events: none;
}
#edit-toast.visible {
display: flex;
}
+#edit-toast-body {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.4rem;
+ padding: 0.5rem 0.5rem 0.5rem 0.75rem;
+}
#edit-toast svg {
color: var(--green);
flex-shrink: 0;
+ margin-top: 0.15rem;
}
#edit-toast-text {
- overflow: hidden;
- text-overflow: ellipsis;
+ word-break: break-word;
+}
+#edit-toast-close {
+ background: none;
+ border: none;
+ color: var(--subtext);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0 0.25rem;
+ flex-shrink: 0;
+ pointer-events: auto;
+}
+#edit-toast-close:hover {
+ color: var(--text);
+}
+#edit-toast-progress {
+ height: 2px;
+ background: var(--green);
+ transform-origin: left;
+ animation: edit-toast-grow 3s linear forwards;
+}
+@keyframes edit-toast-grow {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
}
/* src/css/presenter/picker.css */
diff --git a/src/inkflow/bundles/presenter.js b/src/inkflow/bundles/presenter.js
index 5d415f72..45de5ca5 100644
--- a/src/inkflow/bundles/presenter.js
+++ b/src/inkflow/bundles/presenter.js
@@ -33,25 +33,36 @@
var editWrap = btnEdit.closest(".edit-wrap");
var editToast = document.getElementById("edit-toast");
var editToastText = document.getElementById("edit-toast-text");
- var config = { svg: false, md: false };
+ var editToastClose = document.getElementById("edit-toast-close");
+ var TOAST_DURATION_MS = 3e3;
+ var config = { default: false, svg: false };
var toastTimeout = null;
+ editToastClose.addEventListener("click", () => hideToast());
var ROW_ICONS = {
Layout: ``,
Parent: ``,
Content: ``,
- Notes: ``
+ Notes: ``,
+ Deck: ``
};
function isConfigured(file) {
- return file.path.toLowerCase().endsWith(".svg") ? config.svg : config.md;
+ if (file.path.toLowerCase().endsWith(".svg")) {
+ return config.svg || config.default;
+ }
+ return config.default;
}
- function flashToast(message) {
+ function hideToast() {
if (toastTimeout) clearTimeout(toastTimeout);
+ editToast.classList.remove("visible");
+ toastTimeout = null;
+ }
+ function flashToast(message) {
editToastText.textContent = message;
+ editToast.classList.remove("visible");
+ void editToast.offsetWidth;
editToast.classList.add("visible");
- toastTimeout = setTimeout(() => {
- editToast.classList.remove("visible");
- toastTimeout = null;
- }, 1600);
+ if (toastTimeout) clearTimeout(toastTimeout);
+ toastTimeout = setTimeout(hideToast, TOAST_DURATION_MS);
}
function actOn(file) {
if (isConfigured(file) && state.ws && state.ws.readyState === WebSocket.OPEN) {
@@ -61,7 +72,7 @@
}
try {
void navigator.clipboard.writeText(file.path);
- flashToast(`Copied ${file.name}`);
+ flashToast(`Copied ${file.path}`);
} catch (_) {
}
}
@@ -127,11 +138,6 @@
config = cfg;
btnEdit.addEventListener("click", (e) => {
e.stopPropagation();
- const files = state.slides[state.slideIndex]?.editableFiles ?? [];
- if (files.length <= 1) {
- if (files.length === 1) actOn(files[0]);
- return;
- }
toggleMenu();
});
renderEditButton();
From 93ae8648809f200b1411f7f9dbfceffb06f219da Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 20:58:26 +0200
Subject: [PATCH 24/25] Show error message when opening command fails
---
src/css/presenter/overlays.css | 21 +++++++++++++++
src/inkflow/bundles/presenter.css | 16 ++++++++++++
src/inkflow/bundles/presenter.js | 10 +++++--
src/inkflow/edit.py | 12 ++++++---
src/inkflow/presenter.html | 7 ++++-
src/inkflow/server.py | 6 ++++-
src/ts/presenter/edit.test.ts | 43 ++++++++++++++++++++++++++++++-
src/ts/presenter/edit.ts | 23 ++++++++++++++---
src/ts/presenter/websocket.ts | 3 +++
src/ts/shared/types.ts | 1 +
tests/test_edit.py | 11 +++++---
11 files changed, 138 insertions(+), 15 deletions(-)
diff --git a/src/css/presenter/overlays.css b/src/css/presenter/overlays.css
index cb453dbe..0fef9c30 100644
--- a/src/css/presenter/overlays.css
+++ b/src/css/presenter/overlays.css
@@ -276,3 +276,24 @@ kbd {
transform: scaleX(1);
}
}
+/* Error state: launching the configured command failed (e.g. the binary isn't
+ on PATH) — red instead of green, the error glyph instead of the checkmark,
+ and no progress bar, since an error toast has no auto-dismiss timer to show
+ the progress of. Declared last so it reliably overrides the base rules above
+ regardless of its own (lower) specificity. */
+.edit-toast-icon-error {
+ display: none;
+}
+#edit-toast.error {
+ border-color: var(--red);
+}
+#edit-toast.error .edit-toast-icon-ok {
+ display: none;
+}
+#edit-toast.error .edit-toast-icon-error {
+ display: block;
+ color: var(--red);
+}
+#edit-toast.error #edit-toast-progress {
+ display: none;
+}
diff --git a/src/inkflow/bundles/presenter.css b/src/inkflow/bundles/presenter.css
index 46ad6fa2..e7b5cf6f 100644
--- a/src/inkflow/bundles/presenter.css
+++ b/src/inkflow/bundles/presenter.css
@@ -498,6 +498,22 @@ kbd {
transform: scaleX(1);
}
}
+.edit-toast-icon-error {
+ display: none;
+}
+#edit-toast.error {
+ border-color: var(--red);
+}
+#edit-toast.error .edit-toast-icon-ok {
+ display: none;
+}
+#edit-toast.error .edit-toast-icon-error {
+ display: block;
+ color: var(--red);
+}
+#edit-toast.error #edit-toast-progress {
+ display: none;
+}
/* src/css/presenter/picker.css */
#picker {
diff --git a/src/inkflow/bundles/presenter.js b/src/inkflow/bundles/presenter.js
index 45de5ca5..6b20d4ad 100644
--- a/src/inkflow/bundles/presenter.js
+++ b/src/inkflow/bundles/presenter.js
@@ -56,13 +56,17 @@
editToast.classList.remove("visible");
toastTimeout = null;
}
- function flashToast(message) {
+ function flashToast(message, kind = "success") {
editToastText.textContent = message;
+ editToast.classList.toggle("error", kind === "error");
editToast.classList.remove("visible");
void editToast.offsetWidth;
editToast.classList.add("visible");
if (toastTimeout) clearTimeout(toastTimeout);
- toastTimeout = setTimeout(hideToast, TOAST_DURATION_MS);
+ toastTimeout = kind === "error" ? null : setTimeout(hideToast, TOAST_DURATION_MS);
+ }
+ function showEditError(message) {
+ flashToast(message, "error");
}
function actOn(file) {
if (isConfigured(file) && state.ws && state.ws.readyState === WebSocket.OPEN) {
@@ -2956,6 +2960,8 @@
renderPv();
} else if (msg.type === "error") {
showError(msg.message);
+ } else if (msg.type === "edit-error") {
+ showEditError(msg.message);
} else if (msg.type === "position") {
if (receives() && !msg.snap && firstPositionPending) {
firstPositionPending = false;
diff --git a/src/inkflow/edit.py b/src/inkflow/edit.py
index 71630f10..d4dbd87b 100644
--- a/src/inkflow/edit.py
+++ b/src/inkflow/edit.py
@@ -45,12 +45,15 @@ def command_for(path: Path, commands: EditCommands) -> str | None:
return commands.default
-def open_in_editor(path: Path, template: str) -> None:
+def open_in_editor(path: Path, template: str) -> str | None:
"""Launch ``template`` with ``path`` substituted, detached from this process.
``{path}`` is substituted into every token that contains it; if no token does,
the path is appended as a final argument (the ``$EDITOR file`` convention).
- Never raises: a bad template or missing binary is a warning, not a crash.
+ Never raises: a bad template or missing binary is logged and returned as a
+ message (e.g. so the caller can also report it back to the requesting
+ browser tab, which is otherwise the only place nothing visibly happens),
+ not raised as a crash.
"""
args = shlex.split(template)
substituted = [a.replace("{path}", str(path)) for a in args]
@@ -66,6 +69,9 @@ def open_in_editor(path: Path, template: str) -> None:
stdin=subprocess.DEVNULL,
)
except OSError as e:
- logger.warning(f"failed to launch edit command {template!r}: {e}")
+ message = f"failed to launch edit command {template!r}: {e}"
+ logger.warning(message)
+ return message
finally:
os.close(devnull)
+ return None
diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html
index d2ca65dc..c33073ad 100644
--- a/src/inkflow/presenter.html
+++ b/src/inkflow/presenter.html
@@ -25,9 +25,14 @@ BUILD ERROR
diff --git a/src/inkflow/server.py b/src/inkflow/server.py
index 9ea3530e..97bcb936 100644
--- a/src/inkflow/server.py
+++ b/src/inkflow/server.py
@@ -300,7 +300,11 @@ async def handler(websocket: ServerConnection) -> None:
msg, _state["slides"], edit_commands
)
if request is not None:
- open_in_editor(*request)
+ error = open_in_editor(*request)
+ if error is not None:
+ await websocket.send(
+ json.dumps({"type": "edit-error", "message": error})
+ )
finally:
_state["ws_clients"].discard(websocket)
logger.debug(f"client disconnected ({len(_state['ws_clients'])} total)")
diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts
index be252894..4163703b 100644
--- a/src/ts/presenter/edit.test.ts
+++ b/src/ts/presenter/edit.test.ts
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
let initEditMenu: typeof import("./edit").initEditMenu;
let renderEditButton: typeof import("./edit").renderEditButton;
+let showEditError: typeof import("./edit").showEditError;
let menuOpened: typeof import("./menus").menuOpened;
let state: typeof import("./state").state;
let btnEdit: HTMLButtonElement;
@@ -34,7 +35,9 @@ beforeEach(async () => {
`;
vi.resetModules();
- ({ initEditMenu, renderEditButton } = await import("./edit"));
+ ({ initEditMenu, renderEditButton, showEditError } = await import(
+ "./edit"
+ ));
({ menuOpened } = await import("./menus"));
({ state } = await import("./state"));
btnEdit = document.getElementById("btn-edit") as HTMLButtonElement;
@@ -339,3 +342,41 @@ test("the general command also applies to SVG files when no SVG-specific overrid
);
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
});
+
+test("showEditError shows a red, non-auto-dismissing toast", () => {
+ vi.useFakeTimers();
+ showEditError("failed to launch edit command 'nope': not found");
+ expect(editToast.classList.contains("visible")).toBe(true);
+ expect(editToast.classList.contains("error")).toBe(true);
+ expect(editToastText.textContent).toBe(
+ "failed to launch edit command 'nope': not found",
+ );
+ // Unlike a success toast, an error stays until dismissed — it's diagnostic
+ // text worth reading, not a transient confirmation.
+ vi.advanceTimersByTime(10_000);
+ expect(editToast.classList.contains("visible")).toBe(true);
+ vi.useRealTimers();
+});
+
+test("the close button dismisses an error toast too", () => {
+ showEditError("something went wrong");
+ editToastClose.click();
+ expect(editToast.classList.contains("visible")).toBe(false);
+});
+
+test("a success toast after an error clears the error styling", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ showEditError("something went wrong");
+ expect(editToast.classList.contains("error")).toBe(true);
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector
(".edit-row")!.click();
+ expect(editToast.classList.contains("error")).toBe(false);
+ expect(editToast.classList.contains("visible")).toBe(true);
+});
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
index 20034b2d..06aab484 100644
--- a/src/ts/presenter/edit.ts
+++ b/src/ts/presenter/edit.ts
@@ -62,10 +62,16 @@ function hideToast(): void {
// Flashes a confirmation styled like the #log-banner message boxes (same
// surface/border/shadow treatment, an accent colour instead of its warning
-// yellow), with its own close button and a shrinking progress bar (pure CSS,
-// see overlays.css) showing time left before it dismisses itself.
-function flashToast(message: string): void {
+// yellow), with its own close button and (for a success message) a shrinking
+// progress bar (pure CSS, see overlays.css) showing time left before it
+// dismisses itself. An error stays until manually dismissed — it's diagnostic
+// text the click that triggered it didn't expect, worth more than a glance.
+function flashToast(
+ message: string,
+ kind: "success" | "error" = "success",
+): void {
editToastText.textContent = message;
+ editToast.classList.toggle("error", kind === "error");
// Drop .visible and force a reflow before re-adding it, even if the toast is
// already showing (two edits in quick succession) — otherwise the browser
// never sees the class go away and won't restart the progress-bar animation.
@@ -73,7 +79,16 @@ function flashToast(message: string): void {
void editToast.offsetWidth;
editToast.classList.add("visible");
if (toastTimeout) clearTimeout(toastTimeout);
- toastTimeout = setTimeout(hideToast, TOAST_DURATION_MS);
+ toastTimeout =
+ kind === "error" ? null : setTimeout(hideToast, TOAST_DURATION_MS);
+}
+
+// Called from websocket.ts when the server replies with an "edit-error"
+// message: the configured command failed to launch (e.g. the binary isn't on
+// PATH). Without this, a failed launch was only ever logged server-side —
+// invisible from the browser tab where the click actually happened.
+export function showEditError(message: string): void {
+ flashToast(message, "error");
}
function actOn(file: EditableFile): void {
diff --git a/src/ts/presenter/websocket.ts b/src/ts/presenter/websocket.ts
index 24269ac8..56a818f7 100644
--- a/src/ts/presenter/websocket.ts
+++ b/src/ts/presenter/websocket.ts
@@ -5,6 +5,7 @@ import type {
TransitionData,
WsMessage,
} from "../shared/types";
+import { showEditError } from "./edit";
import { renderPv, renderPvNext, updatePvInfo } from "./pv";
import { state } from "./state";
import {
@@ -209,6 +210,8 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void {
renderPv();
} else if (msg.type === "error") {
showError(msg.message);
+ } else if (msg.type === "edit-error") {
+ showEditError(msg.message);
} else if (msg.type === "position") {
// Discard exactly the stale connect-time push so an authoritative window
// keeps its own position; later updates apply normally. Mirrors the
diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts
index 34a50560..d669fa16 100644
--- a/src/ts/shared/types.ts
+++ b/src/ts/shared/types.ts
@@ -70,4 +70,5 @@ export type WsMessage =
logs: LogEntry[];
}
| { type: "error"; message: string }
+ | { type: "edit-error"; message: string }
| ({ type: "position" } & SyncPosition);
diff --git a/tests/test_edit.py b/tests/test_edit.py
index 62aaa780..c68f7c7f 100644
--- a/tests/test_edit.py
+++ b/tests/test_edit.py
@@ -78,9 +78,10 @@ def test_open_in_editor_substitutes_placeholder(
) -> None:
popen = MagicMock()
monkeypatch.setattr(subprocess, "Popen", popen)
- open_in_editor(Path("/tmp/slide.svg"), "code -r --goto {path}")
+ result = open_in_editor(Path("/tmp/slide.svg"), "code -r --goto {path}")
args = popen.call_args[0][0] # pyright: ignore[reportAny]
assert args == ["code", "-r", "--goto", "/tmp/slide.svg"]
+ assert result is None
def test_open_in_editor_appends_path_when_no_placeholder(
@@ -93,11 +94,15 @@ def test_open_in_editor_appends_path_when_no_placeholder(
assert args == ["nvim", "/tmp/notes.md"]
-def test_open_in_editor_launch_failure_warns(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_open_in_editor_launch_failure_warns_and_returns_message(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
def _raise(*_args: object, **_kwargs: object) -> None:
raise OSError("no such file or directory")
monkeypatch.setattr(subprocess, "Popen", _raise)
with collect_logs(logging.WARNING) as warnings:
- open_in_editor(Path("/tmp/slide.svg"), "not-a-real-editor {path}")
+ result = open_in_editor(Path("/tmp/slide.svg"), "not-a-real-editor {path}")
assert any("failed to launch edit command" in w.message for w in warnings)
+ assert result is not None
+ assert "failed to launch edit command" in result
From e6854b3531f94ebcab7d467aef8877770d31860d Mon Sep 17 00:00:00 2001
From: ll-nick <68419636+ll-nick@users.noreply.github.com>
Date: Sun, 20 Sep 2026 21:03:06 +0200
Subject: [PATCH 25/25] Fix Windows CI failures from hardcoded Unix path
separators
---
tests/test_edit.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/tests/test_edit.py b/tests/test_edit.py
index c68f7c7f..fa19ea94 100644
--- a/tests/test_edit.py
+++ b/tests/test_edit.py
@@ -78,9 +78,10 @@ def test_open_in_editor_substitutes_placeholder(
) -> None:
popen = MagicMock()
monkeypatch.setattr(subprocess, "Popen", popen)
- result = open_in_editor(Path("/tmp/slide.svg"), "code -r --goto {path}")
+ path = Path("/tmp/slide.svg")
+ result = open_in_editor(path, "code -r --goto {path}")
args = popen.call_args[0][0] # pyright: ignore[reportAny]
- assert args == ["code", "-r", "--goto", "/tmp/slide.svg"]
+ assert args == ["code", "-r", "--goto", str(path)]
assert result is None
@@ -89,9 +90,10 @@ def test_open_in_editor_appends_path_when_no_placeholder(
) -> None:
popen = MagicMock()
monkeypatch.setattr(subprocess, "Popen", popen)
- open_in_editor(Path("/tmp/notes.md"), "nvim")
+ path = Path("/tmp/notes.md")
+ open_in_editor(path, "nvim")
args = popen.call_args[0][0] # pyright: ignore[reportAny]
- assert args == ["nvim", "/tmp/notes.md"]
+ assert args == ["nvim", str(path)]
def test_open_in_editor_launch_failure_warns_and_returns_message(