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:
1 parent
a11e0ec498
commit
55f0ee174e
13 files changed
+792
-20
No files matched your search
@@ -22,7 +22,7 @@ docker run -d --name rawmark \
|
|||||||
|
|
||||||
| 环境变量 | 默认值 | 说明 |
|
| 环境变量 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `RAWMARK_DATA` | `/data` | 数据目录(数据库、默认笔记仓库) |
|
| `RAWMARK_DATA` | `/data` | 数据目录(数据库、可重建的搜索索引 `index.db`、默认笔记仓库) |
|
||||||
| `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 |
|
| `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 |
|
||||||
| `RAWMARK_ADDR` | `:8080` | 监听地址 |
|
| `RAWMARK_ADDR` | `:8080` | 监听地址 |
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
// RawMark 服务入口。配置全部来自环境变量:
|
// RawMark 服务入口。配置全部来自环境变量:
|
||||||
//
|
//
|
||||||
// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db 与默认仓库)
|
// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db、可重建的搜索索引 index.db 与默认仓库)
|
||||||
// RAWMARK_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录
|
// RAWMARK_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录
|
||||||
// RAWMARK_ADDR 监听地址,默认 :8080
|
// RAWMARK_ADDR 监听地址,默认 :8080
|
||||||
package main
|
package main
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"rawmark/internal/api"
|
"rawmark/internal/api"
|
||||||
"rawmark/internal/auth"
|
"rawmark/internal/auth"
|
||||||
"rawmark/internal/events"
|
"rawmark/internal/events"
|
||||||
|
"rawmark/internal/search"
|
||||||
"rawmark/internal/store"
|
"rawmark/internal/store"
|
||||||
"rawmark/internal/vault"
|
"rawmark/internal/vault"
|
||||||
"rawmark/internal/watch"
|
"rawmark/internal/watch"
|
||||||
@@ -80,13 +81,20 @@ func run(addr string) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
idx, err := search.Open(filepath.Join(dataDir, "index.db"), v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer idx.Close()
|
||||||
|
go idx.Run(ctx)
|
||||||
|
|
||||||
hub := events.NewHub()
|
hub := events.NewHub()
|
||||||
if err := watch.Start(ctx, v, hub); err != nil {
|
if err := watch.Start(ctx, v, hub, idx.Update); err != nil {
|
||||||
return fmt.Errorf("启动文件监听失败: %w", err)
|
return fmt.Errorf("启动文件监听失败: %w", err)
|
||||||
}
|
}
|
||||||
go cleanupSessions(ctx, st)
|
go cleanupSessions(ctx, st)
|
||||||
|
|
||||||
srv := api.New(v, am, hub)
|
srv := api.New(v, am, hub, idx)
|
||||||
srv.OnSetup = func() { os.Remove(codeFile) }
|
srv.OnSetup = func() { os.Remove(codeFile) }
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
+16
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"rawmark/internal/auth"
|
"rawmark/internal/auth"
|
||||||
"rawmark/internal/events"
|
"rawmark/internal/events"
|
||||||
|
"rawmark/internal/search"
|
||||||
"rawmark/internal/vault"
|
"rawmark/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,12 +31,13 @@ type Server struct {
|
|||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
auth *auth.Manager
|
auth *auth.Manager
|
||||||
hub *events.Hub
|
hub *events.Hub
|
||||||
|
index *search.Index
|
||||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||||
OnSetup func()
|
OnSetup func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(v *vault.Vault, a *auth.Manager, hub *events.Hub) *Server {
|
func New(v *vault.Vault, a *auth.Manager, hub *events.Hub, index *search.Index) *Server {
|
||||||
return &Server{vault: v, auth: a, hub: hub}
|
return &Server{vault: v, auth: a, hub: hub, index: index}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler(static fs.FS) http.Handler {
|
func (s *Server) Handler(static fs.FS) http.Handler {
|
||||||
@@ -59,6 +61,7 @@ func (s *Server) Handler(static fs.FS) http.Handler {
|
|||||||
"POST /api/v1/trash/{id}/restore": s.trashRestore,
|
"POST /api/v1/trash/{id}/restore": s.trashRestore,
|
||||||
"DELETE /api/v1/trash/{id}": s.trashPurge,
|
"DELETE /api/v1/trash/{id}": s.trashPurge,
|
||||||
"GET /api/v1/events": s.events,
|
"GET /api/v1/events": s.events,
|
||||||
|
"GET /api/v1/search": s.search,
|
||||||
}
|
}
|
||||||
for pattern, h := range protected {
|
for pattern, h := range protected {
|
||||||
mux.Handle(pattern, s.requireAuth(h))
|
mux.Handle(pattern, s.requireAuth(h))
|
||||||
@@ -211,6 +214,17 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]any{"entries": entries})
|
writeJSON(w, 200, map[string]any{"entries": entries})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchLimit = 50
|
||||||
|
|
||||||
|
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
results, err := s.index.Search(r.URL.Query().Get("q"), searchLimit)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
|
||||||
data, rev, err := s.vault.Read(r.PathValue("path"))
|
data, rev, err := s.vault.Read(r.PathValue("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Package search 维护笔记的全文索引。索引存放在独立的数据库文件中,内容完全由仓库文件派生,
|
||||||
|
// 删除后会在下次启动时重建;结构变化时也直接重建而不做迁移。
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"rawmark/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 修改表结构时递增,旧索引会被丢弃重建
|
||||||
|
const schemaVersion = 1
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE docs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
rev TEXT NOT NULL
|
||||||
|
);
|
||||||
|
-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE
|
||||||
|
CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram');
|
||||||
|
`
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxTerms = 8
|
||||||
|
snippetRunes = 160
|
||||||
|
snippetBefore = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
// 第一处匹配所在行(从 1 开始)与该行的摘录;只有文件名匹配时 Line 为 0
|
||||||
|
Line int `json:"line"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type doc struct {
|
||||||
|
id int64
|
||||||
|
rev string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Index struct {
|
||||||
|
db *sql.DB
|
||||||
|
v *vault.Vault
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]string // 待同步的最新快照(路径 -> rev)
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
|
known map[string]doc // 已索引的笔记,仅由同步协程访问
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string, v *vault.Vault) (*Index, error) {
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &Index{db: db, v: v, wake: make(chan struct{}, 1), known: map[string]doc{}}
|
||||||
|
if err := x.init(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("初始化搜索索引失败: %w", err)
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Index) init() error {
|
||||||
|
var version int
|
||||||
|
if err := x.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if version != schemaVersion {
|
||||||
|
stmts := []string{`DROP TABLE IF EXISTS docs`, `DROP TABLE IF EXISTS docs_fts`, schema, fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := x.db.Exec(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := x.db.Query(`SELECT id, path, rev FROM docs`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var d doc
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&d.id, &p, &d.rev); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x.known[p] = d
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Index) Close() error { return x.db.Close() }
|
||||||
|
|
||||||
|
// Update 提交仓库的最新快照(笔记路径 -> rev),立即返回;同步在后台进行,多次提交只处理最新一次
|
||||||
|
func (x *Index) Update(files map[string]string) {
|
||||||
|
x.mu.Lock()
|
||||||
|
x.pending = files
|
||||||
|
x.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case x.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行后台同步,直到 ctx 结束
|
||||||
|
func (x *Index) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-x.wake:
|
||||||
|
x.mu.Lock()
|
||||||
|
files := x.pending
|
||||||
|
x.mu.Unlock()
|
||||||
|
if err := x.sync(files); err != nil {
|
||||||
|
slog.Error("更新搜索索引失败", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync 让索引与快照一致:只读取新增或 rev 变化的笔记
|
||||||
|
func (x *Index) sync(files map[string]string) error {
|
||||||
|
tx, err := x.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
next := make(map[string]doc, len(x.known))
|
||||||
|
for p, d := range x.known {
|
||||||
|
next[p] = d
|
||||||
|
}
|
||||||
|
for p, d := range x.known {
|
||||||
|
if _, ok := files[p]; !ok {
|
||||||
|
if err := remove(tx, d.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(next, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p, rev := range files {
|
||||||
|
old, ok := x.known[p]
|
||||||
|
if ok && old.rev == rev {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, rev, err := x.v.Read(p)
|
||||||
|
if errors.Is(err, vault.ErrNotFound) {
|
||||||
|
continue // 快照之后被删除,下一次快照会处理
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if err := remove(tx, old.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`INSERT INTO docs (path, rev) VALUES (?, ?)`, p, rev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO docs_fts (rowid, path, body) VALUES (?, ?, ?)`, id, p, string(content)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next[p] = doc{id: id, rev: rev}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x.known = next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(tx *sql.Tx, id int64) error {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM docs WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(`DELETE FROM docs_fts WHERE rowid = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 查找同时包含所有词(空白分隔,不区分大小写)的笔记,词可出现在路径或正文中。
|
||||||
|
// 路径(文件名或目录名)命中第一个词的排在前面,其余按相关度排序
|
||||||
|
func (x *Index) Search(query string, limit int) ([]Result, error) {
|
||||||
|
terms := strings.Fields(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return []Result{}, nil
|
||||||
|
}
|
||||||
|
if len(terms) > maxTerms {
|
||||||
|
terms = terms[:maxTerms]
|
||||||
|
}
|
||||||
|
var where, phrases []string
|
||||||
|
var args []any
|
||||||
|
for _, t := range terms {
|
||||||
|
if utf8.RuneCountInString(t) >= 3 {
|
||||||
|
phrases = append(phrases, `"`+strings.ReplaceAll(t, `"`, `""`)+`"`)
|
||||||
|
} else {
|
||||||
|
like := "%" + escapeLike(t) + "%"
|
||||||
|
where = append(where, `(path LIKE ? ESCAPE '\' OR body LIKE ? ESCAPE '\')`)
|
||||||
|
args = append(args, like, like)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := "path"
|
||||||
|
if len(phrases) > 0 {
|
||||||
|
where = append(where, `docs_fts MATCH ?`)
|
||||||
|
args = append(args, strings.Join(phrases, " AND "))
|
||||||
|
order = "rank"
|
||||||
|
}
|
||||||
|
name := "%" + escapeLike(terms[0]) + "%"
|
||||||
|
q := `SELECT path, body FROM docs_fts WHERE ` + strings.Join(where, " AND ") +
|
||||||
|
` ORDER BY path LIKE ? ESCAPE '\' DESC, ` + order + ` LIMIT ?`
|
||||||
|
args = append(args, name, limit)
|
||||||
|
rows, err := x.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
results := []Result{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r Result
|
||||||
|
var body string
|
||||||
|
if err := rows.Scan(&r.Path, &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Line, r.Snippet = snippet(body, terms)
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snippet 返回正文中第一处匹配所在的行号与摘录(过长的行截取匹配附近的一段)
|
||||||
|
func snippet(body string, terms []string) (int, string) {
|
||||||
|
folded := make([][]rune, len(terms))
|
||||||
|
for i, t := range terms {
|
||||||
|
folded[i] = fold([]rune(t))
|
||||||
|
}
|
||||||
|
for n, line := range strings.Split(body, "\n") {
|
||||||
|
runes := []rune(strings.TrimRight(line, "\r"))
|
||||||
|
lower := fold(runes)
|
||||||
|
at := -1
|
||||||
|
for _, t := range folded {
|
||||||
|
if i := indexRunes(lower, t); i >= 0 && (at < 0 || i < at) {
|
||||||
|
at = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if len(runes) > snippetRunes {
|
||||||
|
start = max(0, min(at-snippetBefore, len(runes)-snippetRunes))
|
||||||
|
}
|
||||||
|
end := min(len(runes), start+snippetRunes)
|
||||||
|
text := strings.TrimSpace(string(runes[start:end]))
|
||||||
|
if start > 0 {
|
||||||
|
text = "…" + text
|
||||||
|
}
|
||||||
|
if end < len(runes) {
|
||||||
|
text += "…"
|
||||||
|
}
|
||||||
|
return n + 1, text
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// fold 逐字符转小写,保持字符数不变,便于在原文中按位置截取
|
||||||
|
func fold(rs []rune) []rune {
|
||||||
|
out := make([]rune, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = unicode.ToLower(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexRunes(s, sub []rune) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
match := true
|
||||||
|
for j := range sub {
|
||||||
|
if s[i+j] != sub[j] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rawmark/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
t *testing.T
|
||||||
|
v *vault.Vault
|
||||||
|
idx *Index
|
||||||
|
db string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
v, err := vault.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &fixture{t: t, v: v, db: filepath.Join(t.TempDir(), "index.db")}
|
||||||
|
f.reopen()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) reopen() {
|
||||||
|
if f.idx != nil {
|
||||||
|
f.idx.Close()
|
||||||
|
}
|
||||||
|
idx, err := Open(f.db, f.v)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.idx = idx
|
||||||
|
f.t.Cleanup(func() { idx.Close() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) write(p, content string) {
|
||||||
|
abs := filepath.Join(f.v.Root(), p)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync 模拟监听器提交快照
|
||||||
|
func (f *fixture) sync() {
|
||||||
|
f.t.Helper()
|
||||||
|
entries, err := f.v.Tree()
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := map[string]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Dir {
|
||||||
|
data, _, err := f.v.Read(e.Path)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
files[e.Path] = vault.Rev(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.idx.sync(files); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) paths(q string) string {
|
||||||
|
f.t.Helper()
|
||||||
|
rs, err := f.idx.Search(q, 50)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatalf("搜索 %q: %v", q, err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range rs {
|
||||||
|
out = append(out, r.Path)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchTerms(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("a.md", "# 标题\n\n今天学习 CodeMirror 的装饰器\n")
|
||||||
|
f.write("b.md", "第二篇笔记,讲 SQLite 全文检索\n")
|
||||||
|
f.write("目录/c.md", "装饰器和检索都提到\n")
|
||||||
|
f.sync()
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS
|
||||||
|
"检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE
|
||||||
|
"codemirror": "a.md", // 不区分大小写
|
||||||
|
"装饰器 检索": "目录/c.md", // 多个词同时满足
|
||||||
|
"目录": "目录/c.md", // 命中路径
|
||||||
|
"不存在的词": "",
|
||||||
|
"100%": "",
|
||||||
|
`"引号"`: "",
|
||||||
|
" ": "",
|
||||||
|
}
|
||||||
|
for q, want := range cases {
|
||||||
|
if got := f.paths(q); got != want && !(strings.Contains(want, ",") && sameSet(got, want)) {
|
||||||
|
t.Errorf("搜索 %q = %q,期望 %q", q, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameSet(a, b string) bool {
|
||||||
|
as, bs := strings.Split(a, ","), strings.Split(b, ",")
|
||||||
|
if len(as) != len(bs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range as {
|
||||||
|
seen[s] = true
|
||||||
|
}
|
||||||
|
for _, s := range bs {
|
||||||
|
if !seen[s] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchRanksNameMatchFirstAndReturnsSnippet(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("其他.md", "第一行\n这里提到部署流程\n")
|
||||||
|
f.write("部署流程.md", "正文无关\n")
|
||||||
|
f.write("长行.md", strings.Repeat("前", 300)+"关键字"+strings.Repeat("后", 300))
|
||||||
|
f.sync()
|
||||||
|
|
||||||
|
rs, err := f.idx.Search("部署流程", 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rs) != 2 || rs[0].Path != "部署流程.md" || rs[0].Line != 0 {
|
||||||
|
t.Fatalf("文件名命中应排第一且无正文摘录: %+v", rs)
|
||||||
|
}
|
||||||
|
if rs[1].Line != 2 || rs[1].Snippet != "这里提到部署流程" {
|
||||||
|
t.Fatalf("摘录错误: %+v", rs[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
rs, _ = f.idx.Search("关键字", 50)
|
||||||
|
s := rs[0].Snippet
|
||||||
|
if !strings.HasPrefix(s, "…") || !strings.HasSuffix(s, "…") || !strings.Contains(s, "关键字") || len([]rune(s)) > snippetRunes+2 {
|
||||||
|
t.Fatalf("长行摘录应截取匹配附近: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncTracksChanges(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("a.md", "旧内容")
|
||||||
|
f.write("b.md", "保留")
|
||||||
|
f.sync()
|
||||||
|
|
||||||
|
f.write("a.md", "新内容")
|
||||||
|
if err := os.Remove(filepath.Join(f.v.Root(), "b.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.write("c.md", "新增")
|
||||||
|
f.sync()
|
||||||
|
for q, want := range map[string]string{"旧内容": "", "新内容": "a.md", "保留": "", "新增": "c.md"} {
|
||||||
|
if got := f.paths(q); got != want {
|
||||||
|
t.Errorf("搜索 %q = %q,期望 %q", q, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重启后沿用已有索引,只处理变化
|
||||||
|
f.reopen()
|
||||||
|
if len(f.idx.known) != 2 {
|
||||||
|
t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known)
|
||||||
|
}
|
||||||
|
f.sync()
|
||||||
|
if got := f.paths("新内容"); got != "a.md" {
|
||||||
|
t.Errorf("重启后搜索 = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-2
@@ -34,20 +34,23 @@ type Watcher struct {
|
|||||||
fw *fsnotify.Watcher
|
fw *fsnotify.Watcher
|
||||||
snap map[string]fileState
|
snap map[string]fileState
|
||||||
watched map[string]bool
|
watched map[string]bool
|
||||||
|
onScan func(notes map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub) error {
|
// Start 开始监听。onScan 在首次扫描及之后每次有变化的扫描后调用,参数为全部笔记的路径 -> rev(用于更新搜索索引)
|
||||||
|
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) error {
|
||||||
fw, err := fsnotify.NewWatcher()
|
fw, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}}
|
w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}, onScan: onScan}
|
||||||
snap, err := w.scan()
|
snap, err := w.scan()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fw.Close()
|
fw.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.snap = snap
|
w.snap = snap
|
||||||
|
w.onScan(notes(snap))
|
||||||
go w.loop(ctx)
|
go w.loop(ctx)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,9 +93,22 @@ func (w *Watcher) rescan() {
|
|||||||
}
|
}
|
||||||
evs := diff(w.snap, next)
|
evs := diff(w.snap, next)
|
||||||
w.snap = next
|
w.snap = next
|
||||||
|
if len(evs) > 0 {
|
||||||
|
w.onScan(notes(next))
|
||||||
|
}
|
||||||
w.hub.Broadcast(evs...)
|
w.hub.Broadcast(evs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notes(snap map[string]fileState) map[string]string {
|
||||||
|
out := make(map[string]string, len(snap))
|
||||||
|
for p, st := range snap {
|
||||||
|
if !st.dir {
|
||||||
|
out[p] = st.rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
|
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
|
||||||
func (w *Watcher) scan() (map[string]fileState, error) {
|
func (w *Watcher) scan() (map[string]fileState, error) {
|
||||||
entries, err := w.v.Tree()
|
entries, err := w.v.Tree()
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState, type StateEffect, type Text } from '@codemirror/state'
|
||||||
import type { EditorView } from '@codemirror/view'
|
import type { EditorView } from '@codemirror/view'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { createEditorView } from '../editor'
|
import { createEditorView } from '../editor'
|
||||||
import { activeTab, attachView, editorStateOf, noteRefOf, storeViewState, ws } from './workspace'
|
import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, ws } from './workspace'
|
||||||
|
|
||||||
const host = ref<HTMLElement>()
|
const host = ref<HTMLElement>()
|
||||||
const tab = computed(activeTab)
|
const tab = computed(activeTab)
|
||||||
let view: EditorView
|
let view: EditorView
|
||||||
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置
|
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置。
|
||||||
|
// 优先用滚动快照(锚定文档位置):重设状态后视口外的行高会重新估算,单靠 scrollTop 会偏移几行;
|
||||||
|
// 快照只对同一文档有效,笔记在后台被外部修改时退回 scrollTop
|
||||||
|
interface ScrollPosition {
|
||||||
|
doc: Text
|
||||||
|
snapshot: StateEffect<unknown>
|
||||||
|
top: number
|
||||||
|
}
|
||||||
let shown: { path: string } | undefined
|
let shown: { path: string } | undefined
|
||||||
const scrollTops = new WeakMap<object, number>()
|
const scrollPositions = new WeakMap<object, ScrollPosition>()
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
const path = ws.active
|
const path = ws.active
|
||||||
@@ -18,7 +25,7 @@ function show() {
|
|||||||
const state = path ? editorStateOf(path) : undefined
|
const state = path ? editorStateOf(path) : undefined
|
||||||
if (shown && shown !== ref) {
|
if (shown && shown !== ref) {
|
||||||
storeViewState(shown.path, view.state)
|
storeViewState(shown.path, view.state)
|
||||||
scrollTops.set(shown, view.scrollDOM.scrollTop)
|
scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop })
|
||||||
}
|
}
|
||||||
if (!ref || !state) {
|
if (!ref || !state) {
|
||||||
shown = undefined
|
shown = undefined
|
||||||
@@ -27,8 +34,13 @@ function show() {
|
|||||||
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
|
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
|
||||||
if (shown !== ref || state.doc !== view.state.doc) {
|
if (shown !== ref || state.doc !== view.state.doc) {
|
||||||
view.setState(state)
|
view.setState(state)
|
||||||
const top = scrollTops.get(ref) ?? 0
|
const pos = scrollPositions.get(ref)
|
||||||
requestAnimationFrame(() => (view.scrollDOM.scrollTop = top))
|
// 等一帧:v-show 在此回调之后才取消 display:none,隐藏状态下无法滚动和聚焦
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (shown !== ref || revealPending(view, ref.path)) return
|
||||||
|
if (pos?.doc === view.state.doc) view.dispatch({ effects: pos.snapshot })
|
||||||
|
else view.scrollDOM.scrollTop = pos?.top ?? 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
shown = ref
|
shown = ref
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const paths = {
|
|||||||
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||||
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||||
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
||||||
|
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
||||||
}
|
}
|
||||||
defineProps<{ name: keyof typeof paths; size?: number }>()
|
defineProps<{ name: keyof typeof paths; size?: number }>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { api, type SearchResult } from './api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { dirname, noteTitle } from './paths'
|
||||||
|
import { openAt, toast, ws } from './workspace'
|
||||||
|
|
||||||
|
const SEARCH_DELAY = 150
|
||||||
|
|
||||||
|
const input = ref<HTMLInputElement>()
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref<SearchResult[] | null>(null)
|
||||||
|
const selected = ref(0)
|
||||||
|
const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
|
||||||
|
|
||||||
|
// 只采用最后一次请求的结果,避免慢请求覆盖新结果
|
||||||
|
let seq = 0
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
watch(query, (q) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => void run(q), SEARCH_DELAY)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function run(q: string) {
|
||||||
|
const id = ++seq
|
||||||
|
if (!q.trim()) {
|
||||||
|
results.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api.search(q)
|
||||||
|
if (id !== seq) return
|
||||||
|
results.value = r
|
||||||
|
selected.value = 0
|
||||||
|
} catch {
|
||||||
|
if (id === seq) toast('搜索失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把文本按搜索词(不区分大小写)切分,命中部分标记为高亮 */
|
||||||
|
function segments(text: string): { text: string; hit: boolean }[] {
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
const words = terms.value.map((t) => t.toLowerCase())
|
||||||
|
const out: { text: string; hit: boolean }[] = []
|
||||||
|
let plain = ''
|
||||||
|
for (let i = 0; i < text.length; ) {
|
||||||
|
const len = Math.max(0, ...words.filter((w) => lower.startsWith(w, i)).map((w) => w.length))
|
||||||
|
if (len) {
|
||||||
|
if (plain) out.push({ text: plain, hit: false })
|
||||||
|
out.push({ text: text.slice(i, i + len), hit: true })
|
||||||
|
plain = ''
|
||||||
|
i += len
|
||||||
|
} else plain += text[i++]
|
||||||
|
}
|
||||||
|
if (plain) out.push({ text: plain, hit: false })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(r: SearchResult) {
|
||||||
|
void openAt(r.path, r.line, terms.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const list = results.value ?? []
|
||||||
|
if (e.key === 'Escape') ws.searchOpen = false
|
||||||
|
else if (e.key === 'ArrowDown' && list.length) selected.value = (selected.value + 1) % list.length
|
||||||
|
else if (e.key === 'ArrowUp' && list.length) selected.value = (selected.value - 1 + list.length) % list.length
|
||||||
|
else if (e.key === 'Enter' && list[selected.value]) open(list[selected.value])
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => input.value?.focus())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-search">
|
||||||
|
<div class="rm-search-box">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="query"
|
||||||
|
class="rm-search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="搜索笔记"
|
||||||
|
enterkeyhint="search"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button class="rm-icon-btn" title="关闭搜索" @click="ws.searchOpen = false"><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<ul v-if="results?.length" class="rm-search-results">
|
||||||
|
<li
|
||||||
|
v-for="(r, i) in results"
|
||||||
|
:key="r.path"
|
||||||
|
class="rm-search-item"
|
||||||
|
:class="{ active: i === selected, current: r.path === ws.active }"
|
||||||
|
@click="open(r)"
|
||||||
|
@mousemove="selected = i"
|
||||||
|
>
|
||||||
|
<div class="rm-search-title">
|
||||||
|
<span v-for="(s, j) in segments(noteTitle(r.path))" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||||
|
<span v-if="dirname(r.path)" class="rm-search-dir">{{ dirname(r.path) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="r.snippet" class="rm-search-snippet">
|
||||||
|
<span v-for="(s, j) in segments(r.snippet)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else-if="results" class="rm-tree-empty">没有找到匹配的笔记</p>
|
||||||
|
<p v-else class="rm-tree-empty">输入关键词,多个词用空格分隔</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,7 +5,8 @@ import { basename } from './paths'
|
|||||||
import TrashDialog from './TrashDialog.vue'
|
import TrashDialog from './TrashDialog.vue'
|
||||||
import { tree } from './tree'
|
import { tree } from './tree'
|
||||||
import TreeNode from './TreeNode.vue'
|
import TreeNode from './TreeNode.vue'
|
||||||
import { createFolder, createNote, move } from './workspace'
|
import SearchPanel from './SearchPanel.vue'
|
||||||
|
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
const trashOpen = ref(false)
|
const trashOpen = ref(false)
|
||||||
@@ -30,10 +31,14 @@ function onRootDrop(e: DragEvent) {
|
|||||||
<div class="rm-sidebar-inner">
|
<div class="rm-sidebar-inner">
|
||||||
<div class="rm-sidebar-head">
|
<div class="rm-sidebar-head">
|
||||||
<span class="rm-brand">RawMark</span>
|
<span class="rm-brand">RawMark</span>
|
||||||
|
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" title="搜索(Ctrl+Shift+F)" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()">
|
||||||
|
<Icon name="search" />
|
||||||
|
</button>
|
||||||
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
||||||
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
<SearchPanel v-if="ws.searchOpen" />
|
||||||
|
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
||||||
<ul class="rm-tree-list">
|
<ul class="rm-tree-list">
|
||||||
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface TrashItem {
|
|||||||
deletedAt: number
|
deletedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
path: string
|
||||||
|
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
|
||||||
|
line: number
|
||||||
|
snippet: string
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
@@ -68,6 +75,7 @@ export const api = {
|
|||||||
logout: () => request('POST', '/api/v1/auth/logout'),
|
logout: () => request('POST', '/api/v1/auth/logout'),
|
||||||
|
|
||||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
||||||
|
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||||
|
|
||||||
async readFile(path: string): Promise<{ content: string; rev: string }> {
|
async readFile(path: string): Promise<{ content: string; rev: string }> {
|
||||||
const res = await request('GET', '/api/v1/files/' + encodePath(path))
|
const res = await request('GET', '/api/v1/files/' + encodePath(path))
|
||||||
|
|||||||
@@ -154,6 +154,51 @@ button {
|
|||||||
.rm-tree-row:hover .rm-tree-more,
|
.rm-tree-row:hover .rm-tree-more,
|
||||||
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
|
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
|
||||||
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; }
|
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; }
|
||||||
|
/* ---- 搜索 ---- */
|
||||||
|
.rm-sidebar-head .rm-icon-btn.on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
||||||
|
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.rm-search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 10px 8px;
|
||||||
|
padding: 0 4px 0 8px;
|
||||||
|
border: 1px solid var(--rm-divider);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--rm-bg);
|
||||||
|
color: var(--rm-text-muted);
|
||||||
|
}
|
||||||
|
.rm-search-box:focus-within { border-color: var(--rm-accent); }
|
||||||
|
.rm-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--rm-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rm-search-input::-webkit-search-cancel-button { display: none; }
|
||||||
|
.rm-search-results { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
|
||||||
|
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.rm-search-item.active { background: var(--rm-hover); }
|
||||||
|
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
|
||||||
|
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: 14px; overflow: hidden; white-space: nowrap; }
|
||||||
|
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: 12px; }
|
||||||
|
.rm-search-snippet {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--rm-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
|
||||||
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
||||||
.rm-foot-btn {
|
.rm-foot-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -344,5 +389,8 @@ button {
|
|||||||
.rm-tree-row { height: 40px; font-size: 15px; }
|
.rm-tree-row { height: 40px; font-size: 15px; }
|
||||||
.rm-tree-more { opacity: 1; width: 36px; height: 36px; }
|
.rm-tree-more { opacity: 1; width: 36px; height: 36px; }
|
||||||
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
|
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
|
||||||
|
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
||||||
|
.rm-search-input { height: 40px; font-size: 16px; }
|
||||||
|
.rm-search-item { padding: 10px 8px; }
|
||||||
.rm-trash-list li { flex-wrap: wrap; }
|
.rm-trash-list li { flex-wrap: wrap; }
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
|
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
|
||||||
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
|
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
|
||||||
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
||||||
import type { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { reactive, watch } from 'vue'
|
import { nextTick, reactive, watch } from 'vue'
|
||||||
import { createEditorState, pasteImages } from '../editor'
|
import { createEditorState, pasteImages } from '../editor'
|
||||||
import { api, ApiError, type Entry } from './api'
|
import { api, ApiError, type Entry } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
@@ -37,6 +37,8 @@ export const ws = reactive({
|
|||||||
renaming: null as string | null,
|
renaming: null as string | null,
|
||||||
// 移动端侧栏抽屉
|
// 移动端侧栏抽屉
|
||||||
sidebarOpen: false,
|
sidebarOpen: false,
|
||||||
|
// 侧栏显示搜索而非目录树
|
||||||
|
searchOpen: false,
|
||||||
toasts: [] as Toast[],
|
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) {
|
export async function openNote(path: string) {
|
||||||
@@ -606,13 +647,29 @@ export async function startWorkspace() {
|
|||||||
})
|
})
|
||||||
window.addEventListener('pagehide', () => void flushAll(true))
|
window.addEventListener('pagehide', () => void flushAll(true))
|
||||||
window.addEventListener('keydown', (e) => {
|
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()
|
e.preventDefault()
|
||||||
if (ws.active) void flush(ws.active)
|
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() {
|
export function stopWorkspace() {
|
||||||
disconnect?.()
|
disconnect?.()
|
||||||
disconnect = undefined
|
disconnect = undefined
|
||||||
|
|||||||
Reference in new issue
Block a user