|
|
|
|
@ -11,11 +11,17 @@ import {
|
|
|
|
|
OpenViewState,
|
|
|
|
|
PaneType,
|
|
|
|
|
Plugin,
|
|
|
|
|
prepareFuzzySearch,
|
|
|
|
|
prepareSimpleSearch,
|
|
|
|
|
renderResults,
|
|
|
|
|
SearchResult,
|
|
|
|
|
requireApiVersion,
|
|
|
|
|
Scope,
|
|
|
|
|
SearchView,
|
|
|
|
|
setIcon,
|
|
|
|
|
PluginSettingTab,
|
|
|
|
|
Setting,
|
|
|
|
|
SuggestModal,
|
|
|
|
|
TAbstractFile,
|
|
|
|
|
TFile,
|
|
|
|
|
ViewStateResult,
|
|
|
|
|
@ -53,11 +59,18 @@ interface searchState extends Record<string, unknown> {
|
|
|
|
|
current?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CmdkTriggerKey = "Shift" | "Control" | "Alt" | "Meta" | "none";
|
|
|
|
|
|
|
|
|
|
interface FloatSearchSettings {
|
|
|
|
|
searchViewState: searchState;
|
|
|
|
|
showFilePath: boolean;
|
|
|
|
|
showInstructions: boolean;
|
|
|
|
|
defaultViewType: searchType;
|
|
|
|
|
cmdkTriggerKey: CmdkTriggerKey;
|
|
|
|
|
cmdkDoubleTapInterval: number;
|
|
|
|
|
cmdkQuickCreate: boolean;
|
|
|
|
|
cmdkQuickCreateFolder: string;
|
|
|
|
|
cmdkQuickCreateTitleFormat: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SETTINGS: FloatSearchSettings = {
|
|
|
|
|
@ -72,6 +85,11 @@ const DEFAULT_SETTINGS: FloatSearchSettings = {
|
|
|
|
|
showFilePath: false,
|
|
|
|
|
showInstructions: true,
|
|
|
|
|
defaultViewType: "modal",
|
|
|
|
|
cmdkTriggerKey: "Shift",
|
|
|
|
|
cmdkDoubleTapInterval: 300,
|
|
|
|
|
cmdkQuickCreate: false,
|
|
|
|
|
cmdkQuickCreateFolder: "",
|
|
|
|
|
cmdkQuickCreateTitleFormat: "YYYYMMDDHHmmss",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const allViews: viewType[] = [
|
|
|
|
|
@ -127,6 +145,7 @@ export default class FloatSearchPlugin extends Plugin {
|
|
|
|
|
settings: FloatSearchSettings;
|
|
|
|
|
private state: searchState;
|
|
|
|
|
private modal: FloatSearchModal;
|
|
|
|
|
private cmdkModal: FloatSearchCmdkModal;
|
|
|
|
|
|
|
|
|
|
allLoaded: boolean = false;
|
|
|
|
|
queryLoaded: boolean = false;
|
|
|
|
|
@ -168,6 +187,7 @@ export default class FloatSearchPlugin extends Plugin {
|
|
|
|
|
this.patchSearchView();
|
|
|
|
|
this.patchVchildren();
|
|
|
|
|
this.patchDragManager();
|
|
|
|
|
this.registerDoubleKeyHandler();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.registerObsidianURIHandler();
|
|
|
|
|
@ -194,6 +214,7 @@ export default class FloatSearchPlugin extends Plugin {
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
this.updateFilePathVisibility();
|
|
|
|
|
this.addSettingTab(new FloatSearchSettingTab(this.app, this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onunload() {
|
|
|
|
|
@ -201,6 +222,47 @@ export default class FloatSearchPlugin extends Plugin {
|
|
|
|
|
this.modal?.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerDoubleKeyHandler() {
|
|
|
|
|
let lastKeyUp = 0;
|
|
|
|
|
let keyOnly = true;
|
|
|
|
|
|
|
|
|
|
this.registerDomEvent(document, "keydown", (e: KeyboardEvent) => {
|
|
|
|
|
const triggerKey = this.settings.cmdkTriggerKey;
|
|
|
|
|
if (triggerKey === "none" || e.key !== triggerKey) {
|
|
|
|
|
keyOnly = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.registerDomEvent(document, "keyup", (e: KeyboardEvent) => {
|
|
|
|
|
const triggerKey = this.settings.cmdkTriggerKey;
|
|
|
|
|
if (triggerKey === "none") return;
|
|
|
|
|
if (e.key === triggerKey) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (
|
|
|
|
|
keyOnly &&
|
|
|
|
|
now - lastKeyUp < this.settings.cmdkDoubleTapInterval &&
|
|
|
|
|
!document.querySelector(
|
|
|
|
|
".float-search-cmdk-container"
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
lastKeyUp = 0;
|
|
|
|
|
this.openCmdkModal();
|
|
|
|
|
} else {
|
|
|
|
|
lastKeyUp = now;
|
|
|
|
|
keyOnly = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
openCmdkModal() {
|
|
|
|
|
if (this.cmdkModal) {
|
|
|
|
|
this.cmdkModal.close();
|
|
|
|
|
}
|
|
|
|
|
this.cmdkModal = new FloatSearchCmdkModal(this);
|
|
|
|
|
this.cmdkModal.open();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateFilePathVisibility() {
|
|
|
|
|
const { showFilePath } = this.settings;
|
|
|
|
|
document.body.toggleClass("show-file-path", showFilePath);
|
|
|
|
|
@ -1265,6 +1327,114 @@ export default class FloatSearchPlugin extends Plugin {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TRIGGER_KEY_OPTIONS: Record<CmdkTriggerKey, string> = {
|
|
|
|
|
Shift: "Double Shift",
|
|
|
|
|
Control: "Double Ctrl",
|
|
|
|
|
Alt: "Double Alt",
|
|
|
|
|
Meta: "Double Meta (Cmd/Win)",
|
|
|
|
|
none: "Disabled",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class FloatSearchSettingTab extends PluginSettingTab {
|
|
|
|
|
plugin: FloatSearchPlugin;
|
|
|
|
|
|
|
|
|
|
constructor(app: App, plugin: FloatSearchPlugin) {
|
|
|
|
|
super(app, plugin);
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
display() {
|
|
|
|
|
const { containerEl } = this;
|
|
|
|
|
containerEl.empty();
|
|
|
|
|
|
|
|
|
|
containerEl.createEl("h2", { text: "Float Search Settings" });
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Quick search trigger")
|
|
|
|
|
.setDesc(
|
|
|
|
|
"Double-tap this key to open the quick search modal (CMDK)."
|
|
|
|
|
)
|
|
|
|
|
.addDropdown((dropdown) => {
|
|
|
|
|
for (const [value, label] of Object.entries(
|
|
|
|
|
TRIGGER_KEY_OPTIONS
|
|
|
|
|
)) {
|
|
|
|
|
dropdown.addOption(value, label);
|
|
|
|
|
}
|
|
|
|
|
dropdown.setValue(this.plugin.settings.cmdkTriggerKey);
|
|
|
|
|
dropdown.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.cmdkTriggerKey =
|
|
|
|
|
value as CmdkTriggerKey;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Double-tap interval (ms)")
|
|
|
|
|
.setDesc(
|
|
|
|
|
"Maximum time between two key presses to trigger quick search. Default: 300ms."
|
|
|
|
|
)
|
|
|
|
|
.addSlider((slider) => {
|
|
|
|
|
slider
|
|
|
|
|
.setLimits(150, 600, 50)
|
|
|
|
|
.setValue(this.plugin.settings.cmdkDoubleTapInterval)
|
|
|
|
|
.setDynamicTooltip()
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.cmdkDoubleTapInterval =
|
|
|
|
|
value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
containerEl.createEl("h3", { text: "Quick Create" });
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Enable quick create")
|
|
|
|
|
.setDesc(
|
|
|
|
|
"When no exact match is found in quick search, show an option to create a new note with the search text as content."
|
|
|
|
|
)
|
|
|
|
|
.addToggle((toggle) => {
|
|
|
|
|
toggle
|
|
|
|
|
.setValue(this.plugin.settings.cmdkQuickCreate)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.cmdkQuickCreate = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Quick create folder")
|
|
|
|
|
.setDesc(
|
|
|
|
|
"Folder to create new notes in. Leave empty for vault root."
|
|
|
|
|
)
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text.setPlaceholder("e.g. Inbox")
|
|
|
|
|
.setValue(this.plugin.settings.cmdkQuickCreateFolder)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.cmdkQuickCreateFolder =
|
|
|
|
|
value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Title format")
|
|
|
|
|
.setDesc(
|
|
|
|
|
"Timestamp format for the note title. Tokens: YYYY, MM, DD, HH, mm, ss."
|
|
|
|
|
)
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text.setPlaceholder("YYYYMMDDHHmmss")
|
|
|
|
|
.setValue(
|
|
|
|
|
this.plugin.settings.cmdkQuickCreateTitleFormat
|
|
|
|
|
)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.cmdkQuickCreateTitleFormat =
|
|
|
|
|
value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createInstructionElement(
|
|
|
|
|
parentEl: HTMLElement,
|
|
|
|
|
divCls: string,
|
|
|
|
|
@ -1305,6 +1475,10 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
|
|
|
|
|
private focusdItem: any;
|
|
|
|
|
|
|
|
|
|
private debouncedAutoPreview = debounce(() => {
|
|
|
|
|
this.autoPreviewFocusedItem();
|
|
|
|
|
}, 150);
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
cb: (state: any) => void,
|
|
|
|
|
plugin: FloatSearchPlugin,
|
|
|
|
|
@ -1422,30 +1596,37 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
this.searchLeaf.setPinned(true);
|
|
|
|
|
await this.searchLeaf.setViewState({
|
|
|
|
|
type: "search",
|
|
|
|
|
state: this.state,
|
|
|
|
|
state: { ...this.state, triggerBySelf: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
await this.searchLeaf.view.setState(this.state, {
|
|
|
|
|
history: false,
|
|
|
|
|
});
|
|
|
|
|
this.state?.current
|
|
|
|
|
? (
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.setSelectionRange(0, 0)
|
|
|
|
|
: (
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.setSelectionRange(
|
|
|
|
|
0,
|
|
|
|
|
this.state?.query?.length
|
|
|
|
|
);
|
|
|
|
|
await this.searchLeaf.view.setState(
|
|
|
|
|
{ ...this.state, triggerBySelf: true },
|
|
|
|
|
{ history: false }
|
|
|
|
|
);
|
|
|
|
|
const searchComponent = (this.searchLeaf.view as SearchView)
|
|
|
|
|
.searchComponent;
|
|
|
|
|
if (searchComponent?.inputEl) {
|
|
|
|
|
this.state?.current
|
|
|
|
|
? searchComponent.inputEl.setSelectionRange(0, 0)
|
|
|
|
|
: searchComponent.inputEl.setSelectionRange(
|
|
|
|
|
0,
|
|
|
|
|
this.state?.query?.length
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initInput() {
|
|
|
|
|
initInput(retries = 10) {
|
|
|
|
|
const inputEl = this.contentEl.getElementsByTagName("input")[0];
|
|
|
|
|
if (!inputEl) {
|
|
|
|
|
if (retries > 0) {
|
|
|
|
|
setTimeout(() => this.initInput(retries - 1), 50);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
inputEl.focus();
|
|
|
|
|
inputEl.onkeydown = (e) => {
|
|
|
|
|
const currentView = this.searchLeaf.view as SearchView;
|
|
|
|
|
@ -1466,6 +1647,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
} else {
|
|
|
|
|
currentView.onKeyArrowDownInFocus(e);
|
|
|
|
|
this.focusdItem = currentView.dom.focusedItem;
|
|
|
|
|
this.debouncedAutoPreview();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "ArrowUp":
|
|
|
|
|
@ -1487,6 +1669,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
if (!currentView.dom.focusedItem.content) {
|
|
|
|
|
this.focusdItem = undefined;
|
|
|
|
|
}
|
|
|
|
|
this.debouncedAutoPreview();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "ArrowLeft":
|
|
|
|
|
@ -1565,7 +1748,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
(
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.focus();
|
|
|
|
|
).searchComponent?.inputEl?.focus();
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -1589,6 +1772,30 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private autoPreviewFocusedItem() {
|
|
|
|
|
const currentView = this.searchLeaf.view as SearchView;
|
|
|
|
|
const item = currentView.dom?.focusedItem;
|
|
|
|
|
if (!item) return;
|
|
|
|
|
|
|
|
|
|
const file =
|
|
|
|
|
item.parent?.file instanceof TFile
|
|
|
|
|
? item.parent.file
|
|
|
|
|
: item.file;
|
|
|
|
|
if (!(file instanceof TFile)) return;
|
|
|
|
|
|
|
|
|
|
const state =
|
|
|
|
|
item.parent?.file instanceof TFile
|
|
|
|
|
? {
|
|
|
|
|
match: {
|
|
|
|
|
content: item.content,
|
|
|
|
|
matches: item.matches,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
this.initFileView(file, state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initContent() {
|
|
|
|
|
const { contentEl } = this;
|
|
|
|
|
contentEl.onclick = (e) => {
|
|
|
|
|
@ -1652,7 +1859,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
this.initFileView(file, undefined);
|
|
|
|
|
(
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.focus();
|
|
|
|
|
).searchComponent?.inputEl?.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1686,7 +1893,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
(
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.focus();
|
|
|
|
|
).searchComponent?.inputEl?.focus();
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1705,7 +1912,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.focus();
|
|
|
|
|
).searchComponent?.inputEl?.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === "Tab" && e.ctrlKey) {
|
|
|
|
|
@ -1714,7 +1921,7 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
this.searchLeaf.view as SearchView
|
|
|
|
|
).searchComponent.inputEl.focus();
|
|
|
|
|
).searchComponent?.inputEl?.focus();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -1734,6 +1941,512 @@ class FloatSearchModal extends Modal {
|
|
|
|
|
});
|
|
|
|
|
this.fileState = state;
|
|
|
|
|
|
|
|
|
|
(this.searchLeaf.view as SearchView).searchComponent.inputEl.focus();
|
|
|
|
|
(this.searchLeaf.view as SearchView).searchComponent?.inputEl?.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CmdkResult {
|
|
|
|
|
file: TFile;
|
|
|
|
|
nameMatch: SearchResult | null;
|
|
|
|
|
pathMatch: SearchResult | null;
|
|
|
|
|
heading?: string;
|
|
|
|
|
headingMatch?: SearchResult | null;
|
|
|
|
|
content?: string;
|
|
|
|
|
contentMatch?: SearchResult | null;
|
|
|
|
|
line?: number;
|
|
|
|
|
type: "file" | "heading" | "content" | "create";
|
|
|
|
|
createQuery?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
|
|
|
|
|
plugin: FloatSearchPlugin;
|
|
|
|
|
private bodyEl: HTMLElement;
|
|
|
|
|
private previewEl: HTMLElement | undefined;
|
|
|
|
|
private fileLeaf: WorkspaceLeaf | undefined;
|
|
|
|
|
private fileEmbeddedView: EmbeddedView | undefined;
|
|
|
|
|
private searchAbort: AbortController | null = null;
|
|
|
|
|
|
|
|
|
|
constructor(plugin: FloatSearchPlugin) {
|
|
|
|
|
super(plugin.app);
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
this.limit = 50;
|
|
|
|
|
this.setPlaceholder("Search files and content...");
|
|
|
|
|
this.setInstructions([
|
|
|
|
|
{ command: "↑↓", purpose: "Navigate" },
|
|
|
|
|
{ command: "↵", purpose: "Open" },
|
|
|
|
|
{ command: "Shift ↵", purpose: "New tab" },
|
|
|
|
|
{ command: "esc", purpose: "Close" },
|
|
|
|
|
]);
|
|
|
|
|
this.modalEl.addClass("float-search-cmdk");
|
|
|
|
|
this.containerEl.addClass("float-search-cmdk-container");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onOpen() {
|
|
|
|
|
super.onOpen();
|
|
|
|
|
this.bodyEl = createDiv("float-search-cmdk-body");
|
|
|
|
|
this.modalEl.insertBefore(
|
|
|
|
|
this.bodyEl,
|
|
|
|
|
(this as any).resultContainerEl
|
|
|
|
|
);
|
|
|
|
|
this.bodyEl.appendChild((this as any).resultContainerEl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Required by SuggestModal but unused — we drive the chooser directly
|
|
|
|
|
getSuggestions(_query: string): CmdkResult[] {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Override to use progressive rendering via chooser.addSuggestion
|
|
|
|
|
updateSuggestions() {
|
|
|
|
|
// Cancel previous in-flight search
|
|
|
|
|
this.searchAbort?.abort();
|
|
|
|
|
const abort = (this.searchAbort = new AbortController());
|
|
|
|
|
const { signal } = abort;
|
|
|
|
|
|
|
|
|
|
const chooser = (this as any).chooser;
|
|
|
|
|
const query = this.inputEl.value;
|
|
|
|
|
|
|
|
|
|
const files = this.app.vault.getFiles().filter(
|
|
|
|
|
(f: TFile) =>
|
|
|
|
|
f.extension === "md" ||
|
|
|
|
|
f.extension === "canvas" ||
|
|
|
|
|
f.extension === "pdf"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Empty query — show recent files
|
|
|
|
|
if (!query.trim()) {
|
|
|
|
|
const recent = files
|
|
|
|
|
.sort((a: TFile, b: TFile) => b.stat.mtime - a.stat.mtime)
|
|
|
|
|
.slice(0, this.limit)
|
|
|
|
|
.map((file: TFile) => ({
|
|
|
|
|
file,
|
|
|
|
|
nameMatch: null,
|
|
|
|
|
pathMatch: null,
|
|
|
|
|
type: "file" as const,
|
|
|
|
|
}));
|
|
|
|
|
chooser.setSuggestions(recent);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 1: file name/path fuzzy (sync, instant)
|
|
|
|
|
const fuzzy = prepareFuzzySearch(query);
|
|
|
|
|
const fileResults: CmdkResult[] = [];
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
const nameMatch = fuzzy(file.basename);
|
|
|
|
|
const pathMatch = fuzzy(file.path);
|
|
|
|
|
if (nameMatch || pathMatch) {
|
|
|
|
|
fileResults.push({
|
|
|
|
|
file,
|
|
|
|
|
nameMatch,
|
|
|
|
|
pathMatch,
|
|
|
|
|
type: "file",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileResults.sort((a, b) => {
|
|
|
|
|
const sa = Math.max(
|
|
|
|
|
a.nameMatch?.score ?? -Infinity,
|
|
|
|
|
(a.pathMatch?.score ?? -Infinity) * 0.5
|
|
|
|
|
);
|
|
|
|
|
const sb = Math.max(
|
|
|
|
|
b.nameMatch?.score ?? -Infinity,
|
|
|
|
|
(b.pathMatch?.score ?? -Infinity) * 0.5
|
|
|
|
|
);
|
|
|
|
|
return sb - sa;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Check if there is an exact name match (for quick-create gating)
|
|
|
|
|
const queryLower = query.trim().toLowerCase();
|
|
|
|
|
const hasExactMatch = fileResults.some(
|
|
|
|
|
(r) => r.file.basename.toLowerCase() === queryLower
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const resultsToShow = fileResults.slice(0, this.limit);
|
|
|
|
|
|
|
|
|
|
// Append "quick create" option when enabled and no exact match
|
|
|
|
|
if (
|
|
|
|
|
this.plugin.settings.cmdkQuickCreate &&
|
|
|
|
|
!hasExactMatch &&
|
|
|
|
|
query.trim().length > 0
|
|
|
|
|
) {
|
|
|
|
|
resultsToShow.push({
|
|
|
|
|
file: null as any,
|
|
|
|
|
nameMatch: null,
|
|
|
|
|
pathMatch: null,
|
|
|
|
|
type: "create",
|
|
|
|
|
createQuery: query.trim(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render file results immediately
|
|
|
|
|
chooser.setSuggestions(resultsToShow);
|
|
|
|
|
|
|
|
|
|
// Phase 2: heading search — progressive, batched via setTimeout
|
|
|
|
|
if (query.trim().length >= 2) {
|
|
|
|
|
this.progressiveHeadingSearch(
|
|
|
|
|
files,
|
|
|
|
|
fuzzy,
|
|
|
|
|
chooser,
|
|
|
|
|
signal
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Phase 3: content search — progressive, async (reads file content)
|
|
|
|
|
this.progressiveContentSearch(
|
|
|
|
|
files,
|
|
|
|
|
query,
|
|
|
|
|
chooser,
|
|
|
|
|
signal
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private progressiveHeadingSearch(
|
|
|
|
|
files: TFile[],
|
|
|
|
|
fuzzy: (text: string) => SearchResult | null,
|
|
|
|
|
chooser: any,
|
|
|
|
|
signal: AbortSignal
|
|
|
|
|
) {
|
|
|
|
|
const BATCH_SIZE = 50; // files per tick
|
|
|
|
|
let idx = 0;
|
|
|
|
|
let added = 0;
|
|
|
|
|
const MAX_HEADING_RESULTS = 20;
|
|
|
|
|
|
|
|
|
|
const processBatch = () => {
|
|
|
|
|
if (signal.aborted) return;
|
|
|
|
|
|
|
|
|
|
const end = Math.min(idx + BATCH_SIZE, files.length);
|
|
|
|
|
for (; idx < end; idx++) {
|
|
|
|
|
if (added >= MAX_HEADING_RESULTS) return;
|
|
|
|
|
const file = files[idx];
|
|
|
|
|
const cache =
|
|
|
|
|
this.app.metadataCache.getFileCache(file);
|
|
|
|
|
if (!cache?.headings) continue;
|
|
|
|
|
|
|
|
|
|
for (const h of cache.headings) {
|
|
|
|
|
const headingMatch = fuzzy(h.heading);
|
|
|
|
|
if (headingMatch) {
|
|
|
|
|
chooser.addSuggestion({
|
|
|
|
|
file,
|
|
|
|
|
nameMatch: null,
|
|
|
|
|
pathMatch: null,
|
|
|
|
|
heading: h.heading,
|
|
|
|
|
headingMatch,
|
|
|
|
|
type: "heading",
|
|
|
|
|
});
|
|
|
|
|
added++;
|
|
|
|
|
if (added >= MAX_HEADING_RESULTS) return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// More files to process — yield to event loop
|
|
|
|
|
if (idx < files.length && added < MAX_HEADING_RESULTS) {
|
|
|
|
|
setTimeout(processBatch, 0);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Start first batch on next microtask so file results render first
|
|
|
|
|
setTimeout(processBatch, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private progressiveContentSearch(
|
|
|
|
|
files: TFile[],
|
|
|
|
|
query: string,
|
|
|
|
|
chooser: any,
|
|
|
|
|
signal: AbortSignal
|
|
|
|
|
) {
|
|
|
|
|
const BATCH_SIZE = 50;
|
|
|
|
|
const MAX_CONTENT_RESULTS = 20;
|
|
|
|
|
const DURATION_LIMIT = 5; // ms before yielding
|
|
|
|
|
const simpleSearch = prepareSimpleSearch(query);
|
|
|
|
|
let idx = 0;
|
|
|
|
|
let added = 0;
|
|
|
|
|
|
|
|
|
|
const processBatch = async () => {
|
|
|
|
|
if (signal.aborted) return;
|
|
|
|
|
const start = performance.now();
|
|
|
|
|
|
|
|
|
|
for (; idx < files.length; idx++) {
|
|
|
|
|
if (signal.aborted || added >= MAX_CONTENT_RESULTS) return;
|
|
|
|
|
|
|
|
|
|
// Adaptive yielding: check time every BATCH_SIZE items
|
|
|
|
|
if (idx % BATCH_SIZE === 0 && idx > 0) {
|
|
|
|
|
if (performance.now() - start > DURATION_LIMIT) {
|
|
|
|
|
setTimeout(processBatch, 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const file = files[idx];
|
|
|
|
|
if (file.extension !== "md") continue;
|
|
|
|
|
|
|
|
|
|
let text: string;
|
|
|
|
|
try {
|
|
|
|
|
text = await this.app.vault.cachedRead(file);
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = simpleSearch(text);
|
|
|
|
|
if (!result) continue;
|
|
|
|
|
|
|
|
|
|
// Compute line number and context from first match offset
|
|
|
|
|
const matchStart = result.matches[0][0];
|
|
|
|
|
let line = 0;
|
|
|
|
|
let lineStart = 0;
|
|
|
|
|
for (let i = 0; i < matchStart; i++) {
|
|
|
|
|
if (text.charCodeAt(i) === 10) {
|
|
|
|
|
line++;
|
|
|
|
|
lineStart = i + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let lineEnd = text.indexOf("\n", lineStart);
|
|
|
|
|
if (lineEnd === -1) lineEnd = text.length;
|
|
|
|
|
const lineText = text.substring(lineStart, lineEnd).trim();
|
|
|
|
|
|
|
|
|
|
// Recompute match on the line for highlight offsets
|
|
|
|
|
const lineMatch = simpleSearch(lineText);
|
|
|
|
|
|
|
|
|
|
chooser.addSuggestion({
|
|
|
|
|
file,
|
|
|
|
|
nameMatch: null,
|
|
|
|
|
pathMatch: null,
|
|
|
|
|
content: lineText,
|
|
|
|
|
contentMatch: lineMatch,
|
|
|
|
|
line,
|
|
|
|
|
type: "content",
|
|
|
|
|
} as CmdkResult);
|
|
|
|
|
added++;
|
|
|
|
|
if (added >= MAX_CONTENT_RESULTS) return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Start after heading search has a chance to render
|
|
|
|
|
setTimeout(processBatch, 50);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderSuggestion(result: CmdkResult, el: HTMLElement) {
|
|
|
|
|
el.addClass("mod-complex");
|
|
|
|
|
|
|
|
|
|
if (result.type === "create") {
|
|
|
|
|
el.addClass("float-search-cmdk-create-item");
|
|
|
|
|
const contentEl = el.createDiv("suggestion-content");
|
|
|
|
|
const titleEl = contentEl.createDiv("suggestion-title");
|
|
|
|
|
titleEl.setText("Create new note");
|
|
|
|
|
const noteEl = contentEl.createDiv("suggestion-note");
|
|
|
|
|
const folder =
|
|
|
|
|
this.plugin.settings.cmdkQuickCreateFolder || "/";
|
|
|
|
|
noteEl.setText(
|
|
|
|
|
`"${result.createQuery}" → ${folder}`
|
|
|
|
|
);
|
|
|
|
|
const auxEl = el.createDiv("suggestion-aux");
|
|
|
|
|
const flair = auxEl.createSpan("suggestion-flair");
|
|
|
|
|
setIcon(flair, "plus");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contentEl = el.createDiv("suggestion-content");
|
|
|
|
|
|
|
|
|
|
if (result.type === "content" && result.content) {
|
|
|
|
|
const titleEl = contentEl.createDiv("suggestion-title");
|
|
|
|
|
if (result.contentMatch) {
|
|
|
|
|
renderResults(titleEl, result.content, result.contentMatch);
|
|
|
|
|
} else {
|
|
|
|
|
titleEl.setText(result.content);
|
|
|
|
|
}
|
|
|
|
|
const noteEl = contentEl.createDiv("suggestion-note");
|
|
|
|
|
noteEl.setText(result.file.path);
|
|
|
|
|
} else if (result.type === "heading" && result.heading) {
|
|
|
|
|
const titleEl = contentEl.createDiv("suggestion-title");
|
|
|
|
|
if (result.headingMatch) {
|
|
|
|
|
renderResults(titleEl, result.heading, result.headingMatch);
|
|
|
|
|
} else {
|
|
|
|
|
titleEl.setText(result.heading);
|
|
|
|
|
}
|
|
|
|
|
const noteEl = contentEl.createDiv("suggestion-note");
|
|
|
|
|
noteEl.setText(result.file.path);
|
|
|
|
|
} else {
|
|
|
|
|
const titleEl = contentEl.createDiv("suggestion-title");
|
|
|
|
|
if (result.nameMatch) {
|
|
|
|
|
renderResults(
|
|
|
|
|
titleEl,
|
|
|
|
|
result.file.basename,
|
|
|
|
|
result.nameMatch
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
titleEl.setText(result.file.basename);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const noteEl = contentEl.createDiv("suggestion-note");
|
|
|
|
|
const parentPath = result.file.parent?.path || "/";
|
|
|
|
|
if (result.pathMatch) {
|
|
|
|
|
renderResults(noteEl, parentPath, result.pathMatch);
|
|
|
|
|
} else {
|
|
|
|
|
noteEl.setText(parentPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.file.extension !== "md") {
|
|
|
|
|
el.createDiv("suggestion-aux")
|
|
|
|
|
.createSpan("suggestion-flair")
|
|
|
|
|
.setText(result.file.extension);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onChooseSuggestion(
|
|
|
|
|
result: CmdkResult,
|
|
|
|
|
evt: MouseEvent | KeyboardEvent
|
|
|
|
|
) {
|
|
|
|
|
if (result.type === "create") {
|
|
|
|
|
this.quickCreateNote(result.createQuery ?? "", evt);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const leaf = Keymap.isModEvent(evt)
|
|
|
|
|
? this.app.workspace.getLeaf("tab")
|
|
|
|
|
: this.app.workspace.getMostRecentLeaf() ??
|
|
|
|
|
this.app.workspace.getLeaf();
|
|
|
|
|
|
|
|
|
|
const eState: Record<string, any> = {};
|
|
|
|
|
if (result.type === "heading" && result.heading) {
|
|
|
|
|
eState.subpath = "#" + result.heading;
|
|
|
|
|
} else if (result.type === "content" && result.line != null) {
|
|
|
|
|
eState.line = result.line;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
leaf.setViewState({
|
|
|
|
|
type: result.file.extension === "pdf" ? "pdf" : "markdown",
|
|
|
|
|
state: { file: result.file.path },
|
|
|
|
|
active: true,
|
|
|
|
|
}).then(() => {
|
|
|
|
|
if (eState.subpath || eState.line != null) {
|
|
|
|
|
leaf.setEphemeralState(eState);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async quickCreateNote(
|
|
|
|
|
content: string,
|
|
|
|
|
evt: MouseEvent | KeyboardEvent
|
|
|
|
|
) {
|
|
|
|
|
const settings = this.plugin.settings;
|
|
|
|
|
const fmt = settings.cmdkQuickCreateTitleFormat || "YYYYMMDDHHmmss";
|
|
|
|
|
const title = this.formatTimestamp(new Date(), fmt);
|
|
|
|
|
|
|
|
|
|
// Resolve target folder
|
|
|
|
|
let folderPath = settings.cmdkQuickCreateFolder.trim();
|
|
|
|
|
if (!folderPath || folderPath === "/") {
|
|
|
|
|
folderPath = "";
|
|
|
|
|
}
|
|
|
|
|
// Ensure folder exists
|
|
|
|
|
if (folderPath) {
|
|
|
|
|
const folder =
|
|
|
|
|
this.app.vault.getAbstractFileByPath(folderPath);
|
|
|
|
|
if (!folder) {
|
|
|
|
|
await this.app.vault.createFolder(folderPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filePath = folderPath
|
|
|
|
|
? `${folderPath}/${title}.md`
|
|
|
|
|
: `${title}.md`;
|
|
|
|
|
|
|
|
|
|
const file = await this.app.vault.create(filePath, content);
|
|
|
|
|
|
|
|
|
|
const leaf = Keymap.isModEvent(evt)
|
|
|
|
|
? this.app.workspace.getLeaf("tab")
|
|
|
|
|
: this.app.workspace.getMostRecentLeaf() ??
|
|
|
|
|
this.app.workspace.getLeaf();
|
|
|
|
|
|
|
|
|
|
await leaf.setViewState({
|
|
|
|
|
type: "markdown",
|
|
|
|
|
state: { file: file.path },
|
|
|
|
|
active: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private formatTimestamp(date: Date, fmt: string): string {
|
|
|
|
|
const pad = (n: number, len = 2) =>
|
|
|
|
|
String(n).padStart(len, "0");
|
|
|
|
|
const tokens: Record<string, string> = {
|
|
|
|
|
YYYY: String(date.getFullYear()),
|
|
|
|
|
YY: String(date.getFullYear()).slice(-2),
|
|
|
|
|
MM: pad(date.getMonth() + 1),
|
|
|
|
|
DD: pad(date.getDate()),
|
|
|
|
|
HH: pad(date.getHours()),
|
|
|
|
|
mm: pad(date.getMinutes()),
|
|
|
|
|
ss: pad(date.getSeconds()),
|
|
|
|
|
};
|
|
|
|
|
let result = fmt;
|
|
|
|
|
// Replace longest tokens first to avoid partial matches
|
|
|
|
|
for (const token of ["YYYY", "YY", "MM", "DD", "HH", "mm", "ss"]) {
|
|
|
|
|
result = result.split(token).join(tokens[token]);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Called by internal Chooser on selection change
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
onSelectedChange = debounce(
|
|
|
|
|
(result: CmdkResult, _evt: Event | null) => {
|
|
|
|
|
if (result?.type === "create" || !result?.file) {
|
|
|
|
|
this.hidePreview();
|
|
|
|
|
} else {
|
|
|
|
|
this.showPreview(result);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
100
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
private hidePreview() {
|
|
|
|
|
if (this.previewEl) {
|
|
|
|
|
this.previewEl.hide();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async showPreview(result: CmdkResult) {
|
|
|
|
|
if (!this.previewEl) {
|
|
|
|
|
this.previewEl = this.bodyEl.createDiv(
|
|
|
|
|
"float-search-cmdk-preview"
|
|
|
|
|
);
|
|
|
|
|
const [leaf, view] = spawnLeafView(
|
|
|
|
|
this.plugin,
|
|
|
|
|
this.previewEl
|
|
|
|
|
);
|
|
|
|
|
this.fileLeaf = leaf;
|
|
|
|
|
this.fileEmbeddedView = view;
|
|
|
|
|
this.fileLeaf.setPinned(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.previewEl.show();
|
|
|
|
|
this.modalEl.addClass("float-search-cmdk-expanded");
|
|
|
|
|
|
|
|
|
|
const file = result.file;
|
|
|
|
|
await this.fileLeaf!.openFile(file, { active: false });
|
|
|
|
|
|
|
|
|
|
const eState: Record<string, any> = {};
|
|
|
|
|
if (result.type === "heading" && result.heading) {
|
|
|
|
|
eState.subpath = "#" + result.heading;
|
|
|
|
|
} else if (result.type === "content" && result.line != null) {
|
|
|
|
|
eState.line = result.line;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (eState.subpath || eState.line != null) {
|
|
|
|
|
this.fileLeaf!.setViewState({
|
|
|
|
|
type: file.extension === "pdf" ? "pdf" : "markdown",
|
|
|
|
|
state: { file: file.path },
|
|
|
|
|
}).then(() => {
|
|
|
|
|
this.fileLeaf?.setEphemeralState(eState);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.inputEl.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onClose() {
|
|
|
|
|
this.fileLeaf?.detach();
|
|
|
|
|
this.fileEmbeddedView?.unload();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|