From 9a800279826744fa6665adaa6b56837acc7f65a4 Mon Sep 17 00:00:00 2001 From: aniongithub Date: Mon, 18 May 2026 21:32:17 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(webui):=20graph=20polish=20=E2=80=94?= =?UTF-8?q?=20translucent=20edges,=20opaque=20arrows,=20fit-all=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edges are now translucent so they don't visually merge with nodes of the same color: path edges at 0.5 alpha, reference edges at 0.55. Path edge width bumped 1px → 1.5px to compensate for the softer contrast. - Arrowheads stay opaque via linkDirectionalArrowColor set to the full-alpha edge color, so they reliably cover the translucent line where it would otherwise show through the arrow body. Arrow length shrunk 4 → 2.5 — felt oversized at the new node radius. - New 'fit all' button in the graph toolbar. Calls forceGraph.zoomToFit(400ms, 40px padding) — handy when a search filter has narrowed the visible set down to a few far-apart nodes, or when the user has zoomed/panned away from the layout. - withAlpha() helper handles #rgb, #rrggbb, #rrggbbaa, and rgb()/rgba() forms so the alpha override survives whatever CSS var format the theme uses. --- webui/src/GraphView.tsx | 59 ++++++++++++++++++++++++++++++++++++++--- webui/src/styles.css | 20 ++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/webui/src/GraphView.tsx b/webui/src/GraphView.tsx index aab4387..a7a6505 100644 --- a/webui/src/GraphView.tsx +++ b/webui/src/GraphView.tsx @@ -73,6 +73,38 @@ function readCssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; } +// Append an alpha component to a CSS color. Handles #rgb, #rgba, #rrggbb, +// #rrggbbaa, and rgb()/rgba() forms. Falls back to the original color if +// the format isn't recognized (so theme tweaks can't crash the renderer). +function withAlpha(color: string, alpha: number): string { + const c = color.trim(); + const a = Math.max(0, Math.min(1, alpha)); + if (c.startsWith('#')) { + const hex = c.slice(1); + let r = 0, g = 0, b = 0; + if (hex.length === 3 || hex.length === 4) { + r = parseInt(hex[0] + hex[0], 16); + g = parseInt(hex[1] + hex[1], 16); + b = parseInt(hex[2] + hex[2], 16); + } else if (hex.length === 6 || hex.length === 8) { + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + } else { + return c; + } + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + const m = c.match(/^rgba?\(([^)]+)\)$/i); + if (m) { + const parts = m[1].split(',').map(s => s.trim()); + if (parts.length >= 3) { + return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${a})`; + } + } + return c; +} + // Greedy word-wrap a label to a maximum width (in canvas units). // Words that on their own exceed maxWidth pass through unbroken; we // don't try to hyphenate, the goal is readability not perfection. @@ -209,9 +241,16 @@ export function GraphView({ pages, searchQuery, onNavigate, onSearch }: GraphVie .nodeVal((n: GraphNode) => (n.isPage ? 1.5 : 0.6)) .nodeColor((n: GraphNode) => (n.isPage ? colors.accent : colors.fgDim)) .nodeLabel((n: GraphNode) => n.page?.title || n.label || n.id) - .linkColor((l: GraphLink) => (l.kind === 'reference' ? colors.edgeRef : colors.edgePath)) - .linkWidth((l: GraphLink) => (l.kind === 'reference' ? 2 : 1)) - .linkDirectionalArrowLength((l: GraphLink) => (l.kind === 'reference' ? 4 : 0)) + // Edges are translucent so they don't visually merge with + // nodes of the same color. Path edges get slightly more + // weight to balance their lower contrast. + .linkColor((l: GraphLink) => withAlpha( + l.kind === 'reference' ? colors.edgeRef : colors.edgePath, + l.kind === 'reference' ? 0.55 : 0.5, + )) + .linkWidth((l: GraphLink) => (l.kind === 'reference' ? 2 : 1.5)) + .linkDirectionalArrowLength((l: GraphLink) => (l.kind === 'reference' ? 2.5 : 0)) + .linkDirectionalArrowColor((l: GraphLink) => (l.kind === 'reference' ? colors.edgeRef : colors.edgePath)) .linkDirectionalArrowRelPos(0.9) .nodeCanvasObjectMode(() => 'after') .nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { @@ -275,6 +314,12 @@ export function GraphView({ pages, searchQuery, onNavigate, onSearch }: GraphVie graphRef.current.graphData(visibleGraph); }, [visibleGraph]); + const handleFitAll = () => { + // 400ms tween, 40px padding around the bounding box of all + // currently-rendered nodes. + if (graphRef.current) graphRef.current.zoomToFit(400, 40); + }; + return (
@@ -296,6 +341,14 @@ export function GraphView({ pages, searchQuery, onNavigate, onSearch }: GraphVie references +
diff --git a/webui/src/styles.css b/webui/src/styles.css index 8b8ba1c..0f96058 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -740,6 +740,26 @@ mark { cursor: pointer; } +.graph-fit { + margin-top: 2px; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 0; + color: var(--fg); + font-family: var(--font); + font-size: 11px; + font-weight: 300; + letter-spacing: 0.5px; + cursor: pointer; + text-align: center; +} + +.graph-fit:hover { + border-color: var(--accent); + color: var(--accent); +} + .graph-swatch { display: inline-block; width: 18px; From 7c9a75719326b2da8f327901055f2eb1e4733d1f Mon Sep 17 00:00:00 2001 From: aniongithub Date: Mon, 18 May 2026 22:29:22 -0700 Subject: [PATCH 2/3] feat: directional sync + devcontainer demo via the wiki itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-mapping direction field to the sync config and uses it to ship a self-documenting devcontainer: on first launch the container pulls mind-map's own documentation down from the project's GitHub wiki via the very sync feature we're demonstrating. ## Sync direction - New SyncMapping.Direction field. Values: 'bidirectional' (default, backward-compatible), 'pull' (read-only — never push local changes), 'push' (write-only — never pull remote changes). - SyncDirection.Normalize() coerces empty/unknown values to bidirectional so existing configs keep working. - syncTarget honors direction: pull skips copyFromWiki + commit + push; push skips copyToWiki + reindex. - Both modes still fetch+merge so push has a base to push onto and pull picks up remote changes — only the local copy directions are gated. - Regression tests: TestPullOnlyDoesNotPushLocalChanges and TestPushOnlyDoesNotPullRemoteChanges. ## Devcontainer wiring - .devcontainer/devcontainer.json bind-mounts the workspace's .mind-map/ as the vscode user's ~/.mind-map/ so the committed sync config is what mind-map serve reads. - .mind-map/config.json declares a single pull-only mapping at prefix '' pointing at the project's GitHub wiki repo. - .mind-map/.gitignore keeps everything but config.json out of git (the wiki content, sqlite index, shadow clones, logs all get regenerated on first launch). - .vscode/launch.json drops --dir overrides; defaults route to ~/.mind-map/wiki which is now where sync lands content. ## SKILL.md updates - Adds move_page to the tools list and a dedicated section explaining why agents should use it instead of create+delete. - Adds a 'Wiki Layout (Recommended)' section nudging agents toward //. Not enforced, but encouraged so prefix-based sync mappings have meaningful shapes to bite on. - Adds a 'Sync' section explaining the new direction modes. ## testdata removal The previous testdata/ contents have been pushed to the GitHub wiki (33 pages organized as concepts/, architecture/, agents/, design/, comparisons/, guides/). Fresh devcontainer launches now pull that content down via sync instead of carrying it in-repo. Verified end-to-end: rebuilt devcontainer, ran mind-map serve, sync created the shadow clone and populated 34 pages into the wiki dir within 1.5 seconds. /api/context returned the full page list. --- .devcontainer/devcontainer.json | 10 ++- .mind-map/.gitignore | 6 ++ .mind-map/config.json | 13 ++++ .vscode/launch.json | 6 +- SKILL.md | 43 ++++++++++++- internal/config/config.go | 29 ++++++++- internal/sync/sync.go | 109 ++++++++++++++++++++++++-------- internal/sync/sync_test.go | 92 +++++++++++++++++++++++++++ testdata/index.md | 8 --- testdata/projects/mind-map.md | 34 ---------- 10 files changed, 275 insertions(+), 75 deletions(-) create mode 100644 .mind-map/.gitignore create mode 100644 .mind-map/config.json delete mode 100644 testdata/index.md delete mode 100644 testdata/projects/mind-map.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fb095c1..705be8c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -42,5 +42,13 @@ "label": "mind-map Server (devcontainer)", "onAutoForward": "ignore" } - } + }, + "mounts": [ + // Bind-mount the workspace's .mind-map/ as the vscode user's home + // .mind-map/ so the committed config.json (with the pull-only sync + // mapping to the GitHub wiki) is what mind-map serve reads. The + // wiki dir itself (.mind-map/wiki/) is gitignored and populated by + // sync on first launch. + "source=${localWorkspaceFolder}/.mind-map,target=/home/vscode/.mind-map,type=bind,consistency=cached" + ] } diff --git a/.mind-map/.gitignore b/.mind-map/.gitignore new file mode 100644 index 0000000..85f7d22 --- /dev/null +++ b/.mind-map/.gitignore @@ -0,0 +1,6 @@ +# Only the sync config is checked in; everything else is generated at +# runtime (wiki content pulled by sync, SQLite index, shadow clones, +# logs) and stays out of the repo. +* +!.gitignore +!config.json diff --git a/.mind-map/config.json b/.mind-map/config.json new file mode 100644 index 0000000..877c258 --- /dev/null +++ b/.mind-map/config.json @@ -0,0 +1,13 @@ +{ + "sync": { + "enabled": true, + "interval": "30s", + "mappings": [ + { + "prefix": "", + "remote": "https://github.com/aniongithub/mind-map.wiki.git", + "direction": "pull" + } + ] + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b55716..51d2cca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/mind-map", - "args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], + "args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"], "preLaunchTask": "build-webui" }, { @@ -26,7 +26,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/mind-map", - "args": ["serve", "--addr", "0.0.0.0:4242", "--dir", "${workspaceFolder}/testdata", "--webui", "${workspaceFolder}/webui/dist"], + "args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"], }, { "name": "mind-map (stdio)", @@ -34,7 +34,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/mind-map", - "args": ["serve", "--stdio", "--dir", "${workspaceFolder}/testdata"], + "args": ["serve", "--stdio"], }, { "name": "WebUI", diff --git a/SKILL.md b/SKILL.md index e45c16f..34c5fcc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -8,6 +8,7 @@ tools: - create_page - update_page - delete_page + - move_page - list_pages - get_backlinks - register_sync @@ -94,6 +95,14 @@ Replaces the full page content. Read the page first to preserve existing content delete_page(path: "drafts/old-idea") ``` +## Renaming / Moving Pages + +``` +move_page(from: "projects/old-name", to: "projects/new-name") +``` + +`move_page` is **atomic** — it renames the file on disk, updates the index, and rewrites the page's outgoing-link rows in one step. **Always use `move_page` instead of `create_page` + `delete_page`** to avoid leaving duplicate pages behind. Backlinks from other pages (other pages with `[[old-name]]` in their source) are intentionally not rewritten; if you want those updated, search and edit the source pages explicitly. + ## Listing Pages ``` @@ -111,15 +120,47 @@ get_backlinks(path: "api/tokens") Use backlinks to discover related context and navigate the wiki. +## Wiki Layout (Recommended) + +mind-map doesn't enforce a directory scheme, but **agents should follow this convention** so the wiki stays organizable and so [[git sync mappings|sync prefixes]] line up with meaningful boundaries: + +``` +// +``` + +Concretely: + +``` +projects/forgewright/architecture/dag.md +projects/mind-map/concepts/wikilinks.md +notes/reading/2026-05-design-patterns.md +journal/2026-05-18.md +decisions/2026-05-18-pick-sqlite-fts5.md +``` + +Top-level directories should be **projects** (named after the thing you're building) or **areas** (`notes`, `journal`, `decisions`, `people`, etc.). Inside each, group by category. This: + +- Keeps related pages close in the sidebar and graph view. +- Makes `prefix:`-based sync mappings useful: a single mapping like `prefix: "projects/forgewright"` can sync that subtree to its own git repo. +- Lets agents and humans navigate by intuition — "where would I put this?" usually has one obvious answer. + +When in doubt, prefer **deeper paths over wider ones**. A page at `projects/foo/decisions/auth.md` is almost always better than `foo-auth-decision.md` at the root. + ## Best Practices - ✅ **Search first** — check what exists before creating new pages - ✅ **Use frontmatter** — add `title`, `type`, and `status` for structure - ✅ **Use wikilinks** — connect related pages with `[[target]]` syntax -- ✅ **Organize by prefix** — group pages under meaningful directories +- ✅ **Follow `/` layout** — see above +- ✅ **`move_page`, not `create_page` + `delete_page`** — atomic, preserves the link graph - ✅ **Get context first** — call `get_wiki_context()` to orient yourself - ❌ **Don't create duplicates** — search before writing - ❌ **Don't use file extensions** — paths are without `.md` +- ❌ **Don't dump pages at the root** — pick a project or area + +## Sync (read-only or read-write) + +`register_sync` ties a path prefix to a git remote. Sync is bidirectional by default but supports `direction: "pull"` (read-only) and `direction: "push"` (write-only) — useful when the wiki content is owned upstream (e.g. a project's GitHub wiki) and the local wiki is a working copy that should never push back. Respect existing sync mappings: don't reshape paths under a prefix that's syncing somewhere else without confirming with the user first. ## Page Format Example diff --git a/internal/config/config.go b/internal/config/config.go index e285c12..fb59b48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,10 +12,35 @@ import ( "time" ) +// SyncDirection controls which side of the sync flow is active. +// - "" or "bidirectional" — pull from remote and push local changes (default) +// - "pull" — read-only: pull from remote into the wiki, never push +// - "push" — write-only: push wiki changes upstream, ignore remote changes +type SyncDirection string + +const ( + SyncBidirectional SyncDirection = "bidirectional" + SyncPull SyncDirection = "pull" + SyncPush SyncDirection = "push" +) + +// Normalize returns the canonical direction value. Empty string becomes +// SyncBidirectional. Unknown values also become SyncBidirectional (safe +// default — better to over-sync than to silently no-op). +func (d SyncDirection) Normalize() SyncDirection { + switch d { + case SyncPull, SyncPush, SyncBidirectional: + return d + default: + return SyncBidirectional + } +} + // SyncMapping maps a wiki path prefix to a git remote. type SyncMapping struct { - Prefix string `json:"prefix"` - Remote string `json:"remote"` + Prefix string `json:"prefix"` + Remote string `json:"remote"` + Direction SyncDirection `json:"direction,omitempty"` } // SyncConfig holds git sync settings. diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 4102672..d364fb3 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -56,9 +56,10 @@ type Manager struct { // syncTarget manages a single shadow clone for one remote. type syncTarget struct { - remote string - cloneDir string - prefixes []string // wiki prefixes that map to this remote + remote string + cloneDir string + prefixes []string // wiki prefixes that map to this remote + direction config.SyncDirection mu sync.Mutex lastSync time.Time @@ -179,34 +180,59 @@ func (m *Manager) rebuildTargets() { // rebuildTargetsLocked rebuilds targets. Caller must hold m.mu. func (m *Manager) rebuildTargetsLocked() { - // Build remote -> prefixes map - remotePrefixes := make(map[string][]string) + // Build remote -> (prefixes, direction) map. Default field is treated + // as a bidirectional mapping at the empty prefix. + type remoteInfo struct { + prefixes []string + direction config.SyncDirection + } + remotes := make(map[string]*remoteInfo) + add := func(remote, prefix string, dir config.SyncDirection) { + if remote == "" { + return + } + ri, ok := remotes[remote] + if !ok { + ri = &remoteInfo{direction: dir} + remotes[remote] = ri + } + ri.prefixes = append(ri.prefixes, prefix) + // First mapping wins for direction. If a later mapping for the + // same remote disagrees, log a warning — there's no sane way to + // run a single shadow clone with two opposing directions. + if ri.direction != dir { + slog.Warn("conflicting sync directions for remote, using first", + slog.String("remote", remote), + slog.String("kept", string(ri.direction)), + slog.String("ignored", string(dir))) + } + } if m.cfg.Sync.Default != "" { - remotePrefixes[m.cfg.Sync.Default] = append(remotePrefixes[m.cfg.Sync.Default], "") + add(m.cfg.Sync.Default, "", config.SyncBidirectional) } for _, mapping := range m.cfg.Sync.Mappings { - if mapping.Remote != "" { - remotePrefixes[mapping.Remote] = append(remotePrefixes[mapping.Remote], mapping.Prefix) - } + add(mapping.Remote, mapping.Prefix, mapping.Direction.Normalize()) } // Create or update targets - for remote, prefixes := range remotePrefixes { + for remote, ri := range remotes { if t, exists := m.targets[remote]; exists { - t.prefixes = prefixes + t.prefixes = ri.prefixes + t.direction = ri.direction } else { dirName := sanitizeDirName(remote) m.targets[remote] = &syncTarget{ - remote: remote, - cloneDir: filepath.Join(m.syncDir, dirName), - prefixes: prefixes, + remote: remote, + cloneDir: filepath.Join(m.syncDir, dirName), + prefixes: ri.prefixes, + direction: ri.direction, } } } // Remove targets no longer in config for remote := range m.targets { - if _, exists := remotePrefixes[remote]; !exists { + if _, exists := remotes[remote]; !exists { delete(m.targets, remote) } } @@ -229,41 +255,72 @@ func (m *Manager) syncAll(ctx context.Context) { } } -// syncTarget syncs a single remote: pull -> copy in -> reindex -> copy out -> commit -> push. +// syncTarget syncs a single remote. Behavior depends on direction: +// - bidirectional (default): pull → copy in → reindex → copy out → commit → push +// - pull: pull → copy in → reindex (never copies wiki → clone, never pushes) +// - push: copy out → commit → push (never copies clone → wiki, never reindexes +// from remote changes; we still fetch so the merge below is meaningful) func (m *Manager) syncTarget(ctx context.Context, t *syncTarget) { t.mu.Lock() t.lastError = "" t.mu.Unlock() - // Ensure clone exists + direction := t.direction + if direction == "" { + direction = config.SyncBidirectional + } + wantPull := direction == config.SyncBidirectional || direction == config.SyncPull + wantPush := direction == config.SyncBidirectional || direction == config.SyncPush + + // Ensure clone exists. We always need a working clone — even push-only + // targets need somewhere to commit before pushing. if err := m.ensureClone(ctx, t); err != nil { t.setError(fmt.Sprintf("clone: %v", err)) return } - // Pull + // Fetch + (optional) merge. Both pull-only and push-only need this: + // pull-only is obvious; push-only needs a base so `git push` isn't + // rejected as a non-fast-forward against a remote that already has + // commits. The difference between modes is only whether we copy the + // merged remote state INTO the wiki dir and reindex. if err := gitCmd(ctx, t.cloneDir, "fetch", "origin"); err != nil { t.setError(fmt.Sprintf("fetch: %v", err)) return } - - // Check if remote branch exists if err := gitCmd(ctx, t.cloneDir, "rev-parse", "--verify", "origin/main"); err == nil { if err := gitCmd(ctx, t.cloneDir, "merge", "origin/main", "--allow-unrelated-histories", "--no-edit"); err != nil { slog.Warn("merge conflict", slog.String("remote", t.remote), slog.Any("error", err)) } + } else if err := gitCmd(ctx, t.cloneDir, "rev-parse", "--verify", "origin/master"); err == nil { + // GitHub wikis default to the 'master' branch — try it as a + // fallback when 'main' doesn't exist. + if err := gitCmd(ctx, t.cloneDir, "merge", "origin/master", "--allow-unrelated-histories", "--no-edit"); err != nil { + slog.Warn("merge conflict", slog.String("remote", t.remote), slog.Any("error", err)) + } } - // Copy from clone to wiki (pull direction) - m.copyToWiki(t) + if wantPull { + // Copy from clone to wiki (pull direction) + m.copyToWiki(t) - // Reindex to pick up pulled changes - if m.reindexer != nil { - if err := m.reindexer.Reindex(ctx); err != nil { - slog.Warn("reindex after pull failed", slog.Any("error", err)) + // Reindex to pick up pulled changes + if m.reindexer != nil { + if err := m.reindexer.Reindex(ctx); err != nil { + slog.Warn("reindex after pull failed", slog.Any("error", err)) + } } } + if !wantPush { + t.mu.Lock() + t.lastSync = time.Now() + t.lastError = "" + t.mu.Unlock() + slog.Debug("sync target complete (pull-only)", slog.String("remote", t.remote)) + return + } + // Copy from wiki to clone (push direction) m.copyFromWiki(t) diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index fecc84e..cee3596 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -260,3 +260,95 @@ func TestStartAndStop(t *testing.T) { t.Error("status should show enabled") } } + +func TestPullOnlyDoesNotPushLocalChanges(t *testing.T) { +if _, err := exec.LookPath("git"); err != nil { +t.Skip("git not found") +} + +remotePath := setupBareRemote(t) +seedRemote(t, remotePath) + +wikiDir := t.TempDir() +cfg := config.DefaultConfig() +cfg.Sync.Enabled = true +cfg.Sync.Interval = "5s" +cfg.Sync.Mappings = []config.SyncMapping{ +{Prefix: "", Remote: remotePath, Direction: config.SyncPull}, +} +cfgPath := filepath.Join(t.TempDir(), "config.json") +config.Save(cfgPath, cfg) + +mgr := NewManager(wikiDir, cfgPath, cfg, &mockReindexer{}) +if err := mgr.Start(context.Background()); err != nil { +t.Fatalf("Start: %v", err) +} +defer mgr.Stop() + +// Pull happened: seeded index.md should be on disk. +if _, err := os.Stat(filepath.Join(wikiDir, "index.md")); err != nil { +t.Fatalf("pull did not populate wiki: %v", err) +} + +// Drop a local-only page and run another sync cycle. +if err := os.WriteFile(filepath.Join(wikiDir, "local-only.md"), []byte("# Local\n"), 0o644); err != nil { +t.Fatalf("write local page: %v", err) +} +mgr.syncAll(context.Background()) + +// The local page must NOT have been pushed to the remote. +cloneTarget := filepath.Join(t.TempDir(), "verify") +cmd := exec.Command("git", "clone", remotePath, cloneTarget) +if out, err := cmd.CombinedOutput(); err != nil { +t.Fatalf("clone for verify: %s: %v", out, err) +} +if _, err := os.Stat(filepath.Join(cloneTarget, "local-only.md")); !os.IsNotExist(err) { +t.Errorf("pull-only mapping pushed local-only.md upstream (err=%v)", err) +} +} + +func TestPushOnlyDoesNotPullRemoteChanges(t *testing.T) { +if _, err := exec.LookPath("git"); err != nil { +t.Skip("git not found") +} + +remotePath := setupBareRemote(t) +seedRemote(t, remotePath) // creates remote index.md with "Welcome" + +wikiDir := t.TempDir() +cfg := config.DefaultConfig() +cfg.Sync.Enabled = true +cfg.Sync.Interval = "5s" +cfg.Sync.Mappings = []config.SyncMapping{ +{Prefix: "", Remote: remotePath, Direction: config.SyncPush}, +} +cfgPath := filepath.Join(t.TempDir(), "config.json") +config.Save(cfgPath, cfg) + +mgr := NewManager(wikiDir, cfgPath, cfg, &mockReindexer{}) +if err := mgr.Start(context.Background()); err != nil { +t.Fatalf("Start: %v", err) +} +defer mgr.Stop() + +// Remote had index.md; in push-only mode the wiki should NOT have +// gained it via pull. +if _, err := os.Stat(filepath.Join(wikiDir, "index.md")); !os.IsNotExist(err) { +t.Errorf("push-only mapping pulled index.md (err=%v)", err) +} + +// Drop a local page and sync; it should be pushed. +if err := os.WriteFile(filepath.Join(wikiDir, "outbound.md"), []byte("# Out\n"), 0o644); err != nil { +t.Fatalf("write outbound page: %v", err) +} +mgr.syncAll(context.Background()) + +cloneTarget := filepath.Join(t.TempDir(), "verify") +cmd := exec.Command("git", "clone", remotePath, cloneTarget) +if out, err := cmd.CombinedOutput(); err != nil { +t.Fatalf("clone for verify: %s: %v", out, err) +} +if _, err := os.Stat(filepath.Join(cloneTarget, "outbound.md")); err != nil { +t.Errorf("push-only did not push outbound.md: %v", err) +} +} diff --git a/testdata/index.md b/testdata/index.md deleted file mode 100644 index 5986c15..0000000 --- a/testdata/index.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Welcome ---- -# Welcome to mind-map! - -This is a test wiki for development. - -See [[projects/mind-map]] for the project page. diff --git a/testdata/projects/mind-map.md b/testdata/projects/mind-map.md deleted file mode 100644 index 20f5d58..0000000 --- a/testdata/projects/mind-map.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -status: active -title: mind-map -type: project ---- -# mind-map - -A wiki engine for AI agents and humans. Built with [[Go]]. - -Links back to [[index]]. - -## Feature Status - -| Feature | Status | Notes | -|---------|--------|-------| -| Wiki engine | ✅ Done | SQLite-backed, FTS5 search | -| Wikilinks | ✅ Done | [[target]] and [[target|display]] | -| Backlinks | ✅ Done | Auto-tracked in DB | -| MCP server | ✅ Done | read, write, search tools | -| Web UI | 🔧 WIP | Sidebar, markdown, mermaid | -| Git sync | 🔧 WIP | Push/pull with remotes | -| Auth | ⏳ Planned | Token-based access | - -## Architecture - -```mermaid -graph TD - A[Web UI] -->|REST API| B[Go Server] - C[MCP Client] -->|stdio/SSE| B - B --> D[(SQLite + FTS5)] - B --> E[Markdown Files] - B --> F[Git Sync] - F --> G[Remote Repos] -``` From acfb1f7e10a305922ea4a2057d06cf0740372fcb Mon Sep 17 00:00:00 2001 From: aniongithub Date: Mon, 18 May 2026 22:47:33 -0700 Subject: [PATCH 3/3] feat(webui): graph auto-fits on first load, persists pan/zoom - On first mount we subscribe to onEngineStop and call zoomToFit(400, 40) the first time the simulation cools, so the user always sees the full graph rather than landing on a random pre-layout fragment. - onZoomEnd persists the current {k, x, y} transform to localStorage under 'mm-graph-view'. Subsequent loads restore it via centerAt + zoom (duration=0) and skip the auto-fit. - Guarded with a closure-local 'initialViewApplied' flag so the zoom-end events that fire during the auto-fit animation don't clobber the meaningful persisted state, and so later simulation kicks (e.g. theme changes re-feeding data) don't snap the view back to the auto-fit. --- webui/src/GraphView.tsx | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/webui/src/GraphView.tsx b/webui/src/GraphView.tsx index a7a6505..3a3139f 100644 --- a/webui/src/GraphView.tsx +++ b/webui/src/GraphView.tsx @@ -73,6 +73,24 @@ function readCssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; } +interface SavedView { + k: number; + x: number; + y: number; +} + +function readSavedView(): SavedView | null { + try { + const raw = localStorage.getItem('mm-graph-view'); + if (!raw) return null; + const v = JSON.parse(raw); + if (typeof v?.k === 'number' && typeof v?.x === 'number' && typeof v?.y === 'number') { + return v; + } + } catch { /* corrupt entry — ignore */ } + return null; +} + // Append an alpha component to a CSS color. Handles #rgb, #rgba, #rrggbb, // #rrggbbaa, and rgb()/rgba() forms. Falls back to the original color if // the format isn't recognized (so theme tweaks can't crash the renderer). @@ -278,6 +296,34 @@ export function GraphView({ pages, searchQuery, onNavigate, onSearch }: GraphVie else onSearch(n.id); }); + // Restore saved zoom/pan, or fit-all on first layout settle. + // We track this with a closure-local flag so subsequent + // simulation kicks (e.g. theme changes that re-feed data) + // don't keep snapping the view back to the auto-fit. + let initialViewApplied = false; + const savedView = readSavedView(); + if (savedView) { + g.centerAt(savedView.x, savedView.y, 0); + g.zoom(savedView.k, 0); + initialViewApplied = true; + } + g.onEngineStop(() => { + if (!initialViewApplied) { + g.zoomToFit(400, 40); + initialViewApplied = true; + } + }); + g.onZoomEnd((t: { k: number; x: number; y: number }) => { + // Persist the user's view so reloads keep their place. + // Skip until the initial view is applied — the first few + // ZoomEnd events fire during the auto-fit animation and + // would clobber the meaningful saved state. + if (!initialViewApplied) return; + try { + localStorage.setItem('mm-graph-view', JSON.stringify(t)); + } catch { /* quota / unavailable — silent */ } + }); + // Watch for theme class changes; refresh cached colors // and nudge the simulation so the canvas repaints with the new // palette even if the layout has already cooled down.