Skip to content

Commit 85c34a5

Browse files
authored
Merge pull request #1018 from hjamet/master
This PR delivers comprehensive improvements to the Khoj plugin across multiple key areas: 🔍 Search Enhancements: - Added visual loading indicators during search operations - Implemented color-coded results to distinguish between vault and external files - Added abort logic for previous requests to improve performance - Enhanced search feedback with clear status indicators - Improved empty state handling 🔄 Synchronization Improvements: - Added configurable sync interval setting in minutes - Implemented manual "Sync new changes" command - Enhanced sync timer management with automatic restart - Improved notification system for sync operations 📁 Folder Management: - Added granular folder selection for sync - Implemented intuitive folder suggestion modal - Enhanced folder list visualization 💅 UI/UX Improvements: - Added loading animations and spinners - Enhanced search results visualization with color coding - Refined chat interface styling - Improved overall settings panel organization 🔧 Technical Improvements: - Refactored search and synchronization logic - Implemented proper request cancellation - Enhanced error handling and user feedback - Improved code organization and maintainability
2 parents 6e0c767 + f42b0cb commit 85c34a5

File tree

5 files changed

+566
-92
lines changed

5 files changed

+566
-92
lines changed

src/interface/obsidian/src/main.ts

+39-4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ export default class Khoj extends Plugin {
3434
callback: () => { this.activateView(KhojView.CHAT); }
3535
});
3636

37+
// Add sync command to manually sync new changes
38+
this.addCommand({
39+
id: 'sync',
40+
name: 'Sync new changes',
41+
callback: async () => {
42+
this.settings.lastSync = await updateContentIndex(
43+
this.app.vault,
44+
this.settings,
45+
this.settings.lastSync,
46+
false,
47+
true
48+
);
49+
}
50+
});
51+
3752
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
3853

3954
// Create an icon in the left ribbon.
@@ -44,12 +59,32 @@ export default class Khoj extends Plugin {
4459
// Add a settings tab so the user can configure khoj
4560
this.addSettingTab(new KhojSettingTab(this.app, this));
4661

47-
// Add scheduled job to update index every 60 minutes
62+
// Start the sync timer
63+
this.startSyncTimer();
64+
}
65+
66+
// Method to start the sync timer
67+
private startSyncTimer() {
68+
// Clean up the old timer if it exists
69+
if (this.indexingTimer) {
70+
clearInterval(this.indexingTimer);
71+
}
72+
73+
// Start a new timer with the configured interval
4874
this.indexingTimer = setInterval(async () => {
4975
if (this.settings.autoConfigure) {
50-
this.settings.lastSync = await updateContentIndex(this.app.vault, this.settings, this.settings.lastSync);
76+
this.settings.lastSync = await updateContentIndex(
77+
this.app.vault,
78+
this.settings,
79+
this.settings.lastSync
80+
);
5181
}
52-
}, 60 * 60 * 1000);
82+
}, this.settings.syncInterval * 60 * 1000); // Convert minutes to milliseconds
83+
}
84+
85+
// Public method to restart the timer (called from settings)
86+
public restartSyncTimer() {
87+
this.startSyncTimer();
5388
}
5489

5590
async loadSettings() {
@@ -62,7 +97,7 @@ export default class Khoj extends Plugin {
6297
}
6398

6499
async saveSettings() {
65-
this.saveData(this.settings);
100+
await this.saveData(this.settings);
66101
}
67102

68103
async onunload() {

src/interface/obsidian/src/search_modal.ts

+140-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { App, SuggestModal, request, MarkdownRenderer, Instruction, Platform } from 'obsidian';
1+
import { App, SuggestModal, request, MarkdownRenderer, Instruction, Platform, Notice } from 'obsidian';
22
import { KhojSetting } from 'src/settings';
33
import { supportedBinaryFileTypes, createNoteAndCloseModal, getFileFromPath, getLinkToEntry, supportedImageFilesTypes } from 'src/utils';
44

@@ -13,6 +13,9 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
1313
find_similar_notes: boolean;
1414
query: string = "";
1515
app: App;
16+
currentController: AbortController | null = null; // To cancel requests
17+
isLoading: boolean = false;
18+
loadingEl: HTMLElement;
1619

1720
constructor(app: App, setting: KhojSetting, find_similar_notes: boolean = false) {
1821
super(app);
@@ -23,6 +26,24 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
2326
// Hide input element in Similar Notes mode
2427
this.inputEl.hidden = this.find_similar_notes;
2528

29+
// Create loading element
30+
this.loadingEl = createDiv({ cls: "search-loading" });
31+
const spinnerEl = this.loadingEl.createDiv({ cls: "search-loading-spinner" });
32+
33+
this.loadingEl.style.position = "absolute";
34+
this.loadingEl.style.top = "50%";
35+
this.loadingEl.style.left = "50%";
36+
this.loadingEl.style.transform = "translate(-50%, -50%)";
37+
this.loadingEl.style.zIndex = "1000";
38+
this.loadingEl.style.display = "none";
39+
40+
// Add the element to the modal
41+
this.modalEl.appendChild(this.loadingEl);
42+
43+
// Customize empty state message
44+
// @ts-ignore - Access to private property to customize the message
45+
this.emptyStateText = "";
46+
2647
// Register Modal Keybindings to Rerank Results
2748
this.scope.register(['Mod'], 'Enter', async () => {
2849
// Re-rank when explicitly triggered by user
@@ -66,6 +87,101 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
6687
this.setPlaceholder('Search with Khoj...');
6788
}
6889

90+
// Check if the file exists in the vault
91+
private isFileInVault(filePath: string): boolean {
92+
// Normalize the path to handle different separators
93+
const normalizedPath = filePath.replace(/\\/g, '/');
94+
95+
// Check if the file exists in the vault
96+
return this.app.vault.getFiles().some(file =>
97+
file.path === normalizedPath
98+
);
99+
}
100+
101+
async getSuggestions(query: string): Promise<SearchResult[]> {
102+
// Do not show loading if the query is empty
103+
if (!query.trim()) {
104+
this.isLoading = false;
105+
this.updateLoadingState();
106+
return [];
107+
}
108+
109+
// Show loading state
110+
this.isLoading = true;
111+
this.updateLoadingState();
112+
113+
// Cancel previous request if it exists
114+
if (this.currentController) {
115+
this.currentController.abort();
116+
}
117+
118+
try {
119+
// Create a new controller for this request
120+
this.currentController = new AbortController();
121+
122+
// Setup Query Khoj backend for search results
123+
let encodedQuery = encodeURIComponent(query);
124+
let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=${this.rerank}&client=obsidian`;
125+
let headers = {
126+
'Authorization': `Bearer ${this.setting.khojApiKey}`,
127+
}
128+
129+
// Get search results from Khoj backend
130+
const response = await fetch(searchUrl, {
131+
headers: headers,
132+
signal: this.currentController.signal
133+
});
134+
135+
if (!response.ok) {
136+
throw new Error(`HTTP error! status: ${response.status}`);
137+
}
138+
139+
const data = await response.json();
140+
141+
// Parse search results
142+
let results = data
143+
.filter((result: any) =>
144+
!this.find_similar_notes || !result.additional.file.endsWith(this.app.workspace.getActiveFile()?.path)
145+
)
146+
.map((result: any) => {
147+
return {
148+
entry: result.entry,
149+
file: result.additional.file,
150+
inVault: this.isFileInVault(result.additional.file)
151+
} as SearchResult & { inVault: boolean };
152+
})
153+
.sort((a: SearchResult & { inVault: boolean }, b: SearchResult & { inVault: boolean }) => {
154+
if (a.inVault === b.inVault) return 0;
155+
return a.inVault ? -1 : 1;
156+
});
157+
158+
this.query = query;
159+
160+
// Hide loading state only on successful completion
161+
this.isLoading = false;
162+
this.updateLoadingState();
163+
164+
return results;
165+
} catch (error) {
166+
// Ignore cancellation errors and keep loading state
167+
if (error.name === 'AbortError') {
168+
// When cancelling, we don't want to render anything
169+
return undefined as any;
170+
}
171+
172+
// For other errors, hide loading state
173+
console.error('Search error:', error);
174+
this.isLoading = false;
175+
this.updateLoadingState();
176+
return [];
177+
}
178+
}
179+
180+
private updateLoadingState() {
181+
// Show or hide loading element
182+
this.loadingEl.style.display = this.isLoading ? "block" : "none";
183+
}
184+
69185
async onOpen() {
70186
if (this.find_similar_notes) {
71187
// If markdown file is currently active
@@ -86,39 +202,33 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
86202
}
87203
}
88204

89-
async getSuggestions(query: string): Promise<SearchResult[]> {
90-
// Setup Query Khoj backend for search results
91-
let encodedQuery = encodeURIComponent(query);
92-
let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=${this.rerank}&client=obsidian`;
93-
let headers = { 'Authorization': `Bearer ${this.setting.khojApiKey}` }
94-
95-
// Get search results from Khoj backend
96-
let response = await request({ url: `${searchUrl}`, headers: headers });
97-
98-
// Parse search results
99-
let results = JSON.parse(response)
100-
.filter((result: any) => !this.find_similar_notes || !result.additional.file.endsWith(this.app.workspace.getActiveFile()?.path))
101-
.map((result: any) => { return { entry: result.entry, file: result.additional.file } as SearchResult; });
102-
103-
this.query = query;
104-
return results;
105-
}
106-
107-
async renderSuggestion(result: SearchResult, el: HTMLElement) {
205+
async renderSuggestion(result: SearchResult & { inVault: boolean }, el: HTMLElement) {
108206
// Max number of lines to render
109207
let lines_to_render = 8;
110208

111209
// Extract filename of result
112210
let os_path_separator = result.file.includes('\\') ? '\\' : '/';
113211
let filename = result.file.split(os_path_separator).pop();
114212

115-
// Show filename of each search result for context
116-
el.createEl("div",{ cls: 'khoj-result-file' }).setText(filename ?? "");
213+
// Show filename of each search result for context with appropriate color
214+
const fileEl = el.createEl("div", {
215+
cls: `khoj-result-file ${result.inVault ? 'in-vault' : 'not-in-vault'}`
216+
});
217+
fileEl.setText(filename ?? "");
218+
219+
// Add a visual indication for files not in vault
220+
if (!result.inVault) {
221+
fileEl.createSpan({
222+
text: " (not in vault)",
223+
cls: "khoj-result-file-status"
224+
});
225+
}
226+
117227
let result_el = el.createEl("div", { cls: 'khoj-result-entry' })
118228

119229
let resultToRender = "";
120230
let fileExtension = filename?.split(".").pop() ?? "";
121-
if (supportedImageFilesTypes.includes(fileExtension) && filename) {
231+
if (supportedImageFilesTypes.includes(fileExtension) && filename && result.inVault) {
122232
let linkToEntry: string = filename;
123233
let imageFiles = this.app.vault.getFiles().filter(file => supportedImageFilesTypes.includes(fileExtension));
124234
// Find vault file of chosen search result
@@ -140,7 +250,13 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
140250
MarkdownRenderer.renderMarkdown(resultToRender, result_el, result.file, null);
141251
}
142252

143-
async onChooseSuggestion(result: SearchResult, _: MouseEvent | KeyboardEvent) {
253+
async onChooseSuggestion(result: SearchResult & { inVault: boolean }, _: MouseEvent | KeyboardEvent) {
254+
// Only open files that are in the vault
255+
if (!result.inVault) {
256+
new Notice("This file is not in your vault");
257+
return;
258+
}
259+
144260
// Get all markdown, pdf and image files in vault
145261
const mdFiles = this.app.vault.getMarkdownFiles();
146262
const binaryFiles = this.app.vault.getFiles().filter(file => supportedBinaryFileTypes.includes(file.extension));

0 commit comments

Comments
 (0)