mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
063dee3057
commit
63bcfdeae9
6 changed files with 144 additions and 18 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue