fix: remove min query length requirement and improve search UI styling

- Remove 3-character minimum search query requirement
- Add dynamic padding to search results container
- Style search results with icons, grid layout, and hover states
- Fix missing semicolons in ChatInput event handlers
- Implement auto-scroll for selected search result
- Initialize vault cache after metadata resolution
- Update tests to reflect removed query length validation
This commit is contained in:
Andrew Beal 2025-10-29 21:33:45 +00:00
parent 063dee3057
commit 63bcfdeae9
6 changed files with 144 additions and 18 deletions

View file

@ -79,7 +79,7 @@
if (SearchTrigger.isSearchTrigger(e.key)) {
e.preventDefault();
const position = inputService.getCursorPosition(textareaElement)
const position = inputService.getCursorPosition(textareaElement);
const trigger = SearchTrigger.fromInput(e.key);
searchStateStore.initializeSearch(trigger, position);
@ -112,7 +112,7 @@
}
if (e.key === "ArrowDown") {
e.preventDefault();
searchStateStore.setSelectedResultToNext()
searchStateStore.setSelectedResultToNext();
}
return;
}
@ -200,7 +200,7 @@
</script>
<div id="input-container" class:edit-mode={editModeActive}>
<div id="input-search-results-container">
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
<ChatSearchResults searchState={$searchState}/>
</div>
@ -259,7 +259,7 @@
#input-search-results-container {
grid-row: 1;
grid-column: 2 / 8;
grid-column: 2 / 7;
}
#input-field {

View file

@ -1,14 +1,45 @@
<script lang="ts">
import { basename } from 'path';
import type { ISearchState } from 'Stores/SearchStateStore';
import { tick } from 'svelte';
import { setIcon } from 'obsidian';
import { SearchTrigger } from 'Enums/SearchTrigger';
export let searchState: ISearchState;
let contentDiv: HTMLDivElement;
let height = 0;
let resultElements: (HTMLDivElement | null)[] = [];
let iconElements: (HTMLDivElement | null)[] = [];
$: searchState.results, updateHeight();
$: if (searchState.selectedResult && resultElements.length > 0) {
scrollSelectedIntoView();
}
$: if (searchState.results.length > 0 && iconElements.length > 0) {
iconElements.forEach((iconEl) => {
if (iconEl) {
const iconName = getIconName(searchState.trigger);
setIcon(iconEl, iconName);
}
});
}
function getIconName(trigger: SearchTrigger | null): string {
switch(trigger) {
case SearchTrigger.File:
return 'file-text';
case SearchTrigger.Folder:
return 'folder';
case SearchTrigger.Tag:
return 'tag';
default:
return 'file-text';
}
}
function updateHeight() {
tick().then(() => {
if (contentDiv) {
@ -17,12 +48,31 @@
});
}
function scrollSelectedIntoView() {
tick().then(() => {
const selectedIndex = searchState.results.indexOf(searchState.selectedResult);
if (selectedIndex !== -1 && resultElements[selectedIndex]) {
resultElements[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
});
}
</script>
<div id="input-search-results" style:height="{height}px">
<div id="input-search-results-inner-container" bind:this={contentDiv}>
{#each searchState.results as searchResult}
<div style:background-color="{searchResult === searchState.selectedResult ? "red" : "transparent"}">{searchResult}</div>
{#each searchState.results as searchResult, index}
<div class="input-search-result-container"
bind:this={resultElements[index]}
style:background-color={searchResult === searchState.selectedResult ? "var(--interactive-accent)" : "transparent"}>
<div class="input-search-result-icon" bind:this={iconElements[index]}></div>
<div class="input-search-result-title">{basename(searchResult)}</div>
<div class="input-search-result-subtitle">{searchResult}</div>
</div>
{/each}
</div>
</div>
@ -33,7 +83,6 @@
transition: height 0.2s ease-out;
overflow: auto;
scroll-behavior: smooth;
scroll-snap-type: mandatory;
}
#input-search-results::-webkit-scrollbar {
@ -43,6 +92,40 @@
#input-search-results-inner-container {
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--size-2-2);
}
.input-search-result-container {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: auto 1fr;
border-style: solid;
border-radius: var(--size-2-2);
border-color: var(--background-primary-alt);
border-width: 1px;
padding: var(--size-2-2) var(--size-4-2);
}
.input-search-result-icon {
grid-row: 1 / 3;
grid-column: 1;
display: flex;
align-items: center;
justify-content: center;
padding-right: var(--size-4-2);
}
.input-search-result-title {
grid-row: 1;
grid-column: 2;
font-family: var(--font-interface-theme);
}
.input-search-result-subtitle {
grid-row: 2;
grid-column: 2;
font-family: var(--font-interface-theme);
font-size: var(--font-smallest);
color: var(--text-muted);
}
</style>

View file

@ -22,9 +22,7 @@ export class UserInputService {
public performSearch() {
const state = get(this.searchStateStore.searchState);
if (!state.active ||
state.trigger == null ||
state.query.trim().length < 3) {
if (!state.active || state.trigger == null) {
this.searchStateStore.setResults([]);
return;
}

View file

@ -29,12 +29,20 @@ export class VaultCacheService {
private preparedFiles: { prepared: Fuzzysort.Prepared, file: TFile }[] = [];
private preparedFolders: { prepared: Fuzzysort.Prepared, folder: TFolder }[] = [];
private initialised = false;
public constructor() {
this.plugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
this.vaultService = Resolve<VaultService>(Services.VaultService);
this.metaDataCache = this.plugin.app.metadataCache;
this.setupCaches();
this.registerFileEvents();
this.plugin.app.metadataCache.on("resolved", () => {
if (!this.initialised) {
this.setupCaches();
this.initialised = true;
}
});
}
public matchTag(input: string): Fuzzysort.KeyResults<{ prepared: Fuzzysort.Prepared, tag: string }> {

View file

@ -132,10 +132,14 @@ describe('UserInputService', () => {
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#ab' }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("ab");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#ab']);
});
it('should set empty results when query is empty', () => {
@ -148,10 +152,12 @@ describe('UserInputService', () => {
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
});
it('should set empty results when trimmed query length is less than 3', () => {
@ -164,10 +170,12 @@ describe('UserInputService', () => {
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith(" a ");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
});
it('should proceed with search when query length is exactly 3', () => {
@ -578,10 +586,12 @@ describe('UserInputService', () => {
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith(" ");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
});
it('should preserve query with internal spaces', () => {

View file

@ -54,7 +54,8 @@ vi.mock('fuzzysort', () => {
// Create mock instances
const mockMetadataCache = {
getCache: vi.fn()
getCache: vi.fn(),
on: vi.fn()
};
const mockVault = {
@ -152,16 +153,42 @@ describe('VaultCacheService - Integration Tests', () => {
fileEventHandler = handler;
});
let resolvedHandler: any = null;
const mockMetadataCacheWithHandler = {
getCache: mockMetadataCache.getCache,
on: vi.fn((event: string, handler: any) => {
if (event === 'resolved') {
resolvedHandler = handler;
}
})
};
const mockPluginWithMetadata = {
app: {
vault: mockVault,
metadataCache: mockMetadataCacheWithHandler
},
settings: {
exclusions: []
},
registerEvent: vi.fn()
};
const mockVaultServiceWithContent = {
registerFileEvents: mockRegisterEvents,
listVaultContents: mockListVaultContents
};
RegisterSingleton(Services.AIAgentPlugin, mockPluginWithMetadata as any);
RegisterSingleton(Services.VaultService, mockVaultServiceWithContent as any);
// Create new instance to trigger initialization
new VaultCacheService();
// Trigger the resolved event
expect(resolvedHandler).toBeDefined();
resolvedHandler();
expect(mockListVaultContents).toHaveBeenCalled();
});
});