mq-serve 0.1.16

Browser-based Markdown viewer with mq query support
import { useState, useEffect, useCallback, useRef } from 'react'
import type { FileGroup, SearchResult, Session } from './types'
import { loadSession, saveSession } from './store/session'
import { useWebSocket } from './hooks/useWebSocket'
import { useDropzone } from './hooks/useDropzone'
import { renderMarkdown, type ParseResult } from './lib/markdown'
import { preprocessMdx } from './lib/mdx'
import { getTheme, applyTheme } from './lib/themes'
import QueryBar from './components/QueryBar'
import Toolbar from './components/Toolbar'
import Sidebar from './components/Sidebar'
import Preview from './components/Preview'

interface InMemoryFile { name: string; content: string }

function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
  return debounced
}

export default function App() {
  const [session, setSession] = useState<Session>(loadSession)
  const [groups, setGroups] = useState<FileGroup[]>([])
  const [rawContent, setRawContent] = useState('')
  const [queryError, setQueryError] = useState('')
  const [parseResult, setParseResult] = useState<ParseResult | null>(null)
  const [searchResults, setSearchResults] = useState<SearchResult[]>([])
  const [inMemoryFiles, setInMemoryFiles] = useState<InMemoryFile[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const rawContentRef = useRef(rawContent)
  useEffect(() => { rawContentRef.current = rawContent }, [rawContent])
  const [systemPrefersDark, setSystemPrefersDark] = useState(
    () => window.matchMedia('(prefers-color-scheme: dark)').matches
  )

  const debouncedQuery = useDebounce(session.query, 350)

  const effectiveThemeId =
    session.theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : session.theme
  const themePreset = getTheme(effectiveThemeId)

  // Sync system theme preference
  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches)
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  // Apply theme to document
  useEffect(() => {
    applyTheme(themePreset)
  }, [themePreset])

  // Apply font size to document
  useEffect(() => {
    document.documentElement.setAttribute('data-font-size', session.fontSize)
  }, [session.fontSize])

  const updateSession = useCallback((partial: Partial<Session>) => {
    setSession((prev) => {
      const next = { ...prev, ...partial }
      saveSession(next)
      return next
    })
  }, [])

  const fetchGroups = useCallback(async () => {
    try {
      const res = await fetch('/api/files')
      const data = await res.json() as { groups: FileGroup[] }
      setGroups(data.groups)
    } catch {
      // server unreachable
    }
  }, [])

  // Initialize: fetch file list, restore saved path or auto-select first file
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => {
    const init = async () => {
      try {
        const res = await fetch('/api/files')
        const data = await res.json() as { groups: FileGroup[] }
        setGroups(data.groups)

        const allFiles = data.groups.flatMap((g) => g.files)
        if (allFiles.length === 0) return

        const savedPath = session.currentPath
        const targetPath =
          savedPath && allFiles.some((f) => f.path === savedPath)
            ? savedPath
            : allFiles[0].path

        loadFile(targetPath)
      } catch {
        // server unreachable
      }
    }
    init()
  }, []) // intentionally on mount only

  const loadFile = useCallback(async (path: string) => {
    setIsLoading(true)
    // Add to open tabs if not already open
    setSession((prev) => {
      if (prev.openPaths.includes(path)) return prev
      const next = { ...prev, openPaths: [...prev.openPaths, path] }
      saveSession(next)
      return next
    })
    const mem = inMemoryFiles.find((f) => f.name === path)
    if (mem) {
      if (mem.content === rawContentRef.current) setIsLoading(false)
      setRawContent(mem.content)
      updateSession({ currentPath: path })
      return
    }
    try {
      const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`)
      if (res.ok) {
        const content = await res.text()
        if (content === rawContentRef.current) setIsLoading(false)
        setRawContent(content)
        updateSession({ currentPath: path })
      } else {
        setIsLoading(false)
      }
    } catch {
      setIsLoading(false)
    }
  }, [inMemoryFiles, updateSession])

  const closeTab = useCallback((path: string) => {
    setSession((prev) => {
      const idx = prev.openPaths.indexOf(path)
      const newOpen = prev.openPaths.filter((p) => p !== path)
      let newCurrent = prev.currentPath
      if (prev.currentPath === path) {
        // Switch to adjacent tab
        newCurrent = newOpen[Math.max(0, idx - 1)] ?? newOpen[0] ?? null
      }
      const next = { ...prev, openPaths: newOpen, currentPath: newCurrent }
      saveSession(next)
      return next
    })
  }, [])

  const switchTab = useCallback((path: string) => {
    updateSession({ currentPath: path })
    loadFile(path)
  }, [updateSession, loadFile])

  // Run mq query when content or query changes, then render markdown in one step
  useEffect(() => {
    if (!rawContent) {
      setIsLoading(false)
      return
    }
    const isMdx = session.currentPath?.endsWith('.mdx') ?? false
    const content = isMdx ? preprocessMdx(rawContent) : rawContent

    if (debouncedQuery.trim() === '.' || debouncedQuery.trim() === '') {
      setQueryError('')
      renderMarkdown(content, themePreset.mode).then((result) => {
        setParseResult(result)
        setIsLoading(false)
      }).catch(console.error)
      return
    }

    fetch('/api/query', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content, query: debouncedQuery }),
    })
      .then((r) => r.json())
      .then((data: { result?: string; error?: string }) => {
        if (data.error) {
          setQueryError(data.error)
          setIsLoading(false)
        } else {
          const result = data.result ?? ''
          setQueryError('')
          return renderMarkdown(result, themePreset.mode).then((parsed) => {
            setParseResult(parsed)
            setIsLoading(false)
          })
        }
      })
      .catch(() => {
        setQueryError('Network error')
        setIsLoading(false)
      })
  }, [rawContent, debouncedQuery, session.currentPath, themePreset.mode])

  // WebSocket live reload
  const wsStatus = useWebSocket(
    useCallback(
      (data: unknown) => {
        const msg = data as { type: string; path?: string }
        if (msg.type === 'change') {
          fetchGroups()
          if (msg.path && msg.path === session.currentPath) {
            loadFile(msg.path)
          }
        } else if (msg.type === 'reload') {
          // A new file was added to the server – just refresh the sidebar.
          fetchGroups()
        }
      },
      [fetchGroups, loadFile, session.currentPath]
    )
  )

  // OS file drag-and-drop
  useDropzone(
    useCallback(
      (name: string, content: string) => {
        setInMemoryFiles((prev) => {
          const idx = prev.findIndex((f) => f.name === name)
          if (idx >= 0) {
            const next = [...prev]
            next[idx] = { name, content }
            return next
          }
          return [...prev, { name, content }]
        })
        setRawContent(content)
        updateSession({ currentPath: name })
      },
      [updateSession]
    )
  )

  const handleSearch = useCallback(async (query: string) => {
    if (!query.trim()) {
      setSearchResults([])
      return
    }
    try {
      const res = await fetch('/api/search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query }),
      })
      const results = await res.json() as SearchResult[]
      setSearchResults(results)
    } catch {
      // ignore
    }
  }, [])

  const handleRestart = useCallback(async () => {
    try {
      await fetch('/api/restart', { method: 'POST' })
    } finally {
      window.location.reload()
    }
  }, [])

  return (
    <div className={`app${session.sidebarOpen ? '' : ' sidebar-hidden'}`}>
      <title>{session.currentPath ? `mq-serve | ${session.currentPath}` : 'mq-serve'}</title>
      <QueryBar
        query={session.query}
        onQueryChange={(q) => updateSession({ query: q })}
        onClear={() => updateSession({ query: '.' })}
        wsStatus={wsStatus}
        queryError={queryError}
      />
      <Toolbar
        theme={session.theme}
        effectiveThemeId={effectiveThemeId}
        onThemeChange={(t) => updateSession({ theme: t })}
        sidebarOpen={session.sidebarOpen}
        onSidebarOpenChange={(v) => updateSession({ sidebarOpen: v })}
        wideView={session.wideView}
        onWideViewChange={(w) => updateSession({ wideView: w })}
        showToc={session.showToc}
        onShowTocChange={(t) => updateSession({ showToc: t })}
        showRaw={session.showRaw}
        onShowRawChange={(r) => updateSession({ showRaw: r })}
        fontSize={session.fontSize}
        onFontSizeChange={(f) => updateSession({ fontSize: f })}
        rawContent={rawContent}
        parseResult={parseResult}
        onRestart={handleRestart}
      />
      <Sidebar
        groups={groups}
        inMemoryFiles={inMemoryFiles}
        currentPath={session.currentPath}
        onFileSelect={loadFile}
        session={session}
        onSessionUpdate={updateSession}
        searchResults={searchResults}
        onSearch={handleSearch}
      />
      <Preview
        parseResult={parseResult}
        rawContent={rawContent}
        showRaw={session.showRaw}
        wideView={session.wideView}
        showToc={session.showToc}
        onShowTocChange={(t) => updateSession({ showToc: t })}
        isLoading={isLoading}
        openPaths={session.openPaths}
        currentPath={session.currentPath}
        onTabSelect={switchTab}
        onTabClose={closeTab}
      />
    </div>
  )
}