Eidos File

基于 Eidos File 构建

Eidos File 0.1.0 提供两个公共层:与宿主无关的 headless runtime,以及 React View Host。你的应用负责文件选择、权限、保存决策与可信视图代码;package 负责可携带的数据和视图契约。

安装

pnpm add @eidos.space/eidos-file@0.1.0 \
  @eidos.space/eidos-file-ui@0.1.0 \
  @glideapps/glide-data-grid marked@^4 react react-dom

在应用中引入一次编译后的样式。Consumer 不需要配置 Tailwind。

import "@eidos.space/eidos-file-ui/styles.css"

浏览器构建需要 SQLite WASM 与 top-level await。Vite 项目应添加 vite-plugin-wasmvite-plugin-top-level-await,并从依赖预构建中排除 @sqlite.org/sqlite-wasm

嵌入 View Host

创建一个 EidosFileSession,连接浏览器 adapter,再通过 React Provider 提供给视图:

import { useEffect, useMemo } from "react"
import { EidosFileSession } from "@eidos.space/eidos-file"
import {
  EidosFileBrowserRuntime,
  IndexedDbEidosFileRecoveryStore,
  pickBrowserEidosFile,
} from "@eidos.space/eidos-file/browser"
import {
  EidosFileProvider,
  EidosFileViewHost,
} from "@eidos.space/eidos-file-ui"
import "@eidos.space/eidos-file-ui/styles.css"

export function EidosFileEditor() {
  const session = useMemo(
    () =>
      new EidosFileSession(
        new EidosFileBrowserRuntime(),
        new IndexedDbEidosFileRecoveryStore()
      ),
    []
  )

  useEffect(() => () => void session.close(), [session])

  async function openFile() {
    const handle = await pickBrowserEidosFile()
    if (handle) await session.open(handle)
  }

  return (
    <EidosFileProvider session={session} themeName="light">
      <button type="button" onClick={() => void openFile()}>
        打开 .eidos 文件
      </button>
      <EidosFileViewHost />
    </EidosFileProvider>
  )
}

EidosFileViewHost 渲染当前持久化视图。文件选择、视图切换、保存与冲突操作,以及 session cleanup 仍由宿主负责。

构建类型安全的视图

Renderer 会收到异步 data source、类型化的 table/view descriptor、规范化 query、selection、commands 与明确的 capabilities。它不会获得 SQLite、原始文件字节、routes、Zustand store 或 Electron IPC。

import { useEffect, useState } from "react"
import type { EidosFileRow } from "@eidos.space/eidos-file"
import {
  defineEidosFileView,
  type EidosFileViewRendererProps,
} from "@eidos.space/eidos-file-ui"

function Timeline({
  source,
  table,
  query,
  reloadToken,
  disabled,
  onMutation,
  onError,
}: EidosFileViewRendererProps) {
  const [rows, setRows] = useState<EidosFileRow[]>([])

  useEffect(() => {
    let active = true
    source
      .getPage(table.table.id, 0, 50, query)
      .then((page) => active && setRows(page.rows))
      .catch(onError)
    return () => {
      active = false
    }
  }, [onError, query, reloadToken, source, table.table.id])

  return (
    <ol aria-label={`${table.table.name} timeline`}>
      {rows.map((row) => (
        <li key={String(row._id)}>
          <time>{String(row.due ?? "未排期")}</time>
          <button
            type="button"
            disabled={disabled}
            onClick={async () => {
              try {
                const result = await source.updateRow(
                  table.table.id,
                  String(row._id),
                  { status: "Done" }
                )
                onMutation?.(result)
              } catch (error) {
                onError?.(error)
              }
            }}
          >
            {String(row.title ?? "未命名")}
          </button>
        </li>
      ))}
    </ol>
  )
}

export const timelineView = defineEidosFileView({
  type: "com.example.timeline",
  label: "Timeline",
  description: "在交付时间轴上呈现带日期的记录。",
  renderer: Timeline,
  create: { defaultName: "Timeline" },
})

使用持久化 type 注册 renderer:

<EidosFileViewHost
  renderers={{ "com.example.timeline": timelineView.renderer }}
/>

创建持久化视图时,调用 session 的公共 data source,再把返回的 snapshot 标记为 dirty:

const source = session.getState().source
const next = await source?.createView(tableId, {
  name: timelineView.create.defaultName,
  type: timelineView.type,
})

if (next) session.markDirty(next)

第三方视图应使用带命名空间的 type。其他宿主未安装该 renderer 时,可以回退到 Grid,但必须保留 type 与兼容 JSON 的 view.properties

保存、冲突与恢复

完成 mutation 后调用 session.markDirty(nextSnapshot?)。保存会把打开文件时记录的 revision 与当前原文件比较,执行 compare-and-swap 写入。

import { EidosFileHostError } from "@eidos.space/eidos-file"

session.markDirty()
await session.checkpoint()

try {
  await session.save()
} catch (error) {
  if (error instanceof EidosFileHostError && error.code === "conflict") {
    // 提供重新加载、明确覆盖或另存为。
  }
}

不要静默解决冲突。Chromium 宿主获得授权后可以写回原文件;其他浏览器应提供另存为或下载副本。文件字节始终留在浏览器内。

公共边界

负责内容
@eidos.space/eidos-file descriptor、handler、runtime、分页、mutation、session state、冲突与恢复契约
@eidos.space/eidos-file/browser 浏览器 handle、SQLite WASM、picker、下载与 IndexedDB recovery
@eidos.space/eidos-file-ui provider、hooks、View Host、共享视图、commands、selection 与作用域样式
你的宿主 权限、导航、持久化决策、错误 UI 与可信 renderer import

React 视图是可信应用代码,不是运行在沙箱中的 Eidos Space Extension。应像审查其他依赖一样审查它,并且只提供必要的宿主能力。

编辑工具