Add full-text search

- Index notes in a separate, rebuildable SQLite database (index.db) using FTS5 trigram,
  synced from watcher snapshots so external edits are picked up; terms under 3 characters
  fall back to LIKE
- GET /api/v1/search?q= returns matching notes with the first matching line and a snippet;
  path matches rank first
- Sidebar search panel (Ctrl+Shift+F) with highlighted terms and keyboard navigation;
  opening a result jumps to and selects the match
- Restore scroll position on tab switch with scroll snapshots; restoring scrollTop drifted
  by a few lines after line heights were re-estimated
This commit is contained in:
eezd committed 2026-10-16 20:17:24 +08:00
1 parent a11e0ec498
commit 55f0ee174e
13 files changed
+792 -20

No files matched your search

+60 -3
View File
@@ -1,8 +1,8 @@
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { reactive, watch } from 'vue'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor'
import { api, ApiError, type Entry } from './api'
import { drafts } from './drafts'
@@ -37,6 +37,8 @@ export const ws = reactive({
renaming: null as string | null,
// 移动端侧栏抽屉
sidebarOpen: false,
// 侧栏显示搜索而非目录树
searchOpen: false,
toasts: [] as Toast[],
})
@@ -277,6 +279,45 @@ export async function keepLocalVersion(path: string) {
}
}
// ---- 搜索结果跳转 ----
interface RevealTarget {
path: string
line: number
terms: string[]
}
// 待定位的位置:笔记可能尚未载入,由编辑器面板在显示该笔记时执行
let pendingReveal: RevealTarget | null = null
/** 打开笔记并定位到第 line 行(从 1 开始,0 表示不定位),选中该行第一个搜索词 */
export async function openAt(path: string, line: number, terms: string[]) {
pendingReveal = line > 0 ? { path, line, terms } : null
if (pendingReveal && path === ws.active && view && editorStates.has(path)) {
ws.sidebarOpen = false
revealPending(view, path)
} else await openNote(path)
}
/** 编辑器面板显示笔记后调用;返回 true 表示已定位(不应再恢复原来的滚动位置) */
export function revealPending(v: EditorView, path: string): boolean {
const r = pendingReveal
if (!r || r.path !== path) return false
pendingReveal = null
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
const text = line.text.toLowerCase()
let at = -1
let len = 0
for (const t of r.terms) {
const i = text.indexOf(t.toLowerCase())
if (i >= 0 && (at < 0 || i < at)) [at, len] = [i, t.length]
}
const from = line.from + Math.max(at, 0)
v.dispatch({ selection: { anchor: from, head: from + len }, effects: EditorView.scrollIntoView(from, { y: 'center' }) })
// 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
if (!matchMedia('(pointer: coarse)').matches) v.focus()
return true
}
// ---- 标签页 ----
export async function openNote(path: string) {
@@ -606,13 +647,29 @@ export async function startWorkspace() {
})
window.addEventListener('pagehide', () => void flushAll(true))
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
if (!(e.ctrlKey || e.metaKey)) return
const key = e.key.toLowerCase()
if (key === 's' && !e.shiftKey) {
e.preventDefault()
if (ws.active) void flush(ws.active)
} else if (key === 'f' && e.shiftKey) {
e.preventDefault()
openSearch()
}
})
}
export function openSearch() {
ws.searchOpen = true
ws.sidebarOpen = true
// 已打开时重新聚焦并全选,便于直接输入新的搜索词
void nextTick(() => {
const input = document.querySelector<HTMLInputElement>('.rm-search-input')
input?.focus()
input?.select()
})
}
export function stopWorkspace() {
disconnect?.()
disconnect = undefined