mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-09 05:39:12 +00:00
Enhance Khoj plugin search functionality and loading indicators
- Added visual loading indicators to the search modal for improved user experience during search operations. - Implemented logic to check if search results correspond to files in the vault, with color-coded results for better clarity. - Refactored the getSuggestions method to handle loading states and abort previous requests if necessary. - Updated CSS styles to support new loading animations and result file status indicators. - Improved the renderSuggestion method to display file status and provide feedback for files not in the vault.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { App, SuggestModal, request, MarkdownRenderer, Instruction, Platform } from 'obsidian';
|
import { App, SuggestModal, request, MarkdownRenderer, Instruction, Platform, Notice } from 'obsidian';
|
||||||
import { KhojSetting } from 'src/settings';
|
import { KhojSetting } from 'src/settings';
|
||||||
import { supportedBinaryFileTypes, createNoteAndCloseModal, getFileFromPath, getLinkToEntry, supportedImageFilesTypes } from 'src/utils';
|
import { supportedBinaryFileTypes, createNoteAndCloseModal, getFileFromPath, getLinkToEntry, supportedImageFilesTypes } from 'src/utils';
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
find_similar_notes: boolean;
|
find_similar_notes: boolean;
|
||||||
query: string = "";
|
query: string = "";
|
||||||
app: App;
|
app: App;
|
||||||
|
currentController: AbortController | null = null; // Pour annuler les requêtes
|
||||||
|
isLoading: boolean = false;
|
||||||
|
loadingEl: HTMLElement;
|
||||||
|
|
||||||
constructor(app: App, setting: KhojSetting, find_similar_notes: boolean = false) {
|
constructor(app: App, setting: KhojSetting, find_similar_notes: boolean = false) {
|
||||||
super(app);
|
super(app);
|
||||||
@@ -23,6 +26,24 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
// Hide input element in Similar Notes mode
|
// Hide input element in Similar Notes mode
|
||||||
this.inputEl.hidden = this.find_similar_notes;
|
this.inputEl.hidden = this.find_similar_notes;
|
||||||
|
|
||||||
|
// Create loading element
|
||||||
|
this.loadingEl = createDiv({ cls: "search-loading" });
|
||||||
|
const spinnerEl = this.loadingEl.createDiv({ cls: "search-loading-spinner" });
|
||||||
|
|
||||||
|
this.loadingEl.style.position = "absolute";
|
||||||
|
this.loadingEl.style.top = "50%";
|
||||||
|
this.loadingEl.style.left = "50%";
|
||||||
|
this.loadingEl.style.transform = "translate(-50%, -50%)";
|
||||||
|
this.loadingEl.style.zIndex = "1000";
|
||||||
|
this.loadingEl.style.display = "none";
|
||||||
|
|
||||||
|
// Ajouter l'élément au modal
|
||||||
|
this.modalEl.appendChild(this.loadingEl);
|
||||||
|
|
||||||
|
// Customize empty state message
|
||||||
|
// @ts-ignore - Accès à la propriété privée pour personnaliser le message
|
||||||
|
this.emptyStateText = "";
|
||||||
|
|
||||||
// Register Modal Keybindings to Rerank Results
|
// Register Modal Keybindings to Rerank Results
|
||||||
this.scope.register(['Mod'], 'Enter', async () => {
|
this.scope.register(['Mod'], 'Enter', async () => {
|
||||||
// Re-rank when explicitly triggered by user
|
// Re-rank when explicitly triggered by user
|
||||||
@@ -66,6 +87,101 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
this.setPlaceholder('Search with Khoj...');
|
this.setPlaceholder('Search with Khoj...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the file exists in the vault
|
||||||
|
private isFileInVault(filePath: string): boolean {
|
||||||
|
// Normalize the path to handle different separators
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Check if the file exists in the vault
|
||||||
|
return this.app.vault.getFiles().some(file =>
|
||||||
|
file.path === normalizedPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSuggestions(query: string): Promise<SearchResult[]> {
|
||||||
|
// Ne pas afficher le chargement si la requête est vide
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.updateLoadingState();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
this.isLoading = true;
|
||||||
|
this.updateLoadingState();
|
||||||
|
|
||||||
|
// Cancel previous request if it exists
|
||||||
|
if (this.currentController) {
|
||||||
|
this.currentController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new controller for this request
|
||||||
|
this.currentController = new AbortController();
|
||||||
|
|
||||||
|
// Setup Query Khoj backend for search results
|
||||||
|
let encodedQuery = encodeURIComponent(query);
|
||||||
|
let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=${this.rerank}&client=obsidian`;
|
||||||
|
let headers = {
|
||||||
|
'Authorization': `Bearer ${this.setting.khojApiKey}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get search results from Khoj backend
|
||||||
|
const response = await fetch(searchUrl, {
|
||||||
|
headers: headers,
|
||||||
|
signal: this.currentController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Parse search results
|
||||||
|
let results = data
|
||||||
|
.filter((result: any) =>
|
||||||
|
!this.find_similar_notes || !result.additional.file.endsWith(this.app.workspace.getActiveFile()?.path)
|
||||||
|
)
|
||||||
|
.map((result: any) => {
|
||||||
|
return {
|
||||||
|
entry: result.entry,
|
||||||
|
file: result.additional.file,
|
||||||
|
inVault: this.isFileInVault(result.additional.file)
|
||||||
|
} as SearchResult & { inVault: boolean };
|
||||||
|
})
|
||||||
|
.sort((a: SearchResult & { inVault: boolean }, b: SearchResult & { inVault: boolean }) => {
|
||||||
|
if (a.inVault === b.inVault) return 0;
|
||||||
|
return a.inVault ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.query = query;
|
||||||
|
|
||||||
|
// Hide loading state only on successful completion
|
||||||
|
this.isLoading = false;
|
||||||
|
this.updateLoadingState();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore cancellation errors and keep loading state
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
// When cancelling, we don't want to render anything
|
||||||
|
return undefined as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, hide loading state
|
||||||
|
console.error('Search error:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
this.updateLoadingState();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLoadingState() {
|
||||||
|
// Show or hide loading element
|
||||||
|
this.loadingEl.style.display = this.isLoading ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
async onOpen() {
|
async onOpen() {
|
||||||
if (this.find_similar_notes) {
|
if (this.find_similar_notes) {
|
||||||
// If markdown file is currently active
|
// If markdown file is currently active
|
||||||
@@ -86,25 +202,7 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSuggestions(query: string): Promise<SearchResult[]> {
|
async renderSuggestion(result: SearchResult & { inVault: boolean }, el: HTMLElement) {
|
||||||
// Setup Query Khoj backend for search results
|
|
||||||
let encodedQuery = encodeURIComponent(query);
|
|
||||||
let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=${this.rerank}&client=obsidian`;
|
|
||||||
let headers = { 'Authorization': `Bearer ${this.setting.khojApiKey}` }
|
|
||||||
|
|
||||||
// Get search results from Khoj backend
|
|
||||||
let response = await request({ url: `${searchUrl}`, headers: headers });
|
|
||||||
|
|
||||||
// Parse search results
|
|
||||||
let results = JSON.parse(response)
|
|
||||||
.filter((result: any) => !this.find_similar_notes || !result.additional.file.endsWith(this.app.workspace.getActiveFile()?.path))
|
|
||||||
.map((result: any) => { return { entry: result.entry, file: result.additional.file } as SearchResult; });
|
|
||||||
|
|
||||||
this.query = query;
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderSuggestion(result: SearchResult, el: HTMLElement) {
|
|
||||||
// Max number of lines to render
|
// Max number of lines to render
|
||||||
let lines_to_render = 8;
|
let lines_to_render = 8;
|
||||||
|
|
||||||
@@ -112,13 +210,25 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
let os_path_separator = result.file.includes('\\') ? '\\' : '/';
|
let os_path_separator = result.file.includes('\\') ? '\\' : '/';
|
||||||
let filename = result.file.split(os_path_separator).pop();
|
let filename = result.file.split(os_path_separator).pop();
|
||||||
|
|
||||||
// Show filename of each search result for context
|
// Show filename of each search result for context with appropriate color
|
||||||
el.createEl("div",{ cls: 'khoj-result-file' }).setText(filename ?? "");
|
const fileEl = el.createEl("div", {
|
||||||
|
cls: `khoj-result-file ${result.inVault ? 'in-vault' : 'not-in-vault'}`
|
||||||
|
});
|
||||||
|
fileEl.setText(filename ?? "");
|
||||||
|
|
||||||
|
// Add a visual indication for files not in vault
|
||||||
|
if (!result.inVault) {
|
||||||
|
fileEl.createSpan({
|
||||||
|
text: " (not in vault)",
|
||||||
|
cls: "khoj-result-file-status"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let result_el = el.createEl("div", { cls: 'khoj-result-entry' })
|
let result_el = el.createEl("div", { cls: 'khoj-result-entry' })
|
||||||
|
|
||||||
let resultToRender = "";
|
let resultToRender = "";
|
||||||
let fileExtension = filename?.split(".").pop() ?? "";
|
let fileExtension = filename?.split(".").pop() ?? "";
|
||||||
if (supportedImageFilesTypes.includes(fileExtension) && filename) {
|
if (supportedImageFilesTypes.includes(fileExtension) && filename && result.inVault) {
|
||||||
let linkToEntry: string = filename;
|
let linkToEntry: string = filename;
|
||||||
let imageFiles = this.app.vault.getFiles().filter(file => supportedImageFilesTypes.includes(fileExtension));
|
let imageFiles = this.app.vault.getFiles().filter(file => supportedImageFilesTypes.includes(fileExtension));
|
||||||
// Find vault file of chosen search result
|
// Find vault file of chosen search result
|
||||||
@@ -140,7 +250,13 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
|
|||||||
MarkdownRenderer.renderMarkdown(resultToRender, result_el, result.file, null);
|
MarkdownRenderer.renderMarkdown(resultToRender, result_el, result.file, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onChooseSuggestion(result: SearchResult, _: MouseEvent | KeyboardEvent) {
|
async onChooseSuggestion(result: SearchResult & { inVault: boolean }, _: MouseEvent | KeyboardEvent) {
|
||||||
|
// Only open files that are in the vault
|
||||||
|
if (!result.inVault) {
|
||||||
|
new Notice("This file is not in your vault");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all markdown, pdf and image files in vault
|
// Get all markdown, pdf and image files in vault
|
||||||
const mdFiles = this.app.vault.getMarkdownFiles();
|
const mdFiles = this.app.vault.getMarkdownFiles();
|
||||||
const binaryFiles = this.app.vault.getFiles().filter(file => supportedBinaryFileTypes.includes(file.extension));
|
const binaryFiles = this.app.vault.getFiles().filter(file => supportedBinaryFileTypes.includes(file.extension));
|
||||||
|
|||||||
@@ -443,6 +443,14 @@ div.selected-conversation {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.khoj-result-file.in-vault {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.khoj-result-file.not-in-vault {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
.khoj-result-entry {
|
.khoj-result-entry {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: 2em;
|
margin-left: 2em;
|
||||||
@@ -804,3 +812,50 @@ img.copy-icon {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animation de chargement */
|
||||||
|
.khoj-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.khoj-loading-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--background-modifier-border);
|
||||||
|
border-top: 3px solid var(--text-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: khoj-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes khoj-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Research Spinner */
|
||||||
|
.search-loading-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--background-modifier-border);
|
||||||
|
border-top: 3px solid var(--text-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: search-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes search-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user