diff --git a/cmd/mind-map/main.go b/cmd/mind-map/main.go index 8003ea0..9bd1617 100644 --- a/cmd/mind-map/main.go +++ b/cmd/mind-map/main.go @@ -281,6 +281,15 @@ func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh jsonResponse(rw, backlinks) }) + mux.HandleFunc("GET /api/links", func(rw http.ResponseWriter, r *http.Request) { + links, err := w.AllLinks(r.Context()) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + jsonResponse(rw, links) + }) + // Settings API endpoints (UI only, not MCP) mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) { current, err := config.Load(cfgPath) diff --git a/internal/wiki/pages.go b/internal/wiki/pages.go index 584806b..901a811 100644 --- a/internal/wiki/pages.go +++ b/internal/wiki/pages.go @@ -325,6 +325,37 @@ func (w *Wiki) GetBacklinks(ctx context.Context, pagePath string) ([]string, err return w.getBacklinks(ctx, pagePath) } +// Link is a single source→target edge between two pages. +type Link struct { + Source string `json:"source"` + Target string `json:"target"` +} + +// AllLinks returns every wikilink edge in the index. Used by the graph +// view to render reference edges without a per-page round-trip. +func (w *Wiki) AllLinks(ctx context.Context) ([]Link, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + rows, err := w.db.QueryContext(ctx, "SELECT source, target FROM links") + if err != nil { + return nil, err + } + defer rows.Close() + + var links []Link + for rows.Next() { + var l Link + if err := rows.Scan(&l.Source, &l.Target); err != nil { + slog.Warn("all links scan error", slog.Any("error", err)) + continue + } + links = append(links, l) + } + return links, nil +} + // Context returns a WikiContext overview. func (w *Wiki) Context(ctx context.Context) (*WikiContext, error) { if err := ctx.Err(); err != nil { diff --git a/webui/package-lock.json b/webui/package-lock.json index 782a647..b676e2c 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "force-graph": "^1.51.4", "marked": "^15.0.0", "mermaid": "^11.14.0", "preact": "^10.25.0" @@ -163,6 +164,12 @@ "langium": "^4.0.0" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -714,6 +721,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -826,6 +842,16 @@ "node": ">=6.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -919,6 +945,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1281,6 +1319,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -1433,6 +1477,22 @@ "node": ">=12" } }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -1475,6 +1535,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -2000,6 +2066,46 @@ "flat": "cli.js" } }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2196,6 +2302,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2332,6 +2447,18 @@ "dev": true, "license": "MIT" }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/katex": { "version": "0.16.45", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", @@ -3333,6 +3460,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", diff --git a/webui/package.json b/webui/package.json index b9d8b86..ee1f5a7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -4,6 +4,7 @@ "dev": "webpack --mode development --config webpack.config.js --watch" }, "dependencies": { + "force-graph": "^1.51.4", "marked": "^15.0.0", "mermaid": "^11.14.0", "preact": "^10.25.0" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index cebf80a..0c14a6b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,61 +1,14 @@ import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import { api, Page } from './mcp'; import { Logo } from './Logo'; +import { PageBrowser } from './PageBrowser'; +import { GraphView } from './GraphView'; +import { searchTokens, searchRegex, Highlighted } from './search'; 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; @@ -92,7 +45,6 @@ async function requestRestart(): Promise { } export function App() { - const [pages, setPages] = useState([]); const [current, setCurrent] = useState(null); const [editing, setEditing] = useState(false); const [editContent, setEditContent] = useState(''); @@ -157,60 +109,12 @@ export function App() { localStorage.setItem('mm-backlinks-collapsed', String(backlinksCollapsed)); }, [backlinksCollapsed]); - // Sort mode: 'recent' | 'path' | 'title' - type SortMode = 'recent' | 'path' | 'title'; - const sortModes: SortMode[] = ['recent', 'path', 'title']; - const sortLabels: Record = { recent: 'Recent', path: 'A→Z path', title: 'A→Z title' }; - - const SortIcon = ({ mode }: { mode: SortMode }) => { - const props = { width: 16, height: 16, fill: 'currentColor', viewBox: '' as string }; - switch (mode) { - case 'recent': - return ; - case 'path': - return ; - case 'title': - return ; - } - }; - - const [sortMode, setSortMode] = useState(() => { - const saved = localStorage.getItem('mm-sort-mode'); - return (saved === 'path' || saved === 'title') ? saved : 'recent'; - }); - - useEffect(() => { - localStorage.setItem('mm-sort-mode', sortMode); - }, [sortMode]); - - const cycleSortMode = () => { - const idx = sortModes.indexOf(sortMode); - setSortMode(sortModes[(idx + 1) % sortModes.length]); - }; - - const sortPages = (list: Page[]): Page[] => { - const sorted = [...list]; - switch (sortMode) { - case 'path': - sorted.sort((a, b) => a.path.localeCompare(b.path)); - break; - case 'title': - sorted.sort((a, b) => (a.title || a.path).localeCompare(b.title || b.path)); - break; - case 'recent': - default: - // API already returns modified DESC; preserve that order - break; - } - return sorted; - }; - useEffect(() => { document.documentElement.classList.toggle('dark', isDark); localStorage.setItem('mm-theme', isDark ? 'dark' : 'light'); }, [isDark]); - // Load page list + // Load page list (raw, in API order). PageBrowser handles sorting. const [rawPages, setRawPages] = useState([]); const loadPages = async () => { @@ -222,17 +126,36 @@ export function App() { } }; - // Re-sort whenever rawPages or sortMode changes - useEffect(() => { - setPages(sortPages(rawPages)); - }, [rawPages, sortMode]); - // 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. + // either the filtered list (if a query was saved) or the full page + // list. The URL takes precedence over localStorage (handled in the + // hash routing effect below). useEffect(() => { localStorage.setItem('mm-search-query', searchQuery); }, [searchQuery]); + // Mirror searchQuery into the URL hash so the filter is bookmarkable + // and reloads restore it. Live typing replaces in place so we don't + // pollute history with every keystroke — explicit commits (Enter, + // clicking a gray node) call commitSearch() instead to push a new + // entry. + useEffect(() => { + const current = parseHash(); + if (current.query === searchQuery) return; + const newHash = buildHash(current.path, searchQuery); + window.history.replaceState(null, '', '#' + newHash); + }, [searchQuery]); + + // Push a new history entry, then update the query. Use this for + // user actions that "commit" a filter so Back undoes the action. + const commitSearch = (query: string) => { + const current = parseHash(); + if (current.query !== query) { + window.history.pushState(null, '', '#' + buildHash(current.path, query)); + } + setSearchQuery(query); + }; + useEffect(() => { if (searchQuery.trim()) { handleSearch(); @@ -241,27 +164,52 @@ export function App() { } }, []); - // Hash routing - const getHashPath = (): string | null => { - const hash = window.location.hash.replace(/^#\/?/, ''); - return hash || null; + // Hash routing: #/path/to/page?q=filter + // The hash carries two pieces of state: the currently-open page + // (or null for the graph view) and the active search filter. Both + // are reflected in the URL so back/forward navigation and reloads + // restore the same view. + const parseHash = (): { path: string | null; query: string } => { + const raw = window.location.hash.replace(/^#/, ''); + const qIdx = raw.indexOf('?'); + const pathPart = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).replace(/^\//, ''); + const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : ''; + const params = new URLSearchParams(queryPart); + return { path: pathPart || null, query: params.get('q') || '' }; + }; + + const buildHash = (path: string | null, query: string): string => { + const p = path ? `/${path}` : '/'; + const q = query.trim(); + return q ? `${p}?q=${encodeURIComponent(q)}` : p; }; useEffect(() => { const onHash = () => { - const path = getHashPath(); + const { path, query } = parseHash(); if (path) openPage(path); else setCurrent(null); + // setState is a no-op when the value matches, so this won't + // ping-pong with the searchQuery→URL writer effect below. + setSearchQuery(query); }; window.addEventListener('hashchange', onHash); - // Load initial page from hash - const initial = getHashPath(); - if (initial) openPage(initial); + + // Load initial state. URL wins over localStorage: a query in + // the hash means the user is sharing/reloading a filtered view + // and we should honor it exactly. + const initial = parseHash(); + if (initial.path) openPage(initial.path); + if (initial.query) setSearchQuery(initial.query); + return () => window.removeEventListener('hashchange', onHash); }, []); const navigate = (path: string | null) => { - window.location.hash = path ? `/${path}` : '/'; + // Preserve the active search filter across navigations so that + // clicking a result in a filtered sidebar opens the page with + // the same filter still applied. + window.location.hash = buildHash(path, searchQuery); }; const openPage = async (path: string) => { @@ -303,6 +251,12 @@ export function App() { }; const handleSearch = async () => { + // Pressing Enter is an explicit commit — push a history entry + // for the current query so Back undoes the filter session. + const current = parseHash(); + if (current.query !== searchQuery) { + window.history.pushState(null, '', '#' + buildHash(current.path, searchQuery)); + } if (!searchQuery.trim()) { loadPages(); return; @@ -466,7 +420,7 @@ export function App() { } }, [renderedBodyHTML]); - const pageCount = pages.length; + const pageCount = rawPages.length; return (
@@ -476,7 +430,12 @@ export function App() { style={sidebarCollapsed ? undefined : { width: `${sidebarWidth}px` }} > {!sidebarCollapsed && ( <> - -
    - {pages.map(p => ( -
  • navigate(p.path)} - > -
    -
    -
  • - ))} -
+ { setSearchQuery(''); loadPages(); }} + currentPath={current?.path} + onNavigate={navigate} + />
{pageCount} pages
@@ -691,10 +617,12 @@ export function App() { )} ) : ( -
- - select a page -
+ )}
diff --git a/webui/src/GraphView.tsx b/webui/src/GraphView.tsx new file mode 100644 index 0000000..aab4387 --- /dev/null +++ b/webui/src/GraphView.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef, useState, useMemo } from 'preact/hooks'; +import ForceGraph from 'force-graph'; +import { Page, Link, api } from './mcp'; +import { searchTokens, searchRegex } from './search'; + +type EdgeKind = 'path' | 'reference'; + +interface GraphNode { + id: string; + label: string; + isPage: boolean; + page?: Page; + val?: number; + // Mutated by force-graph at runtime: + x?: number; + y?: number; +} + +interface GraphLink { + source: string; + target: string; + kind: EdgeKind; +} + +// Build nodes (one per unique path prefix) and edges (path hierarchy + +// page-to-page references) from the page list and the all-links table. +function buildGraph(pages: Page[], refs: Link[]): { nodes: GraphNode[]; links: GraphLink[] } { + const nodeMap = new Map(); + const ensureNode = (id: string, page?: Page) => { + let n = nodeMap.get(id); + if (!n) { + const segs = id.split('/'); + n = { id, label: segs[segs.length - 1], isPage: false }; + nodeMap.set(id, n); + } + if (page) { + n.isPage = true; + n.page = page; + n.label = page.title || n.label; + } + return n; + }; + + const pathLinks: GraphLink[] = []; + for (const p of pages) { + const parts = p.path.split('/'); + let accum = ''; + let prev = ''; + for (let i = 0; i < parts.length; i++) { + accum = accum ? accum + '/' + parts[i] : parts[i]; + ensureNode(accum, i === parts.length - 1 ? p : undefined); + if (prev) pathLinks.push({ source: prev, target: accum, kind: 'path' }); + prev = accum; + } + } + + const refLinks: GraphLink[] = []; + for (const r of refs) { + // Both endpoints must already be reachable as nodes. If a + // wikilink targets a non-existent page we still want to show + // the broken edge — create the node as a folder placeholder. + ensureNode(r.source); + ensureNode(r.target); + if (r.source !== r.target) { + refLinks.push({ source: r.source, target: r.target, kind: 'reference' }); + } + } + + return { nodes: [...nodeMap.values()], links: [...pathLinks, ...refLinks] }; +} + +function readCssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; +} + +// 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. +function wrapLabel(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + if (!text) return ['']; + if (ctx.measureText(text).width <= maxWidth) return [text]; + + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + for (const w of words) { + const candidate = current ? current + ' ' + w : w; + if (ctx.measureText(candidate).width <= maxWidth) { + current = candidate; + } else { + if (current) lines.push(current); + current = w; + } + } + if (current) lines.push(current); + return lines; +} + +interface GraphViewProps { + pages: Page[]; + searchQuery: string; + onNavigate: (path: string) => void; + onSearch: (query: string) => void; +} + +export function GraphView({ pages, searchQuery, onNavigate, onSearch }: GraphViewProps) { + const containerRef = useRef(null); + const graphRef = useRef(null); + const [refs, setRefs] = useState([]); + const [showPaths, setShowPaths] = useState(() => localStorage.getItem('mm-graph-show-paths') !== 'false'); + const [showRefs, setShowRefs] = useState(() => localStorage.getItem('mm-graph-show-refs') !== 'false'); + + useEffect(() => { + localStorage.setItem('mm-graph-show-paths', String(showPaths)); + }, [showPaths]); + useEffect(() => { + localStorage.setItem('mm-graph-show-refs', String(showRefs)); + }, [showRefs]); + + // Reference edges still come from the full /api/links table — they + // describe wikilink relationships, not search-filtered subsets. + useEffect(() => { + let cancelled = false; + api.allLinks().then(l => { + if (cancelled) return; + setRefs(l); + }).catch(e => console.error('graph links load failed:', e)); + return () => { cancelled = true; }; + }, []); + + const fullGraph = useMemo(() => buildGraph(pages, refs), [pages, refs]); + + // Filter by search query: a node passes if its label, page title, + // or any path segment matches a search token. Folder nodes that + // contain a matching descendant also pass so the path stays + // visible. Edges are kept only when both endpoints pass. + const visibleGraph = useMemo(() => { + const re = searchRegex(searchTokens(searchQuery)); + let allowedIds: Set | null = null; + if (re) { + const matches = new Set(); + for (const n of fullGraph.nodes) { + const haystack = `${n.id} ${n.label} ${n.page?.title || ''}`; + re.lastIndex = 0; + if (re.test(haystack)) { + // Add the node and every ancestor path-prefix so the + // chain back to root remains drawable. + const parts = n.id.split('/'); + let accum = ''; + for (const p of parts) { + accum = accum ? accum + '/' + p : p; + matches.add(accum); + } + } + } + allowedIds = matches; + } + + const nodes = allowedIds + ? fullGraph.nodes.filter(n => allowedIds!.has(n.id)) + : fullGraph.nodes; + + const links = fullGraph.links.filter(l => { + if (l.kind === 'path' && !showPaths) return false; + if (l.kind === 'reference' && !showRefs) return false; + if (allowedIds) { + const src = typeof l.source === 'string' ? l.source : (l.source as any).id; + const tgt = typeof l.target === 'string' ? l.target : (l.target as any).id; + if (!allowedIds.has(src) || !allowedIds.has(tgt)) return false; + } + return true; + }); + + return { nodes, links }; + }, [fullGraph, showPaths, showRefs, searchQuery]); + + // Mount the force-graph instance once; feed it new data on changes. + useEffect(() => { + if (!containerRef.current) return; + + // Hold theme colors in a ref so per-frame callbacks always read + // the latest values when the user toggles light/dark. + const colors = { + accent: readCssVar('--accent'), + fg: readCssVar('--fg'), + fgDim: readCssVar('--fg-dim'), + edgePath: readCssVar('--graph-edge-path'), + edgeRef: readCssVar('--graph-edge-ref'), + font: readCssVar('--font') || 'Inter, sans-serif', + }; + const refreshColors = () => { + colors.accent = readCssVar('--accent'); + colors.fg = readCssVar('--fg'); + colors.fgDim = readCssVar('--fg-dim'); + colors.edgePath = readCssVar('--graph-edge-path'); + colors.edgeRef = readCssVar('--graph-edge-ref'); + colors.font = readCssVar('--font') || 'Inter, sans-serif'; + }; + + const el = containerRef.current; + const g: any = new (ForceGraph as any)(el); + graphRef.current = g; + + g + .width(el.clientWidth || 800) + .height(el.clientHeight || 600) + .backgroundColor('rgba(0,0,0,0)') + .nodeRelSize(2.5) + .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)) + .linkDirectionalArrowRelPos(0.9) + .nodeCanvasObjectMode(() => 'after') + .nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (globalScale < 1.4) return; + const label = node.page?.title || node.label || node.id; + const fontSize = 11 / globalScale; + // Metro-style: light weight, clean sans-serif. + ctx.font = `300 ${fontSize}px ${colors.font}`; + const radius = Math.sqrt(node.isPage ? 1.5 : 0.6) * 2.5; + const gap = 2 / globalScale; + const maxWidth = 80 / globalScale; + const lineHeight = fontSize * 1.15; + const lines = wrapLabel(ctx, label, maxWidth); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = colors.fg; + const startY = (node.y || 0) + radius + gap; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], node.x || 0, startY + i * lineHeight); + } + }) + .onNodeClick((n: GraphNode) => { + if (n.page) onNavigate(n.id); + else onSearch(n.id); + }); + + // 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. + const themeObserver = new MutationObserver(() => { + refreshColors(); + if (graphRef.current) { + // Re-feed the current data to force-graph to trigger a + // repaint with the new colors. + graphRef.current.graphData(graphRef.current.graphData()); + } + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + // Track container size for ResizeObserver-driven re-fit. + const ro = new ResizeObserver(() => { + if (!containerRef.current) return; + g.width(containerRef.current.clientWidth); + g.height(containerRef.current.clientHeight); + }); + ro.observe(el); + + return () => { + ro.disconnect(); + themeObserver.disconnect(); + if (typeof g._destructor === 'function') g._destructor(); + el.innerHTML = ''; + graphRef.current = null; + }; + }, []); + + // Push data into the graph whenever visible-graph changes. + useEffect(() => { + if (!graphRef.current) return; + graphRef.current.graphData(visibleGraph); + }, [visibleGraph]); + + return ( +
+
+ + +
+
+
+ ); +} diff --git a/webui/src/PageBrowser.tsx b/webui/src/PageBrowser.tsx new file mode 100644 index 0000000..7455ace --- /dev/null +++ b/webui/src/PageBrowser.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect, useMemo } from 'preact/hooks'; +import { Page } from './mcp'; +import { PageList } from './PageList'; +import { PageTree } from './PageTree'; +import { SortToggle, SortMode, sortModes } from './SortToggle'; + +interface PageBrowserProps { + pages: Page[]; + searchQuery: string; + onSearchChange: (q: string) => void; + onSearchSubmit: () => void; + onSearchClear: () => void; + currentPath?: string; + onNavigate: (path: string) => void; +} + +// Search input + sort cycle + page-list-or-tree. Owns sortMode and +// the rendered-list-vs-tree decision so the rest of the app doesn't +// have to thread that state around. +export function PageBrowser({ + pages, + searchQuery, + onSearchChange, + onSearchSubmit, + onSearchClear, + currentPath, + onNavigate, +}: PageBrowserProps) { + const [sortMode, setSortMode] = useState(() => { + const saved = localStorage.getItem('mm-sort-mode'); + return (saved === 'path' || saved === 'title') ? saved : 'recent'; + }); + + useEffect(() => { + localStorage.setItem('mm-sort-mode', sortMode); + }, [sortMode]); + + const cycleSortMode = () => { + const idx = sortModes.indexOf(sortMode); + setSortMode(sortModes[(idx + 1) % sortModes.length]); + }; + + const sortedPages = useMemo(() => { + const sorted = [...pages]; + switch (sortMode) { + case 'path': + sorted.sort((a, b) => a.path.localeCompare(b.path)); + break; + case 'title': + sorted.sort((a, b) => (a.title || a.path).localeCompare(b.title || b.path)); + break; + case 'recent': + default: + // API already returns modified DESC; preserve that order. + break; + } + return sorted; + }, [pages, sortMode]); + + return ( + <> + + {sortMode === 'path' ? ( + + ) : ( + + )} + + ); +} diff --git a/webui/src/PageList.tsx b/webui/src/PageList.tsx new file mode 100644 index 0000000..ab6dce0 --- /dev/null +++ b/webui/src/PageList.tsx @@ -0,0 +1,31 @@ +import { Page } from './mcp'; +import { Highlighted } from './search'; + +interface PageListProps { + pages: Page[]; + searchQuery: string; + currentPath?: string; + onNavigate: (path: string) => void; +} + +// Flat list view. Used by the "Recent" and "A→Z title" sort modes. +export function PageList({ pages, searchQuery, currentPath, onNavigate }: PageListProps) { + return ( +
    + {pages.map(p => ( +
  • onNavigate(p.path)} + > +
    + +
    +
    + +
    +
  • + ))} +
+ ); +} diff --git a/webui/src/PageTree.tsx b/webui/src/PageTree.tsx new file mode 100644 index 0000000..f2beab7 --- /dev/null +++ b/webui/src/PageTree.tsx @@ -0,0 +1,165 @@ +import { useState, useEffect, useMemo } from 'preact/hooks'; +import { Page } from './mcp'; +import { Highlighted } from './search'; + +// Tree node used by the "A→Z path" sort mode. Folders are derived from +// path segments; a node carries a real Page when there's an actual page +// at that path (otherwise it's a folder-only intermediate). +interface TreeNode { + path: string; + segment: string; + page?: Page; + children: TreeNode[]; +} + +function buildPageTree(pages: Page[]): TreeNode { + const root: TreeNode = { path: '', segment: '', children: [] }; + const map = new Map(); + map.set('', root); + + for (const p of pages) { + const parts = p.path.split('/'); + let accum = ''; + let parent = root; + for (let i = 0; i < parts.length; i++) { + accum = accum ? accum + '/' + parts[i] : parts[i]; + let node = map.get(accum); + if (!node) { + node = { path: accum, segment: parts[i], children: [] }; + map.set(accum, node); + parent.children.push(node); + } + if (i === parts.length - 1) node.page = p; + parent = node; + } + } + + // Folders first within a level, then alphabetical by segment. + const sortRec = (n: TreeNode) => { + n.children.sort((a, b) => { + const af = a.children.length > 0 ? 0 : 1; + const bf = b.children.length > 0 ? 0 : 1; + if (af !== bf) return af - bf; + return a.segment.localeCompare(b.segment); + }); + n.children.forEach(sortRec); + }; + sortRec(root); + return root; +} + +interface PageTreeProps { + pages: Page[]; + searchQuery: string; + currentPath?: string; + onNavigate: (path: string) => void; +} + +// Outlook-style tree view. Used by the "A→Z path" sort mode. +// - One row per path segment; indent reflects depth. +// - Folders (nodes with children) show a chevron that rotates on toggle. +// - Clicking a folder name that also has a real page navigates to it; +// clicking a folder name without a page just toggles expansion. +// - The chevron always toggles, never navigates. +// - Collapsed state is persisted in localStorage so reloads keep the +// user's expansion preferences. Default: everything expanded. +export function PageTree({ pages, searchQuery, currentPath, onNavigate }: PageTreeProps) { + const tree = useMemo(() => buildPageTree(pages), [pages]); + + const [collapsed, setCollapsed] = useState>(() => { + try { + const raw = localStorage.getItem('mm-tree-collapsed'); + if (raw) return new Set(JSON.parse(raw)); + } catch { /* ignore corrupt storage */ } + return new Set(); + }); + + useEffect(() => { + localStorage.setItem('mm-tree-collapsed', JSON.stringify([...collapsed])); + }, [collapsed]); + + const toggle = (path: string) => { + setCollapsed(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( +
    + {tree.children.map(child => ( + + ))} +
+ ); +} + +interface TreeRowProps { + node: TreeNode; + depth: number; + collapsed: Set; + onToggle: (path: string) => void; + currentPath?: string; + onNavigate: (path: string) => void; + searchQuery: string; +} + +function TreeRow({ node, depth, collapsed, onToggle, currentPath, onNavigate, searchQuery }: TreeRowProps) { + const hasChildren = node.children.length > 0; + const isCollapsed = collapsed.has(node.path); + const isActive = currentPath === node.path && node.page !== undefined; + + const handleRowClick = () => { + if (node.page) onNavigate(node.path); + else if (hasChildren) onToggle(node.path); + }; + + const handleChevronClick = (e: MouseEvent) => { + e.stopPropagation(); + onToggle(node.path); + }; + + const label = node.page?.title || node.segment; + + return ( + <> +
  • + + + + +
  • + {hasChildren && !isCollapsed && node.children.map(child => ( + + ))} + + ); +} diff --git a/webui/src/SortToggle.tsx b/webui/src/SortToggle.tsx new file mode 100644 index 0000000..d6ad888 --- /dev/null +++ b/webui/src/SortToggle.tsx @@ -0,0 +1,40 @@ +export type SortMode = 'recent' | 'path' | 'title'; + +export const sortModes: SortMode[] = ['recent', 'path', 'title']; + +export const sortLabels: Record = { + recent: 'Recent', + path: 'A→Z path', + title: 'A→Z title', +}; + +function SortIcon({ mode }: { mode: SortMode }) { + const props = { width: 16, height: 16, fill: 'currentColor', viewBox: '' as string }; + switch (mode) { + case 'recent': + return ; + case 'path': + return ; + case 'title': + return ; + } +} + +interface SortToggleProps { + mode: SortMode; + onCycle: () => void; +} + +// Cycling sort indicator. Click to advance: recent → path → title → recent. +// Purely presentational — owns no state. +export function SortToggle({ mode, onCycle }: SortToggleProps) { + return ( + + ); +} diff --git a/webui/src/mcp.ts b/webui/src/mcp.ts index 6a86aab..df9bc3a 100644 --- a/webui/src/mcp.ts +++ b/webui/src/mcp.ts @@ -76,6 +76,17 @@ class APIClient { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } + + async allLinks(): Promise { + const res = await fetch('/api/links'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) || []; + } +} + +export interface Link { + source: string; + target: string; } export const api = new APIClient(); diff --git a/webui/src/search.tsx b/webui/src/search.tsx new file mode 100644 index 0000000..65a0d13 --- /dev/null +++ b/webui/src/search.tsx @@ -0,0 +1,53 @@ +import { Page } from './mcp'; + +// 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 +export 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; +} + +export 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 . +// For places that render user-supplied text directly (sidebar items, +// page header). The body uses highlightHTML (in App.tsx) instead +// because it needs to highlight inside marked-rendered HTML. +export 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})}; +} + +export type { Page }; diff --git a/webui/src/styles.css b/webui/src/styles.css index a573b1d..8b8ba1c 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -10,6 +10,8 @@ --tile-bg: #f2f2f2; --code-bg: #f5f5f5; --border: #e0e0e0; + --graph-edge-path: #b0b0b0; + --graph-edge-ref: #005a9e; --font: 'Inter', -apple-system, system-ui, sans-serif; --font-mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; } @@ -24,6 +26,8 @@ html.dark { --tile-bg: #1a1a1a; --code-bg: #1a1a1a; --border: #333333; + --graph-edge-path: #555555; + --graph-edge-ref: #4cc2ff; } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -80,6 +84,16 @@ html, body, #root { display: none; } +.sidebar-header-text { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.sidebar-header-text:hover { + color: var(--accent); +} + .sidebar-collapse-btn { background: none; border: none; @@ -219,6 +233,64 @@ html, body, #root { white-space: nowrap; } +/* --- Page tree (A→Z path sort) --- */ + +.page-tree { + padding: 4px 0; +} + +.tree-row { + display: flex; + align-items: center; + gap: 6px; + padding-top: 4px; + padding-bottom: 4px; + padding-right: 8px; + /* padding-left is inline-styled per row, scaled by depth */ + font-size: 14px; + cursor: pointer; + transition: background 0.1s; + border-left: 3px solid transparent; +} + +.tree-row:hover { background: var(--tile-bg); } + +.tree-row.active { + background: var(--tile-bg); + border-left-color: var(--accent); + font-weight: 500; +} + +.tree-row.folder-only { + color: var(--fg-muted); +} + +.tree-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; + height: 12px; + color: var(--fg-dim); + font-size: 10px; + line-height: 1; + transition: transform 0.12s ease; +} + +/* Leaf rows keep the chevron's width so labels line up with siblings. */ +.tree-row.leaf .tree-chevron { visibility: hidden; } + +.tree-chevron.open { transform: rotate(90deg); } + +.tree-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* --- Main content --- */ .main { @@ -615,6 +687,76 @@ mark { opacity: 0.6; } +/* --- Graph view (shown when no page is selected) --- */ + +.graph-view { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + min-height: 0; +} + +.graph-canvas { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.graph-canvas canvas { + display: block; +} + +.graph-toolbar { + position: absolute; + top: 16px; + right: 16px; + z-index: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + background: var(--tile-bg); + border: 1px solid var(--border); + border-radius: 0; + font-family: var(--font); + font-size: 11px; + font-weight: 300; + letter-spacing: 0.5px; + color: var(--fg); +} + +.graph-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.graph-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + +.graph-swatch { + display: inline-block; + width: 18px; + height: 2px; + border-radius: 1px; +} + +.graph-swatch-path { + background: var(--graph-edge-path); + height: 1px; +} + +.graph-swatch-ref { + background: var(--graph-edge-ref); + height: 2px; +} + /* --- Mobile --- */ @media (max-width: 767px) {