diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f09c0e6..fb095c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -36,7 +36,7 @@ } } }, - "appPort": ["51888:4242"], + "appPort": ["127.0.0.1:51888:4242"], "portsAttributes": { "4242": { "label": "mind-map Server (devcontainer)", diff --git a/.vscode/launch.json b/.vscode/launch.json index aeeacb8..0b55716 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/mind-map", - "args": ["serve", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], + "args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], "preLaunchTask": "build-webui" }, { @@ -26,7 +26,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/mind-map", - "args": ["serve", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], + "args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], }, { "name": "mind-map (stdio)", @@ -42,7 +42,7 @@ "request": "launch", "browserLaunchLocation": "ui", "runtimeExecutable": "stable", - "url": "http://localhost:4242", + "url": "http://localhost:51888", "webRoot": "${workspaceFolder}/webui", "preLaunchTask": "waitForServer", "userDataDir": "${workspaceFolder}/.vscode/cache", diff --git a/internal/wiki/index.go b/internal/wiki/index.go index a17388f..bf71f4e 100644 --- a/internal/wiki/index.go +++ b/internal/wiki/index.go @@ -76,7 +76,7 @@ func (w *Wiki) Reindex(ctx context.Context) error { return err } - diskMtime := info.ModTime().UTC().Format(time.RFC3339) + diskMtime := info.ModTime().UTC().Format(time.RFC3339Nano) if idxMtime, exists := indexed[pagePath]; exists && idxMtime == diskMtime { continue // unchanged } @@ -184,7 +184,7 @@ func (w *Wiki) indexPage(ctx context.Context, pagePath string) error { _, err = tx.ExecContext(ctx, "INSERT OR REPLACE INTO pages (path, title, body, meta, modified) VALUES (?, ?, ?, ?, ?)", - pagePath, parsed.title, parsed.body, string(metaJSON), info.ModTime().UTC().Format(time.RFC3339), + pagePath, parsed.title, parsed.body, string(metaJSON), info.ModTime().UTC().Format(time.RFC3339Nano), ) if err != nil { return err diff --git a/internal/wiki/pages.go b/internal/wiki/pages.go index ef45380..584806b 100644 --- a/internal/wiki/pages.go +++ b/internal/wiki/pages.go @@ -35,7 +35,7 @@ func (w *Wiki) GetPage(ctx context.Context, pagePath string) (*Page, error) { slog.Warn("page metadata parse error", slog.String("page", pagePath), slog.Any("error", err)) } - modTime, err := time.Parse(time.RFC3339, modified) + modTime, err := time.Parse(time.RFC3339Nano, modified) if err != nil { slog.Warn("page modified time parse error", slog.String("page", pagePath), slog.Any("error", err)) } @@ -80,7 +80,12 @@ func (w *Wiki) ListPages(ctx context.Context, prefix string) ([]Page, error) { query += " WHERE path LIKE ? OR path = ?" args = append(args, prefix+"/%", prefix) } - query += " ORDER BY modified DESC" + // modified is stored with nanosecond precision so files written + // within the same second (common after bulk operations like a git + // pull on a synced wiki) still get a deterministic Recent order. + // path is included as a tiebreaker for the rare case where two + // pages share an mtime exactly. + query += " ORDER BY modified DESC, path ASC" rows, err := w.db.QueryContext(ctx, query, args...) if err != nil { @@ -99,7 +104,7 @@ func (w *Wiki) ListPages(ctx context.Context, prefix string) ([]Page, error) { if err := json.Unmarshal([]byte(metaStr), &p.Frontmatter); err != nil { slog.Warn("list pages metadata parse error", slog.String("page", p.Path), slog.Any("error", err)) } - if t, err := time.Parse(time.RFC3339, modified); err == nil { + if t, err := time.Parse(time.RFC3339Nano, modified); err == nil { p.ModifiedAt = t } else { slog.Warn("list pages time parse error", slog.String("page", p.Path), slog.Any("error", err)) @@ -332,7 +337,7 @@ func (w *Wiki) Context(ctx context.Context) (*WikiContext, error) { } // Recent pages - rows, err := w.db.QueryContext(ctx, "SELECT path, title, modified FROM pages ORDER BY modified DESC LIMIT 20") + rows, err := w.db.QueryContext(ctx, "SELECT path, title, modified FROM pages ORDER BY modified DESC, path ASC LIMIT 20") if err != nil { return nil, err } @@ -346,7 +351,7 @@ func (w *Wiki) Context(ctx context.Context) (*WikiContext, error) { slog.Warn("context scan error", slog.Any("error", err)) continue } - if t, err := time.Parse(time.RFC3339, modified); err == nil { + if t, err := time.Parse(time.RFC3339Nano, modified); err == nil { p.ModifiedAt = t } else { slog.Warn("context time parse error", slog.String("page", p.Path), slog.Any("error", err)) diff --git a/internal/wiki/recent_sort_test.go b/internal/wiki/recent_sort_test.go new file mode 100644 index 0000000..ac13e3b --- /dev/null +++ b/internal/wiki/recent_sort_test.go @@ -0,0 +1,70 @@ +package wiki + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// TestListPagesRecentSortDistinguishesSameSecondMtimes is a regression +// for the "Sort: Recent" bug where pages written in the same wall-clock +// second (common after a wiki sync `git pull`) all ended up with an +// identical seconds-precision `modified` value in the index. The +// resulting ORDER BY had nothing to differentiate them, so SQLite +// returned them in an arbitrary internal order that did not match the +// actual edit recency the user expected. +// +// With nanosecond-precision storage and `path` as a stable tiebreaker, +// the most recently written file must come first. +func TestListPagesRecentSortDistinguishesSameSecondMtimes(t *testing.T) { + tmp := t.TempDir() + + // Pre-populate three files with sub-second-spaced mtimes — all in + // the same wall-clock second. This mirrors what `git checkout` + // produces during a wiki sync. + now := time.Now().UTC().Truncate(time.Second) + files := []struct { + path string + mod time.Time + }{ + {"oldest.md", now.Add(1 * time.Millisecond)}, + {"middle.md", now.Add(2 * time.Millisecond)}, + {"newest.md", now.Add(3 * time.Millisecond)}, + } + for _, f := range files { + abs := filepath.Join(tmp, f.path) + if err := os.WriteFile(abs, []byte("# "+f.path), 0o644); err != nil { + t.Fatalf("write %s: %v", f.path, err) + } + if err := os.Chtimes(abs, f.mod, f.mod); err != nil { + t.Fatalf("chtimes %s: %v", f.path, err) + } + } + + w, err := Open(tmp) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer w.Close() + + pages, err := w.ListPages(context.Background(), "") + if err != nil { + t.Fatalf("ListPages: %v", err) + } + if len(pages) != 3 { + t.Fatalf("got %d pages, want 3", len(pages)) + } + + want := []string{"newest", "middle", "oldest"} + for i, p := range pages { + if p.Path != want[i] { + var got []string + for _, q := range pages { + got = append(got, q.Path) + } + t.Fatalf("Recent sort wrong: got %v, want %v", got, want) + } + } +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index f246cdb..a344a51 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,10 +1,60 @@ -import { useState, useEffect, useRef } from 'preact/hooks'; +import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import { api, Page } from './mcp'; import { marked } from 'marked'; import mermaid from 'mermaid'; mermaid.initialize({ startOnLoad: false, theme: 'default' }); +// Tokenize a free-form search query the same way the FTS index does: +// - "quoted phrases" become a single token (so they highlight as a +// phrase and pass through to FTS5 as a phrase match) +// - bare runs of non-whitespace become individual tokens +// - leading/trailing punctuation on bare tokens is stripped +// - empty tokens are dropped +function searchTokens(query: string): string[] { + const tokens: string[] = []; + const re = /"([^"]+)"|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(query)) !== null) { + const tok = m[1] !== undefined + ? m[1].trim() + : m[2].replace(/^[^\p{L}\p{N}_]+|[^\p{L}\p{N}_]+$/gu, ''); + if (tok) tokens.push(tok); + } + return tokens; +} + +function searchRegex(tokens: string[]): RegExp | null { + if (tokens.length === 0) return null; + // Escape regex metacharacters, then collapse interior whitespace in + // phrase tokens to \s+ so "MCP server" still matches even if the + // rendered text has a newline or extra spaces between the words. + const escaped = tokens.map(t => + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\s+/g, '\\s+') + ); + return new RegExp(`(${escaped.join('|')})`, 'giu'); +} + +// Renders plain text with each search-query token wrapped in . +// Use this for any place that renders user-supplied text directly +// (sidebar items, page header). The body uses highlightHTML instead +// because it needs to highlight inside marked-rendered HTML. +function Highlighted({ text, query }: { text: string; query: string }) { + const re = searchRegex(searchTokens(query)); + if (!re || !text) return <>{text}; + const parts: (string | { mark: string })[] = []; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(text.slice(last, m.index)); + parts.push({ mark: m[0] }); + last = m.index + m[0].length; + } + if (last < text.length) parts.push(text.slice(last)); + return <>{parts.map((p, i) => typeof p === 'string' ? p : {p.mark})}; +} + interface SyncSettings { enabled: boolean; default: string; @@ -45,7 +95,7 @@ export function App() { const [current, setCurrent] = useState(null); const [editing, setEditing] = useState(false); const [editContent, setEditContent] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(() => localStorage.getItem('mm-search-query') || ''); const [showSettings, setShowSettings] = useState(false); const [settings, setSettings] = useState(null); const [configPath, setConfigPath] = useState(''); @@ -176,7 +226,19 @@ export function App() { setPages(sortPages(rawPages)); }, [rawPages, sortMode]); - useEffect(() => { loadPages(); }, []); + // Persist search query so it survives reload; on first mount restore + // either the filtered list (if a query was saved) or the full page list. + useEffect(() => { + localStorage.setItem('mm-search-query', searchQuery); + }, [searchQuery]); + + useEffect(() => { + if (searchQuery.trim()) { + handleSearch(); + } else { + loadPages(); + } + }, []); // Hash routing const getHashPath = (): string | null => { @@ -324,9 +386,61 @@ export function App() { return html; }; + // Wrap each occurrence of any search token in . Works on parsed + // DOM (not via regex on raw HTML) so tags and attributes are never + // touched. Skips