Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/native_dialogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Native OS file/folder picker capability (Qt-removal plan R7.4c).

The one genuinely NEW capability gap R7.4 scoping identified (not just a
port): Llama.cpp's GGUF picker and Ollama's folder-scan picker both need an
actual on-disk PATH string (llama.cpp's C++ bindings and Ollama's manifest
walker both need a real filesystem path, not file bytes) - a plain HTML
<input type="file"> only gives the browser-side bytes, never a path, so
this has to be a NATIVE OS dialog.

graphlink_desktop.py's webview.create_window(...) call discards its return
value, but that's a non-issue: pywebview's own webview.windows is a plain
module-level list that create_window() appends the new Window to
UNCONDITIONALLY, the moment it runs - reachable from anywhere afterward via
webview.windows[0], with zero plumbing changes needed in graphlink_desktop.py
itself. Window.create_file_dialog(...) is safe to call from a worker thread
(pywebview's own docs/exposed-JS-API pattern already does this) - confirmed
directly via inspect.signature and the @_shown_call wrapper it goes through,
which just waits on a plain threading.Event, not anything main-thread-only.

NO WINDOW is a normal, expected, gracefully-handled condition, not an error:
create_window() never runs under bare `uvicorn`/pytest (the packaged desktop
entry point is the only caller), so webview.windows is simply `[]` there.
Both functions below return None in that case - callers must treat a None
return exactly like a user-cancelled dialog (nothing was picked), which is
also what pywebview itself returns on cancel.
"""

from __future__ import annotations

import asyncio
from typing import Sequence

import webview


def _active_window():
return webview.windows[0] if webview.windows else None


async def pick_file(file_types: Sequence[str] = (), directory: str = "") -> str | None:
"""Opens a native OPEN file dialog. Returns the selected path, or None
if no window exists (bare uvicorn/tests) or the user cancelled.

`directory` seeds the dialog's starting location - matches legacy's own
_pick_gguf_file, which always computed a real starting directory
(the staged path's own folder, or a saved scan path, or home) rather
than leaving it to whatever the OS defaults to."""
window = _active_window()
if window is None:
return None
result = await asyncio.to_thread(
window.create_file_dialog,
webview.FileDialog.OPEN,
directory=directory,
file_types=tuple(file_types),
)
return result[0] if result else None


async def pick_folder(directory: str = "") -> str | None:
"""Opens a native FOLDER dialog. Returns the selected path, or None if
no window exists or the user cancelled."""
window = _active_window()
if window is None:
return None
result = await asyncio.to_thread(window.create_file_dialog, webview.FileDialog.FOLDER, directory=directory)
return result[0] if result else None
442 changes: 410 additions & 32 deletions backend/settings.py

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions backend/tests/test_native_dialogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Tests for backend/native_dialogs.py (Qt-removal plan R7.4c).

webview.windows is monkeypatched directly (a plain list, per pywebview's
own source) rather than mocking a whole Window class where not needed -
the "no window" branch only cares that the list is empty; the "window
exists" branch needs just enough of a fake Window to satisfy
create_file_dialog's call signature.
"""

import asyncio

import webview

from backend import native_dialogs


class _FakeWindow:
def __init__(self, return_value):
self.return_value = return_value
self.calls = []

def create_file_dialog(self, dialog_type=10, directory="", allow_multiple=False, save_filename="", file_types=()):
self.calls.append(
{
"dialog_type": dialog_type,
"directory": directory,
"allow_multiple": allow_multiple,
"save_filename": save_filename,
"file_types": file_types,
}
)
return self.return_value


def test_pick_file_returns_none_when_no_window_exists(monkeypatch):
monkeypatch.setattr(webview, "windows", [])

result = asyncio.run(native_dialogs.pick_file(file_types=("GGUF files (*.gguf)",)))

assert result is None


def test_pick_file_calls_open_dialog_with_file_types_and_returns_first_path(monkeypatch):
fake = _FakeWindow(return_value=("C:/models/a.gguf",))
monkeypatch.setattr(webview, "windows", [fake])

result = asyncio.run(native_dialogs.pick_file(file_types=("GGUF files (*.gguf)",)))

assert result == "C:/models/a.gguf"
assert fake.calls == [
{
"dialog_type": webview.FileDialog.OPEN,
"directory": "",
"allow_multiple": False,
"save_filename": "",
"file_types": ("GGUF files (*.gguf)",),
}
]


def test_pick_file_returns_none_when_the_user_cancels(monkeypatch):
fake = _FakeWindow(return_value=None)
monkeypatch.setattr(webview, "windows", [fake])

result = asyncio.run(native_dialogs.pick_file())

assert result is None


def test_pick_folder_returns_none_when_no_window_exists(monkeypatch):
monkeypatch.setattr(webview, "windows", [])

result = asyncio.run(native_dialogs.pick_folder())

assert result is None


def test_pick_folder_calls_folder_dialog_and_returns_first_path(monkeypatch):
fake = _FakeWindow(return_value=("C:/models",))
monkeypatch.setattr(webview, "windows", [fake])

result = asyncio.run(native_dialogs.pick_folder())

assert result == "C:/models"
assert fake.calls[0]["dialog_type"] == webview.FileDialog.FOLDER


def test_pick_folder_returns_none_when_the_user_cancels(monkeypatch):
fake = _FakeWindow(return_value=None)
monkeypatch.setattr(webview, "windows", [fake])

result = asyncio.run(native_dialogs.pick_folder())

assert result is None


def test_uses_the_first_window_when_multiple_exist():
# webview.windows can only ever grow (create_window appends, nothing
# removes) - confirms the module reads index 0, not "the last one" or
# anything order-sensitive beyond that documented convention.
first = _FakeWindow(return_value=("first-picked.gguf",))
second = _FakeWindow(return_value=("second-picked.gguf",))
import webview as wv

original = wv.windows
try:
wv.windows = [first, second]
result = asyncio.run(native_dialogs.pick_file())
assert result == "first-picked.gguf"
assert second.calls == []
finally:
wv.windows = original
Loading
Loading