Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
}
}
},
"appPort": ["51888:4242"],
"appPort": ["127.0.0.1:51888:4242"],
"portsAttributes": {
"4242": {
"label": "mind-map Server (devcontainer)",
Expand Down
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand All @@ -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)",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/wiki/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions internal/wiki/pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
Expand Down
70 changes: 70 additions & 0 deletions internal/wiki/recent_sort_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading