diff --git a/collins/chatsessionview.py b/collins/chatsessionview.py index 632744d..71c1c05 100644 --- a/collins/chatsessionview.py +++ b/collins/chatsessionview.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-01. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. """A chat tab backed by a live headless `claude -p` stream-json session. @@ -267,7 +267,7 @@ def _chip_path(tool_input: dict) -> str: return "" def _open_in_editor(self, path: str) -> None: - self.activate_action("win.open-in-editor", GLib.Variant("s", path)) + self.activate_action("win.open-in-editor", GLib.Variant("(sii)", (path, 0, 0))) # -- permissions ----------------------------------------------------------- diff --git a/collins/editor.py b/collins/editor.py index f327f8e..325e599 100644 --- a/collins/editor.py +++ b/collins/editor.py @@ -347,6 +347,12 @@ def open_file(self, path: str | Path, restore_cursor: list | None = None) -> Non existing = self._pages.get(key) if existing is not None: self._tab_view.set_selected_page(existing) + if restore_cursor: + # A clicked `path:line` reference re-targets a page that is + # already open (image pages have no buffer and stay put). + opened = self._open.get(key) + if opened is not None: + self._apply_cursor(opened, restore_cursor) return # Defense in depth behind the tree's own symlink filtering: every # caller (tree activation, session restore, future pop-out) funnels @@ -607,7 +613,23 @@ def _apply_cursor(self, opened: _OpenFile, pos) -> None: _found, it = opened.buffer.get_iter_at_line(max(0, min(int(line), n_lines - 1))) it.set_line_offset(max(0, min(int(offset), it.get_chars_in_line()))) opened.buffer.place_cursor(it) - opened.view.scroll_to_iter(it, 0.1, False, 0, 0) + # On a freshly loaded buffer, line heights are estimates until the + # view's validation idles run: scroll_to_iter silently stays at the + # top, and even scroll_to_mark's pending scroll lands hundreds of + # lines short on a large file. Scroll now for the cheap case, then + # re-issue at PRIORITY_LOW — validation runs at a far higher idle + # priority, so by the time the re-scroll fires the heights are exact + # (a clicked `path:602` reference must actually land on line 602). + opened.view.scroll_to_mark(opened.buffer.get_insert(), 0.1, False, 0.0, 0.0) + view = opened.view + + def rescroll() -> bool: + buffer = view.get_buffer() + if buffer is not None: + view.scroll_to_mark(buffer.get_insert(), 0.1, False, 0.0, 0.0) + return GLib.SOURCE_REMOVE + + GLib.idle_add(rescroll, priority=GLib.PRIORITY_LOW) # -- notices --------------------------------------------------------------- diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 9f838ac..75651ef 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -1,19 +1,92 @@ """What counts as a link in terminal output. -Kept free of GTK/VTE imports so the pattern stays unit-testable on CI, which -has no VTE stack (see tests/conftest.py). The grammar is deliberately simpler -than GNOME Terminal's terminal-regex.h: a scheme'd URL or a bare www. host, -stopping at whitespace, quotes and angle brackets, and refusing to *end* on -punctuation that prose tends to hang off a link — so `(https://a.b/c).` -matches just `https://a.b/c`. - -The pattern sticks to syntax PCRE2 and Python's `re` share: VTE compiles it -with PCRE2 at runtime, the tests exercise it with `re`. +Kept free of GTK/VTE imports so the patterns stay unit-testable on CI, which +has no VTE stack (see tests/conftest.py). Two grammars, each deliberately +simpler than GNOME Terminal's terminal-regex.h: + +- ``URL_PATTERN``: a scheme'd URL or a bare www. host. +- ``FILE_PATTERN``: path-shaped text — absolute, ``~/``, or relative with at + least one slash, with Claude Code's optional ``:line[:col]`` suffix. A + path regex necessarily over-matches prose (``a/b``, dates, package + paths), so a hit is only a *candidate*: ``resolve_file_reference`` checks + the filesystem at click time, and a candidate that resolves nowhere is + ignored — the click falls through to the terminal. Bare filenames + (``terminal.py``) are deliberately out of the grammar; without a slash + the false-positive rate in ordinary prose is too high. + +Both grammars stop at whitespace, quotes and brackets, and refuse to *end* +on punctuation that prose tends to hang off a reference — so +``(https://a.b/c).`` matches just ``https://a.b/c`` and +``collins/foo.py:12.`` keeps the final period out of the line suffix. + +The patterns stick to syntax PCRE2 and Python's `re` share: VTE compiles +them with PCRE2 at runtime, the tests exercise them with `re`. """ +import os +import re + # One body-then-final-char pair per alternative: the greedy body backtracks # until the last character is something a URL can plausibly end on. _BODY = "[^\\s<>\"']*" _FINAL = "[^\\s<>\"'.,:;!?)\\]}]" URL_PATTERN = f"(?:https?|ftp|file)://{_BODY}{_FINAL}|www\\.{_BODY}{_FINAL}" + +# Paths bound tighter than URLs: brackets, backticks and quotes all end them +# (paths with spaces can't be bounded by a regex at all and stay out of +# scope), and `:` is reserved for the line suffix. The lookbehind demands a +# boundary character — or the start of the line — before the match. That +# same lookbehind keeps this grammar out of URLs: inside `https://a.b/c` +# every path-shaped start is preceded by `:`, `/` or a word character, none +# of which are boundaries, so URLs keep matching as URLs only. +_PATH_BOUNDARY = "\\s<>\"'`()\\[\\]{}" +_PATH_PRE = f"(?= 1 slash. The tail is optional so a bare directory + # reference (`collins/`) matches too; the absolute alternative keeps a + # mandatory tail so a lone `/` in prose never becomes a link to the + # filesystem root. + f"|{_PATH_SEG}+/(?:{_PATH_CHAR}*{_PATH_FINAL})?)" + f"{_LINE_SUFFIX}" +) + +_SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?") + + +def resolve_file_reference( + text: str, roots: list[str | None] +) -> tuple[str, int | None, int | None] | None: + """The file a FILE_PATTERN candidate actually points at, or None. + + The regex is only a shape detector; this is the false-positive gate the + click runs. Strips the ``:line[:col]`` suffix (preferring that reading + over a literal filename containing colons), expands ``~``, and tries + relative paths against each of *roots* in order, skipping None entries. + Returns ``(path, line, col)`` with line/col as the reference wrote them + (1-based) or None where it carried no suffix. + """ + candidates: list[tuple[str, int | None, int | None]] = [] + m = _SUFFIX.fullmatch(text) + if m is not None: + candidates.append( + (m.group(1), int(m.group(2)), int(m.group(3)) if m.group(3) else None) + ) + candidates.append((text, None, None)) + for raw, line, col in candidates: + expanded = os.path.expanduser(raw) + if os.path.isabs(expanded): + trials = [expanded] + else: + trials = [os.path.join(root, expanded) for root in roots if root] + for trial in trials: + if os.path.exists(trial): + return os.path.normpath(trial), line, col + return None diff --git a/collins/terminal.py b/collins/terminal.py index ef5536b..45a77a5 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -41,7 +41,7 @@ from .gitinfo import current_branch, has_changes # noqa: E402 from .i18n import _, ngettext # noqa: E402 from .lightbox import present_image_lightbox # noqa: E402 -from .linkpatterns import URL_PATTERN # noqa: E402 +from .linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference # noqa: E402 from .promptcard import build_question_card # noqa: E402 from .providers import Provider, get_provider # noqa: E402 from .prstatus import ( # noqa: E402 @@ -141,51 +141,65 @@ def _setup_links(terminal: Vte.Terminal) -> None: """Give links GNOME Terminal's behaviour: underline on hover, open on Ctrl+click. - Covers both kinds of link a terminal shows: OSC 8 hyperlinks (what agent + Covers three kinds of link a terminal shows: OSC 8 hyperlinks (what agent CLIs emit for file references — VTE ignores the escape entirely until - allow-hyperlink is switched on) and plain URLs in the output, matched by - regex the way GNOME Terminal matches them. + allow-hyperlink is switched on), plain URLs in the output matched by regex + the way GNOME Terminal matches them, and path-shaped file references + (`collins/foo.py:12`) matched by a second regex and validated against the + filesystem only at click time — VTE has no per-match callback, so the + hover underline can't know whether the file exists, but a click on a + candidate that resolves nowhere falls through to the terminal unclaimed + and costs the user nothing. """ terminal.set_allow_hyperlink(True) - try: - regex = Vte.Regex.new_for_match( - URL_PATTERN, len(URL_PATTERN.encode()), _PCRE2_MULTILINE - ) - terminal.match_set_cursor_name(terminal.match_add_regex(regex, 0), "pointer") - except GLib.Error: - regex = None # VTE built without PCRE2: OSC 8 links still work - - def on_launched(launcher: Gtk.UriLauncher | Gtk.FileLauncher, result) -> None: + tag_kinds: dict[int, str] = {} + for pattern, kind in ((URL_PATTERN, "url"), (FILE_PATTERN, "file")): try: - launcher.launch_finish(result) + regex = Vte.Regex.new_for_match( + pattern, len(pattern.encode()), _PCRE2_MULTILINE + ) except GLib.Error: - pass # no handler for the scheme/type, or the user dismissed the chooser + continue # VTE built without PCRE2: OSC 8 links still work + tag = terminal.match_add_regex(regex, 0) + terminal.match_set_cursor_name(tag, "pointer") + tag_kinds[tag] = kind def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: if not gesture.get_current_event_state() & Gdk.ModifierType.CONTROL_MASK: return + kind = "url" uri = terminal.check_hyperlink_at(x, y) - if uri is None and regex is not None: - match, _tag = terminal.check_match_at(x, y) - if match is not None and match.startswith("www."): - match = "http://" + match + if uri is None and tag_kinds: + match, tag = terminal.check_match_at(x, y) + if match is not None: + kind = tag_kinds.get(tag, "url") + if kind == "url" and match.startswith("www."): + match = "http://" + match uri = match if not uri: return + if kind == "file": + resolved = resolve_file_reference(uri, _reference_roots(terminal)) + if resolved is None: + return # over-matched prose: leave the click to the terminal + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + path, line, col = resolved + _open_file_reference(terminal, path, line, col) + return gesture.set_state(Gtk.EventSequenceState.CLAIMED) if uri.startswith("file:"): + # A file: URI (or OSC 8 file: hyperlink) behaves exactly like a + # matched path reference — lightbox for images, editor inside + # the project, default app otherwise — however the CLI happened + # to emit it. path_from_file_uri sheds any #L10-style fragment. path = editorfiles.path_from_file_uri(uri) - if path is not None and editorfiles.is_image_path(path): - _present_image(terminal, path) + if path is not None: + _open_file_reference(terminal, path, None, None) return - # Open the file itself in its default app (what xdg-open does). - # UriLauncher would hand a file: URI to the portal, which only - # reveals it in the file manager. Gio.File also sheds any line - # fragment the emitter tacked on. launcher = Gtk.FileLauncher.new(Gio.File.new_for_uri(uri)) else: launcher = Gtk.UriLauncher.new(uri) - launcher.launch(terminal.get_root(), None, on_launched) + launcher.launch(terminal.get_root(), None, _on_link_launched) # Capture phase, so Ctrl+click opens the link even when the running app # has turned on mouse reporting (same trick as the context menu). @@ -195,6 +209,49 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: terminal.add_controller(click) +def _on_link_launched(launcher: Gtk.UriLauncher | Gtk.FileLauncher, result) -> None: + try: + launcher.launch_finish(result) + except GLib.Error: + pass # no handler for the scheme/type, or the user dismissed the chooser + + +def _reference_roots(terminal: Vte.Terminal) -> list[str | None]: + """Where a relative file reference may be rooted: the running agent's cwd + first (Claude often works from a worktree or subdirectory), then the + tab's project root. Trying both catches the common cases cheaply.""" + tab = terminal.get_ancestor(TerminalTab) + if tab is None: + return [] + return [tab.current_agent_cwd(), tab.editor_root] + + +def _open_file_reference( + terminal: Vte.Terminal, path: str, line: int | None, col: int | None +) -> None: + """Open a resolved file reference the way the reference deserves: images + in the lightbox (any readable path, even outside the project — its own + "Open in Editor" button handles the editor handoff), files inside the + clicking tab's project in that tab's editor at the referenced line, and + everything else — directories, outside-project files, no editor — in the + default app, as `file:` URIs always opened.""" + if os.path.isfile(path): + if editorfiles.is_image_path(path): + _present_image(terminal, path) + return + tab = terminal.get_ancestor(TerminalTab) + if tab is not None and tab.can_open_in_editor(path): + # The window's action, not the tab directly: it also presents a + # popped-out editor window and applies the pop-out-on-small- + # screen policy. Line/col travel 1-based; 0 means none. + terminal.activate_action( + "win.open-in-editor", GLib.Variant("(sii)", (path, line or 0, col or 0)) + ) + return + launcher = Gtk.FileLauncher.new(Gio.File.new_for_path(path)) + launcher.launch(terminal.get_root(), None, _on_link_launched) + + def _present_image(terminal: Vte.Terminal, path: str) -> None: """A clicked file reference turned out to be an image: lightbox over the window instead of handing it to the default app. Its "Open in Editor" @@ -208,7 +265,9 @@ def _present_image(terminal: Vte.Terminal, path: str) -> None: if can_edit: def on_open() -> None: - terminal.activate_action("win.open-in-editor", GLib.Variant("s", path)) + terminal.activate_action( + "win.open-in-editor", GLib.Variant("(sii)", (path, 0, 0)) + ) present_image_lightbox( terminal, path, can_open_in_editor=can_edit, on_open_in_editor=on_open @@ -2369,15 +2428,17 @@ def can_open_in_editor(self, path: str | Path) -> bool: refuse anything outside; this lets the window pick a better tab).""" return self._editor is not None and editorfiles.is_inside(self._editor.root, path) - def open_in_editor(self, path: str | Path) -> None: + def open_in_editor(self, path: str | Path, cursor: list | None = None) -> None: """Open *path* in this tab's editor, revealing the panel if it is - closed. While the pane is popped out its window already shows it — - presenting that window is the caller's job (it owns the windows).""" + closed, optionally placing the cursor (*cursor* is open_file's + restore_cursor: [0-based line, char offset]). While the pane is + popped out its window already shows it — presenting that window is + the caller's job (it owns the windows).""" if self._editor is None: return if not self._editor_detached and not self.editor_visible: self.show_editor() - self._editor.open_file(path) + self._editor.open_file(path, restore_cursor=cursor) self._editor.focus_default() def set_editor_width_lookup(self, lookup) -> None: diff --git a/collins/window.py b/collins/window.py index b672360..c56de47 100644 --- a/collins/window.py +++ b/collins/window.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-02. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. """Main window: composes the session sidebar with the tabbed terminal area.""" from __future__ import annotations @@ -978,13 +978,21 @@ def _install_actions(self) -> None: "trash-session": self._on_trash_session, "open-folder": self._on_open_folder, "open-folder-terminal": self._on_open_folder_terminal, - "open-in-editor": lambda _a, p: self._open_in_editor(p.get_string()), } for name, callback in per_session.items(): action = Gio.SimpleAction(name=name, parameter_type=GLib.VariantType("s")) action.connect("activate", callback) self.add_action(action) + # (path, line, col) with line/col 1-based and 0 meaning "no cursor" — + # clicked file references carry a position, tool chips and the + # lightbox handoff pass zeros. + open_in_editor = Gio.SimpleAction( + name="open-in-editor", parameter_type=GLib.VariantType("(sii)") + ) + open_in_editor.connect("activate", self._on_open_in_editor) + self.add_action(open_in_editor) + # The two-part targets: (desktop-file ID, folder), and the session plus # the prompt a row's PR menu wants typed into it. open_folder_app = Gio.SimpleAction( @@ -2938,11 +2946,16 @@ def _quick_open_file(self) -> None: self._quickopen.connect("closed", lambda *_: setattr(self, "_quickopen", None)) self._quickopen.present(self) - def _open_in_editor(self, path: str) -> None: - """win.open-in-editor(path): the current terminal tab's editor when the - file belongs to its project, else whichever tab's project it does - belong to (chat tabs have no editor, but their tool chips fire this). - Nowhere to open it → quietly nothing, like the other editor actions.""" + def _on_open_in_editor(self, _action, param: GLib.Variant) -> None: + path, line, col = param.unpack() + self._open_in_editor(path, [line - 1, col] if line > 0 else None) + + def _open_in_editor(self, path: str, cursor: list | None = None) -> None: + """win.open-in-editor(path, line, col): the current terminal tab's + editor when the file belongs to its project, else whichever tab's + project it does belong to (chat tabs have no editor, but their tool + chips fire this). Nowhere to open it → quietly nothing, like the + other editor actions.""" tab = self._current_terminal_tab() if tab is None or not tab.can_open_in_editor(path): tab = None @@ -2954,15 +2967,15 @@ def _open_in_editor(self, path: str) -> None: if tab is None: return self.tab_view.set_selected_page(self.tab_view.get_page(tab)) - self._open_in_tab_editor(tab, path) + self._open_in_tab_editor(tab, path, cursor) - def _open_in_tab_editor(self, tab: TerminalTab, path: str) -> None: + def _open_in_tab_editor(self, tab: TerminalTab, path: str, cursor: list | None = None) -> None: win = self._editor_windows.get(tab) if win is not None: win.present() # the pane lives in its own window right now elif not tab.editor_visible and self._editor_opens_popped_out(): self._pop_out_editor(tab) - tab.open_in_editor(path) + tab.open_in_editor(path, cursor) # -- popped-out editor windows -------------------------------------------- diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index 1a215b8..bf15891 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -1,17 +1,18 @@ -"""The URL grammar the terminals hover-highlight and Ctrl+click-open. +"""The link grammars the terminals hover-highlight and Ctrl+click-open. -VTE compiles URL_PATTERN with PCRE2; these tests exercise the same pattern -with Python's `re`, which the pattern deliberately restricts itself to the -shared syntax of (see collins/linkpatterns.py). +VTE compiles URL_PATTERN and FILE_PATTERN with PCRE2; these tests exercise +the same patterns with Python's `re`, which the patterns deliberately +restrict themselves to the shared syntax of (see collins/linkpatterns.py). """ import re import pytest -from collins.linkpatterns import URL_PATTERN +from collins.linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference _RX = re.compile(URL_PATTERN) +_FILE_RX = re.compile(FILE_PATTERN) def _first_match(text: str) -> str | None: @@ -19,6 +20,11 @@ def _first_match(text: str) -> str | None: return m.group(0) if m else None +def _first_file_match(text: str) -> str | None: + m = _FILE_RX.search(text) + return m.group(0) if m else None + + @pytest.mark.parametrize( "text,expected", [ @@ -62,3 +68,166 @@ def test_plain_text_does_not_match(text: str) -> None: def test_url_stops_at_whitespace() -> None: assert _first_match("https://a.example/x https://b.example/y") == "https://a.example/x" + + +# -- FILE_PATTERN ---------------------------------------------------------- + + +@pytest.mark.parametrize( + "text,expected", + [ + ("open collins/terminal.py please", "collins/terminal.py"), + ("at collins/terminal.py:152 there", "collins/terminal.py:152"), + ("at collins/terminal.py:152:8 there", "collins/terminal.py:152:8"), + ("see /etc/hosts for that", "/etc/hosts"), + ("wrote /tmp/shot.png:12 out", "/tmp/shot.png:12"), + ("notes in ~/notes/todo.md", "~/notes/todo.md"), + ("run ./scripts/run first", "./scripts/run"), + ("or ../sibling/file.txt instead", "../sibling/file.txt"), + # Reference at the very start of a line (the lookbehind must accept + # having no character before the match at all). + ("collins/app.py:3 changed", "collins/app.py:3"), + ], +) +def test_matches_file_references(text: str, expected: str) -> None: + assert _first_file_match(text) == expected + + +@pytest.mark.parametrize( + "text,expected", + [ + # Same ending discipline as URLs: sentence punctuation stays outside, + # with or without a line suffix. + ("(see collins/foo.py).", "collins/foo.py"), + ("fixed collins/foo.py:12.", "collins/foo.py:12"), + ("in `collins/foo.py:3` above", "collins/foo.py:3"), + ("edit 'collins/foo.py', then", "collins/foo.py"), + ("[a](collins/foo.py)", "collins/foo.py"), + ("dir collins/subdir/ listed", "collins/subdir"), + # A bare directory reference keeps its structural slash — that slash + # is what makes it a candidate at all. + ("the collins/ package", "collins/"), + ("really /var/log/syslog?", "/var/log/syslog"), + ], +) +def test_file_reference_sheds_trailing_punctuation(text: str, expected: str) -> None: + assert _first_file_match(text) == expected + + +def test_prose_slashes_match_as_candidates() -> None: + # Over-matching prose is fine by design: the filesystem check in + # resolve_file_reference is the real gate, and `a/b` resolves nowhere. + assert _first_file_match("either a/b or both") == "a/b" + + +@pytest.mark.parametrize( + "text", + [ + "no path here", + "bare terminal.py stays inert", + "colon:separated:words", + # URLs must keep matching as URLs, never as file references — the + # slash-bearing tails of these must not produce a file match. + "https://example.com/a/b", + "http://example.com/a/b?q=1", + "file:///home/user/notes.txt", + "ftp://host/file.tar.gz", + "visit www.example.com today", + ], +) +def test_non_paths_do_not_match(text: str) -> None: + assert _first_file_match(text) is None + + +def test_urls_still_match_as_urls() -> None: + for text in ("https://example.com/a/b", "file:///home/user/notes.txt"): + assert _first_match(text) == text + + +# -- resolve_file_reference ------------------------------------------------ + + +@pytest.fixture() +def project(tmp_path): + (tmp_path / "collins").mkdir() + (tmp_path / "collins" / "foo.py").write_text("print()\n") + return tmp_path + + +def test_resolve_relative_against_root(project) -> None: + assert resolve_file_reference("collins/foo.py", [str(project)]) == ( + str(project / "collins" / "foo.py"), + None, + None, + ) + + +def test_resolve_strips_line_and_col(project) -> None: + root = str(project) + path = str(project / "collins" / "foo.py") + assert resolve_file_reference("collins/foo.py:12", [root]) == (path, 12, None) + assert resolve_file_reference("collins/foo.py:12:5", [root]) == (path, 12, 5) + + +def test_resolve_absolute_ignores_roots(project) -> None: + path = str(project / "collins" / "foo.py") + assert resolve_file_reference(f"{path}:7", []) == (path, 7, None) + + +def test_resolve_tries_roots_in_order(tmp_path) -> None: + first = tmp_path / "worktree" + second = tmp_path / "project" + for root in (first, second): + (root / "collins").mkdir(parents=True) + (root / "collins" / "foo.py").touch() + resolved = resolve_file_reference( + "collins/foo.py", [str(first), str(second)] + ) + assert resolved == (str(first / "collins" / "foo.py"), None, None) + + +def test_resolve_skips_none_roots(project) -> None: + resolved = resolve_file_reference("collins/foo.py", [None, str(project)]) + assert resolved == (str(project / "collins" / "foo.py"), None, None) + + +def test_resolve_expands_home(project, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(project)) + assert resolve_file_reference("~/collins/foo.py", []) == ( + str(project / "collins" / "foo.py"), + None, + None, + ) + + +def test_resolve_normalizes_dot_segments(project) -> None: + resolved = resolve_file_reference("./collins/foo.py", [str(project)]) + assert resolved == (str(project / "collins" / "foo.py"), None, None) + + +def test_resolve_finds_directories(project) -> None: + # Directories resolve too — the click path sends them to the file + # manager instead of the editor. With or without the trailing slash a + # bare directory reference matches with (normpath sheds it). + expected = (str(project / "collins"), None, None) + assert resolve_file_reference("collins", [str(project)]) == expected + assert resolve_file_reference("collins/", [str(project)]) == expected + + +def test_resolve_prefers_line_suffix_over_literal_colon_name(project) -> None: + literal = project / "collins" / "foo.py:1" + literal.touch() + resolved = resolve_file_reference("collins/foo.py:1", [str(project)]) + assert resolved == (str(project / "collins" / "foo.py"), 1, None) + + +def test_resolve_falls_back_to_literal_colon_name(project) -> None: + literal = project / "collins" / "weird:2" + literal.touch() + resolved = resolve_file_reference("collins/weird:2", [str(project)]) + assert resolved == (str(literal), None, None) + + +def test_resolve_missing_returns_none(project) -> None: + assert resolve_file_reference("collins/nope.py", [str(project)]) is None + assert resolve_file_reference("collins/nope.py:3", [str(project)]) is None