Bỏ qua để đến nội dung

Frontend architecture

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

Oleafly’s frontend is a pnpm workspace: the app shell lives at the repo root (src/), and reusable frontend code lives in packages/* as twelve focused packages. The Tauri backend lives in src-tauri/, while reusable AI provider and agent code lives in crates/oleafly-agent/. See the README’s architecture diagram for the frontend/Rust/sidecar picture and Development for day-to-day commands.

This document covers: what each package is, the dependency-inversion rules that keep them pure, the contribution registry that wires features into the shell, how module resolution works, and how to add or extract a package.

Every feature used to reach directly into the app’s Zustand stores, the Tauri IPC client, and the shadcn UI kit. That made features inseparable from the shell: nothing could be tested, reused, or reasoned about in isolation. The workspace inverts that. Packages hold the feature logic and declare narrow interfaces called ports for everything they need. The app implements those ports over its stores and Tauri client and injects them. The app depends on packages, never the reverse.

Packages are consumed as TypeScript source (their main points at src/index.ts). There is no build step, no publishing, and no version skew: Vite, Vitest, and tsc all resolve @oleafly/* straight to the package sources via aliases (see Module resolution).

Package What it is Injected ports Notable deps
@oleafly/latex Pure LaTeX/figure logic: the diagram model, TikZ serializer (modelToTikz, embedded-model round-trip), standalone-doc builder (buildStandaloneDoc), figure name/byte helpers none (pure) none
@oleafly/ai-core Provider display metadata and frontend model selection, kept in parity with the Rust catalog none (pure) none
@oleafly/latex-intelligence Validated types and data access for the LaTeX corpus used by editor intelligence none (pure) none
@oleafly/registry The contribution registry: rail tabs, palette/omnibar commands, AI toolsets (details below) none (contributions carry their own behavior) react (types)
@oleafly/preflight The preflight engine: document typing, source/PDF/reference rules, ATS parsing, accessible-export prep, scoring. pdf-extract ships as the @oleafly/preflight/pdf-extract subpath so pdf.js stays out of node test graphs none (pure) pdfjs-dist (subpath only)
@oleafly/pdf-to-latex The local PDF-to-LaTeX reconstruction pipeline: line grouping, heading and column detection, style inference, symbol mapping, figure extraction, escaping, and document assembly. The pdf.js adapter ships as the @oleafly/pdf-to-latex/pdf-adapter subpath for the same node-test-graph reason none (pure) pdfjs-dist (subpath only)
@oleafly/wysiwyg The constrained visual editor core: shared schema, LaTeX and Markdown parse/serialize, and the raw inline/block nodes that keep unsupported source visible instead of discarding it none (pure) none
@oleafly/editor The CodeMirror LaTeX core: CodeMirrorEditor, the editor-view controller (insertAtCursor, gotoLine, …), language + completions, theme, folding, linters, latex-mask, math preview, search panel, spelling/grammar linters EditorHost (document model + settings, hook-shaped), SpellHost (spellchecker/Harper/dictionary), setBibKeysProvider, extraExtensions/extraKeymap @codemirror/*, @replit/codemirror-vim, katex
@oleafly/preview The virtualized pdf.js viewer (PdfViewer), the SyncTeX page controller (gotoRect, pageClickToBp), the pdf.js worker, and the WebView polyfills (@oleafly/preview/polyfills, imported first in main.tsx) onOpenLink prop (system-browser links), setPdfLogger pdfjs-dist
@oleafly/diagram The visual diagram composer: React Flow canvas, shape inspector, Draw/Code tabs, compile-preview-insert flow DiagramHost (compile, file IO, editor insert, AI fix), DiagramKit context (Button/Tooltip/Select/toast/theme) @xyflow/react, @codemirror/*, @oleafly/latex
@oleafly/ai-tools Project, research, memory, and figure tool definitions with approval-aware mutations AiToolsHost (files, compile, symbol index, figure pipeline, editor) @oleafly/latex
@oleafly/templates The new-project template gallery (two-step wizard, categories, previews, one-time asset downloads with progress) TemplatesHost (previews, asset downloads, logging), TemplatesKit (Button/Tooltip), color options as props none beyond UI utils

Dependency direction, strictly enforced:

app (src/) ──▶ @oleafly/* ──▶ dependency-free leaf packages

A package must never import from src/ (@/…), a Zustand store, @tauri-apps/*, or the app’s UI components. The purity check is part of extraction hygiene:

Terminal window
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src # must return nothing

Packages declare interfaces. The app implements them once, usually as a module-level adapter object closing over its stores. Four recurring shapes:

Host ports: one interface XHost per package covering the services it needs. The app builds the adapter with imperative store reads:

// src/lib/ai-tools.ts (app adapter, abridged)
const HOST: AiToolsHost = {
getProjectId: () => useFilesStore.getState().projectId,
readFileContent, // Tauri client passthrough
recompile: () => useCompileStore.getState().recompile(),
insertAtCursor, // editor controller
// ...
};
export const createOleaflyTools = (opts?) => createOleaflyToolsCore(HOST, opts);

Keeping app-only concerns inside the adapter also keeps dependencies out of packages. Provider credentials and requests stay in Rust, so frontend packages receive only the state and results their interfaces require.

UI kits: a React context (or prop) of structurally-typed components: Button: ComponentType<{ variant?: …; onClick?: () => void }>. The app passes its shadcn components through unchanged because the smaller structural type is compatible. Kits also carry toast and a useThemeMode() hook.

Hook-shaped ports: for packages that must subscribe to app state. The editor’s EditorHost has hook members (useActivePath(), useSettings()) plus imperative twins (getActivePath()) for event listeners. Rules: the host object must be module-level (stable identity) and its use* members must call hooks unconditionally.

Module singletons: for cross-cutting glue, setPdfLogger(fn), setSpellHost(host), setBibKeysProvider(fn) have no-op defaults and are installed by the app shim as an import side effect.

Every extraction left a thin file at the old @/… path that re-exports (or wraps) the package, so consumers and existing vi.mock(...) paths never churned. Examples: src/components/editor/cm/controller.ts (re-export), src/components/pdf/PdfViewer.tsx (wrapper injecting the shell-plugin link opener), src/lib/ai-tools.ts (adapter + original factory signatures). New code may import @oleafly/* directly. Shims exist for compatibility, not as the preferred path.

@oleafly/registry is how features plug into the app shell without the shell knowing them. Three contribution kinds:

  • Rail tabs (registerRailTab): icon, section (explore / review / assist, dividers drawn between non-empty sections), optional when(ctx) visibility, optional useBadge() hook (e.g. the Git change count), and the sidebar panel component.
  • Commands (registerCommand): served to the command palette (Cmd+K, grouped by group) and/or the omnibar (Cmd+P) via surfaces. Labels and icons may be functions of the context (e.g. “Switch to light theme”).
  • AI toolsets (registerAiToolset): a chat mode ("chat", "figure") mapped to a toolset factory. The chat panel looks up the active mode’s toolset instead of hard-coding one.

AppContext ({ projectId, projectKind, theme }) is built by each rendering surface from its own subscriptions, so when/label conditions stay reactive.

Registration is static: src/contributions/ registers every built-in (tabs, commands, toolsets) and main.tsx calls registerContributions() once before the shell mounts. Rail, Sidebar, CommandPalette, SearchOmnibar, and ChatPanel render only what is registered.

To add a feature’s tab or command, register it. No shell edits:

// src/contributions/my-feature.tsx (called from src/contributions/index.ts)
registerRailTab({
id: "review", label: "Review", icon: MessageSquare,
section: "review", order: 55,
when: (ctx) => ctx.projectKind !== "image",
panel: ReviewPanel,
});
registerCommand({
id: "omnibar.review", surfaces: ["omnibar"], order: 45,
label: "Open review", keywords: "review comments",
run: () => useSettingsStore.getState().setRailTab("review"),
});

This is an internal API, not a plugin SDK: shapes may change freely, and nothing loads dynamically. Deferred until a real consumer exists: export-menu formats, settings schemas, and project-kind behaviors as contributions.

Because packages are consumed as source, the same alias is declared in three places, and all three must stay in sync when a package is added:

  1. tsconfig.jsoncompilerOptions.paths. Subpaths such as @oleafly/preflight/pdf-extract need their own explicit entry.
  2. vite.config.tsresolve.alias. String aliases prefix-match, so subpaths resolve without another entry.
  3. vitest.config.ts → the same resolve.alias, plus test.include must cover packages/**/*.test.ts. Otherwise moved tests silently drop out of the run.

Also: the root package.json depends on each package via "workspace:*", and src/styles/globals.css has @source "../../packages" so Tailwind v4 scans package sources for class names.

Vite-only imports (?worker&url workers, CSS imports) work unchanged from package sources because the aliases point at source. Keep anything that drags in pdf.js out of barrels that node-environment tests import: that is why pdf-extract and the preview package’s viewer are reachable only where the DOM exists.

  • Tests live next to the source (packages/*/src/*.test.ts) and run in the same pnpm test as app tests. Watch the test count when moving files.
  • Dependencies are declared loosely ("react": "*"). pnpm dedupes against the root, where the real version floors live. Don’t declare deps the package doesn’t import.
  • Gates for any package change: pnpm exec tsc --noEmit, pnpm build, pnpm test (unchanged count), and the purity grep above.
  • Extracting something new: move files with git mv (history follows), define the ports, leave shims at old paths, wire the three aliases, then run the gates. Don’t extract a feature while actively iterating on it.
  • Licensing: every package is AGPL-3.0-or-later, same as the app.

src/ keeps everything that is genuinely the frontend app: the Zustand stores, the Tauri IPC client (src/lib/tauri.ts), the shadcn UI kit, layout/shell components, the port adapters and shims, src/contributions/, and feature glue that is store-coupled by nature (the preflight panel UI, the inline-AI editing plugin, and code or hover intelligence injected into the editor as extraExtensions). Tauri commands and operating-system integration stay in src-tauri/. Provider clients, streaming, and agent orchestration belong in crates/oleafly-agent/ so secrets and provider requests remain outside the webview.