fix(autocomplete): improve wildcard onboarding UX

This commit is contained in:
Will Miao
2026-04-15 22:18:25 +08:00
parent 439679e15f
commit cdd77029b6
10 changed files with 573 additions and 97 deletions

View File

@@ -1209,7 +1209,15 @@ describe('AutoComplete widget interactions', () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: ['animals/cat'] }),
json: () => Promise.resolve({
success: true,
words: ['animals/cat'],
meta: {
has_wildcards: true,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
@@ -1237,6 +1245,129 @@ describe('AutoComplete widget interactions', () => {
expect(autoComplete.items).toEqual(['animals/cat']);
});
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: false,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard cat';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.isVisible).toBe(true);
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
});
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: false,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard ';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
expect(autoComplete.isVisible).toBe(true);
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
});
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: true,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard dragon';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
});
it('inserts wildcard references when accepting a /wildcard result', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');

View File

@@ -499,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]]
@pytest.mark.asyncio
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
wildcards_dir = tmp_path / "settings" / "wildcards"
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(tmp_path / "settings.json")))
calls = []
def fake_popen(args):
calls.append(args)
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
monkeypatch.setattr(
"py.services.wildcard_service.get_wildcards_dir",
lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir)
if create
else str(wildcards_dir),
)
response = await handler.open_wildcards_location(FakeRequest())
payload = json.loads(response.text)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(wildcards_dir)
assert wildcards_dir.is_dir()
assert calls == [["xdg-open", str(wildcards_dir)]]
class RecordingRouter:
def __init__(self):
self.calls = []

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
@@ -15,6 +16,14 @@ class FakeRequest:
@pytest.mark.asyncio
async def test_search_wildcards_returns_results():
class StubService:
def get_metadata(self, create_dir=False):
assert create_dir is True
return SimpleNamespace(
has_wildcards=True,
wildcards_dir="/tmp/settings/wildcards",
supported_formats=(".txt", ".yaml", ".yml", ".json"),
)
def search_keys(self, search_term, limit, offset):
assert search_term == "cat"
assert limit == 25
@@ -28,12 +37,27 @@ async def test_search_wildcards_returns_results():
payload = json.loads(response.text)
assert response.status == 200
assert payload == {"success": True, "words": ["animals/cat"]}
assert payload == {
"success": True,
"words": ["animals/cat"],
"meta": {
"has_wildcards": True,
"wildcards_dir": "/tmp/settings/wildcards",
"supported_formats": [".txt", ".yaml", ".yml", ".json"],
},
}
@pytest.mark.asyncio
async def test_search_wildcards_handles_errors():
class StubService:
def get_metadata(self, create_dir=False):
return SimpleNamespace(
has_wildcards=False,
wildcards_dir="/tmp/settings/wildcards",
supported_formats=(".txt",),
)
def search_keys(self, search_term, limit, offset):
raise RuntimeError("boom")

View File

@@ -68,6 +68,17 @@ def test_search_keys_supports_offset_and_limit(monkeypatch, tmp_path):
assert service.search_keys("cat", limit=1, offset=1) == ["catgirl"]
def test_get_metadata_creates_directory_and_reports_formats(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
metadata = service.get_metadata(create_dir=True)
assert metadata.has_wildcards is False
assert metadata.wildcards_dir == str(wildcards_dir)
assert metadata.supported_formats == (".txt", ".yaml", ".yml", ".json")
assert wildcards_dir.is_dir()
def test_expand_text_resolves_nested_wildcards(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()