From 01855b5c1914393ae4c3e16007d1f622dcb8691d Mon Sep 17 00:00:00 2001 From: aniongithub Date: Sun, 17 May 2026 11:30:45 -0700 Subject: [PATCH 1/6] fix(wiki): store modified mtimes at nanosecond precision so Recent sort is correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListPages and Context stored each page's mtime via time.RFC3339, which has only second-level precision. After a bulk operation (wiki sync git pull, mass agent edit, etc.) many files share the same wall-clock second on disk and therefore share an identical 'modified' string in the index. The query ORDER BY modified DESC then has no tiebreaker and SQLite returns rows in an arbitrary internal order — most user- visibly: the 'Sort: Recent' list in the web UI did not actually reflect recency. - Write 'modified' with time.RFC3339Nano so the nanosecond precision of the filesystem mtime survives into the index. - Add 'path ASC' as a stable tiebreaker on the two queries that sort by modified. - Parse with RFC3339Nano too. (Existing seconds-precision values from older databases parse fine — time.Parse handles the missing fractional component.) Old rows will get re-stored with nanosecond precision the next time their files change; until then the path tiebreaker keeps ordering stable. --- internal/wiki/index.go | 4 +- internal/wiki/pages.go | 15 ++++--- internal/wiki/recent_sort_test.go | 70 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 internal/wiki/recent_sort_test.go 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) + } + } +} From 626f2be00f02fef2f02f4ed0254fc995a8e457ab Mon Sep 17 00:00:00 2001 From: aniongithub Date: Sun, 17 May 2026 11:37:10 -0700 Subject: [PATCH 2/6] feat(webui): persist search query across reloads and add inline clear button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lazy-initialize searchQuery from localStorage ('mm-search-query') and persist on change, so a filter survives a page reload. - On first mount, restore the filtered list if a saved query is present; otherwise load the full page list as before. - Add a small × clear button rendered inside the search input when the field is non-empty. Clicking it clears the query, removes the localStorage entry (next render), and re-loads the full page list. --- webui/src/App.tsx | 42 +++++++++++++++++++++++++++++++++--------- webui/src/styles.css | 26 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index f246cdb..7825a4f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -45,7 +45,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 +176,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 => { @@ -364,13 +376,25 @@ export function App() { <>