feat: add CMDK quick search modal with progressive rendering

- Double-tap configurable modifier key (default: Shift) to invoke
  a SuggestModal-based quick search with real-time file preview
- Progressive heading search via chooser.addSuggestion() and batched
  setTimeout, using metadataCache for zero I/O
- File navigation via setViewState with heading subpath support
- Settings tab for trigger key selection and double-tap interval
- Fix inputEl timing race with retry logic and optional chaining
- Fix extra view creation when defaultViewType is window
- Add auto-preview on ArrowUp/ArrowDown in FloatSearchModal
This commit is contained in:
Quorafind 2026-03-11 14:31:07 +08:00
parent 8953f2175d
commit 3896bcfc34
2 changed files with 511 additions and 21 deletions

View file

@ -11,11 +11,16 @@ import {
OpenViewState,
PaneType,
Plugin,
prepareFuzzySearch,
renderResults,
SearchResult,
requireApiVersion,
Scope,
SearchView,
setIcon,
PluginSettingTab,
Setting,
SuggestModal,
TAbstractFile,
TFile,
ViewStateResult,
@ -53,11 +58,15 @@ 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;
}
const DEFAULT_SETTINGS: FloatSearchSettings = {
@ -72,6 +81,8 @@ const DEFAULT_SETTINGS: FloatSearchSettings = {
showFilePath: false,
showInstructions: true,
defaultViewType: "modal",
cmdkTriggerKey: "Shift",
cmdkDoubleTapInterval: 300,
};
const allViews: viewType[] = [
@ -127,6 +138,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 +180,7 @@ export default class FloatSearchPlugin extends Plugin {
this.patchSearchView();
this.patchVchildren();
this.patchDragManager();
this.registerDoubleKeyHandler();
});
this.registerObsidianURIHandler();
@ -194,6 +207,7 @@ export default class FloatSearchPlugin extends Plugin {
}
);
this.updateFilePathVisibility();
this.addSettingTab(new FloatSearchSettingTab(this.app, this));
}
onunload() {
@ -201,6 +215,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 +1320,66 @@ 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();
});
});
}
}
function createInstructionElement(
parentEl: HTMLElement,
divCls: string,
@ -1305,6 +1420,10 @@ class FloatSearchModal extends Modal {
private focusdItem: any;
private debouncedAutoPreview = debounce(() => {
this.autoPreviewFocusedItem();
}, 150);
constructor(
cb: (state: any) => void,
plugin: FloatSearchPlugin,
@ -1422,30 +1541,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 +1592,7 @@ class FloatSearchModal extends Modal {
} else {
currentView.onKeyArrowDownInFocus(e);
this.focusdItem = currentView.dom.focusedItem;
this.debouncedAutoPreview();
}
break;
case "ArrowUp":
@ -1487,6 +1614,7 @@ class FloatSearchModal extends Modal {
if (!currentView.dom.focusedItem.content) {
this.focusdItem = undefined;
}
this.debouncedAutoPreview();
}
break;
case "ArrowLeft":
@ -1565,7 +1693,7 @@ class FloatSearchModal extends Modal {
setTimeout(() => {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}, 0);
}
}
@ -1589,6 +1717,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 +1804,7 @@ class FloatSearchModal extends Modal {
this.initFileView(file, undefined);
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
}
@ -1686,7 +1838,7 @@ class FloatSearchModal extends Modal {
setTimeout(() => {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}, 0);
}
@ -1705,7 +1857,7 @@ class FloatSearchModal extends Modal {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
if (e.key === "Tab" && e.ctrlKey) {
@ -1714,7 +1866,7 @@ class FloatSearchModal extends Modal {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
};
@ -1734,6 +1886,278 @@ 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;
type: "file" | "heading";
}
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;
});
// Render file results immediately
chooser.setSuggestions(fileResults.slice(0, this.limit));
// Phase 2: heading search — progressive, batched via setTimeout
if (query.trim().length >= 2) {
this.progressiveHeadingSearch(
files,
fuzzy,
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);
}
renderSuggestion(result: CmdkResult, el: HTMLElement) {
el.addClass("mod-complex");
const contentEl = el.createDiv("suggestion-content");
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
) {
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) {
// Use subpath to jump to heading
eState.subpath = "#" + result.heading;
}
leaf.setViewState({
type: result.file.extension === "pdf" ? "pdf" : "markdown",
state: { file: result.file.path },
active: true,
}).then(() => {
if (eState.subpath) {
// Apply eState after view is ready
leaf.setEphemeralState(eState);
}
});
}
// Called by internal Chooser on selection change
// @ts-ignore
onSelectedChange = debounce(
(result: CmdkResult, _evt: Event | null) => {
if (result?.file) this.showPreview(result.file);
},
100
);
async showPreview(file: TFile) {
if (!this.previewEl) {
this.previewEl = this.bodyEl.createDiv(
"float-search-cmdk-preview"
);
this.modalEl.addClass("float-search-cmdk-expanded");
const [leaf, view] = spawnLeafView(
this.plugin,
this.previewEl
);
this.fileLeaf = leaf;
this.fileEmbeddedView = view;
this.fileLeaf.setPinned(true);
}
await this.fileLeaf!.openFile(file, { active: false });
this.inputEl.focus();
}
onClose() {
this.fileLeaf?.detach();
this.fileEmbeddedView?.unload();
}
}

View file

@ -9,6 +9,7 @@ If your plugin does not need CSS, delete this file.
.float-search-modal {
width: 700px;
padding-bottom: 0;
transition: width 0.15s ease;
}
.float-search-modal.float-search-width {
@ -147,3 +148,68 @@ body:not(.show-file-path) .search-result-file-title .search-result-file-path {
.fs-leaf-view .workspace-split {
background-color: var(--background-primary);
}
/* ── CMDK Modal ── */
.prompt.float-search-cmdk {
width: 700px;
max-width: 90vw;
transition: width 0.15s ease;
}
.prompt.float-search-cmdk.float-search-cmdk-expanded {
width: 1100px;
}
.float-search-cmdk-body {
display: flex;
flex-direction: row;
height: 50vh;
max-height: 600px;
overflow: hidden;
}
.float-search-cmdk-body .prompt-results {
max-height: none;
height: 100%;
overflow-y: auto;
flex: 1 1 auto;
}
.float-search-cmdk-expanded .float-search-cmdk-body .prompt-results {
flex: 0 0 40%;
border-right: 1px solid var(--background-modifier-border);
}
.float-search-cmdk-preview {
flex: 1;
overflow: hidden;
height: 100%;
}
.float-search-cmdk-preview .view-header {
display: none;
}
.float-search-cmdk-preview .view-content {
height: 100%;
}
.float-search-cmdk-preview .fs-block {
height: 100% !important;
}
.float-search-cmdk-preview .fs-content {
height: 100%;
}
.float-search-cmdk-preview .fs-content .workspace-split.mod-vertical {
height: 100%;
}
.float-search-cmdk-preview .workspace-leaf-resize-handle {
display: none;
}
.modal-container.float-search-cmdk-container.mod-dim {
z-index: 30;
}