Răsfoiți Sursa

feat(query): implement ::query component with task-state indexing and PM widget rendering

Wire the Comark component pipeline end-to-end — from Lezer parsing
through PM decorations to a live query widget backed by GraphService.

- comark-registry.ts: keyed widget factory (registerWidgetFactory/getWidgetFactory)
- useMarkdownDecorations.ts: applyQueryDecorations — two-state rendering
  (hidden source + widget when blurred, raw syntax when focused)
- query-renderers/query.ts: renderer factory, produces BlockDecoration
- query-renderers/query-widget.ts: plain-DOM widget with loading skeleton,
  empty state (per task type), and result list
- comark-setup.ts: registration on ws.db ready
- indexer.ts: index task state as queryable property (task-state:: TODO)
- parse.ts: extractTaskState + cross-reference comment to block-classifier
- style.css: query widget styles aligned with Nuxt UI tokens
- Export registerWidgetFactory, findChildByTypeName from @enesis/editor

WIP — result links don't navigate yet (PM intercepts clicks).
Zander Hawke 6 ore în urmă
părinte
comite
ff310d7839

+ 12 - 0
apps/tauri/src/app.vue

@@ -16,6 +16,8 @@ import { Menu, MenuItem, Submenu } from "@tauri-apps/api/menu"
 import { nextTick, watch } from "vue"
 import WelcomeScreen from "~/components/WelcomeScreen.vue"
 import { useFileSystem } from "~/composables/useFileSystem"
+import { createGraphService } from "~/services/graph"
+import { setupComarkComponents } from "~/lib/comark-setup"
 import type { InitResult } from "~/stores/workspace"
 import { useWorkspaceStore } from "~/stores/workspace"
 
@@ -186,6 +188,16 @@ watch(
   },
   { deep: true },
 )
+
+// Set up Comark components when db becomes available after init
+watch(
+  () => workspace.db,
+  (db) => {
+    if (db && workspace.workspacePath) {
+      setupComarkComponents(createGraphService(db))
+    }
+  },
+)
 </script>
 
 <template>

+ 185 - 0
apps/tauri/src/lib/comark-renderers/query-widget.ts

@@ -0,0 +1,185 @@
+/**
+ * Plain-DOM widget for rendering ::query{...} results inside a ProseMirror
+ * decoration. No Vue instance — just direct DOM manipulation.
+ *
+ * Lifecycle:
+ * 1. Constructor: creates container div, shows loading skeleton
+ * 2. Async query resolves: replaces skeleton with results or empty state
+ * 3. Destroy: cleans up any pending work
+ */
+import type { GraphService } from "~/services/graph"
+
+export interface QueryAttrs {
+  type?: string
+  tag?: string
+  property?: string
+  equals?: string
+}
+
+export interface QueryResult {
+  blockId: string
+  pagePath: string
+  title: string
+}
+
+const EMPTY_MESSAGES: Record<
+  string,
+  { icon: string; title: string; description: string }
+> = {
+  TODO: {
+    icon: "i-lucide-list-checks",
+    title: "No TODO tasks",
+    description: "Type TODO at the start of a line to create a task.",
+  },
+  DOING: {
+    icon: "i-lucide-loader",
+    title: "No in-progress tasks",
+    description: "Use DOING to mark a task as in progress.",
+  },
+  DONE: {
+    icon: "i-lucide-check-circle",
+    title: "No completed tasks",
+    description: "Completed tasks will appear here.",
+  },
+  LATER: {
+    icon: "i-lucide-clock",
+    title: "No deferred tasks",
+    description: "Use LATER to mark tasks you'll come back to.",
+  },
+  NOW: {
+    icon: "i-lucide-play",
+    title: "No active tasks",
+    description: "Use NOW to mark what you're currently working on.",
+  },
+  WAITING: {
+    icon: "i-lucide-hourglass",
+    title: "No waiting tasks",
+    description: "Use WAITING for tasks blocked on someone else.",
+  },
+  CANCELLED: {
+    icon: "i-lucide-x-circle",
+    title: "No cancelled tasks",
+    description: "Use CANCELLED to mark tasks that won't be done.",
+  },
+}
+
+const FALLBACK_EMPTY = {
+  icon: "i-lucide-search",
+  title: "No results",
+  description: "Try adjusting your query.",
+}
+
+export class QueryWidget {
+  dom: HTMLElement
+  private resultsContainer: HTMLElement
+  private _destroyed = false
+
+  constructor(
+    private attrs: QueryAttrs,
+    private graph: GraphService,
+  ) {
+    this.dom = document.createElement("span")
+    this.dom.className = "md-query-widget"
+
+    // Loading skeleton (mimics USkeleton's animate-pulse)
+    this.resultsContainer = document.createElement("div")
+    this.resultsContainer.className = "md-query-results"
+    this.resultsContainer.innerHTML = `
+      <div class="md-query-loading">
+        <div class="md-query-loading-item" style="width: 75%"></div>
+        <div class="md-query-loading-item" style="width: 50%"></div>
+        <div class="md-query-loading-item" style="width: 60%"></div>
+      </div>`
+    this.dom.appendChild(this.resultsContainer)
+
+    // Fire query asynchronously
+    this.executeQuery().then((results) => {
+      this.render(results)
+    })
+  }
+
+  private async executeQuery(): Promise<QueryResult[]> {
+    try {
+      const { type, tag, property, equals } = this.attrs
+
+      if (tag) {
+        const incoming = await this.graph.getIncoming(tag)
+        return incoming
+          .filter((r) => r.via.type === "tagged")
+          .map((r) => ({
+            blockId: r.from.id,
+            pagePath: r.from.path ?? "",
+            title: r.from.title ?? "",
+          }))
+      }
+
+      if (property) {
+        const entities = await this.graph.queryByProperty(property, equals)
+        return entities.map((e) => ({
+          blockId: e.id,
+          pagePath: e.path ?? "",
+          title: e.title ?? "",
+        }))
+      }
+
+      if (type) {
+        const entities = await this.graph.queryByProperty("task-state", type)
+        return entities.map((e) => ({
+          blockId: e.id,
+          pagePath: e.path ?? "",
+          title: e.title ?? "",
+        }))
+      }
+
+      return []
+    } catch {
+      return []
+    }
+  }
+
+  private render(results: QueryResult[]) {
+    if (this.isDestroyed()) return
+    if (results.length > 0) {
+      const list = document.createElement("div")
+      list.className = "md-query-list"
+
+      for (const r of results) {
+        const item = document.createElement("a")
+        item.className = "md-query-item"
+        item.href = `/${r.pagePath}`
+        item.setAttribute("data-block-id", r.blockId)
+        item.textContent = r.title || "(empty block)"
+        item.addEventListener("mousedown", (e) => {
+          // Prevent ProseMirror from intercepting the click and focusing the block
+          e.preventDefault()
+          e.stopPropagation()
+        })
+        item.addEventListener("click", (e) => {
+          e.preventDefault()
+          e.stopPropagation()
+          window.location.href = `/${r.pagePath}`
+        })
+        list.appendChild(item)
+      }
+
+      this.resultsContainer.innerHTML = ""
+      this.resultsContainer.appendChild(list)
+    } else {
+      const msg = EMPTY_MESSAGES[this.attrs.type ?? ""] ?? FALLBACK_EMPTY
+      this.resultsContainer.innerHTML = `
+        <div class="md-query-empty">
+          <span class="md-query-empty-icon ${msg.icon}"></span>
+          <p class="md-query-empty-title">${msg.title}</p>
+          <p class="md-query-empty-description">${msg.description}</p>
+        </div>`
+    }
+  }
+
+  destroy() {
+    this._destroyed = true
+  }
+
+  private isDestroyed(): boolean {
+    return this._destroyed || !this.dom.isConnected
+  }
+}

+ 35 - 0
apps/tauri/src/lib/comark-renderers/query.ts

@@ -0,0 +1,35 @@
+/**
+ * Query renderer factory for ::query{type="TODO"} / ::query{tag="project"}.
+ *
+ * Returns a BlockDecoration with:
+ * - nodeWrapper for the styled outer container (md-query-block)
+ * - decorationRanges covering the full node range (hidden when blurred)
+ * - wrapperAttrs storing the raw query attrs for the widget factory
+ */
+
+import { findChildByTypeName } from "@enesis/editor"
+import { parseAttrs } from "@enesis/lezer-comark"
+import type { SyntaxNode } from "@lezer/common"
+
+export function createQueryRenderer() {
+  return (node: SyntaxNode, ctx: { doc: string }) => {
+    const attrsNode = findChildByTypeName(node, "ComponentAttrs")
+    if (!attrsNode) return null
+
+    const rawAttrs = ctx.doc.slice(attrsNode.from, attrsNode.to)
+    const parsed = parseAttrs(rawAttrs)
+    return {
+      type: "query",
+      // Single range covering the full block for nodeWrapper positioning.
+      // Hiding the source when blurred is handled by applyQueryDecorations.
+      decorationRanges: [
+        { type: "content", from: node.from, to: node.to },
+      ],
+      wrapperAttrs: { "data-query": rawAttrs },
+      nodeWrapper: {
+        className: "md-query-block",
+        attrs: { "data-query-attrs": rawAttrs },
+      },
+    }
+  }
+}

+ 26 - 0
apps/tauri/src/lib/comark-setup.ts

@@ -0,0 +1,26 @@
+/**
+ * Comark component setup — registers renderers and the query widget
+ * factory when a workspace database becomes available.
+ *
+ * Called from app.vue's watch on ws.db.
+ */
+import { registerComarkComponent, registerWidgetFactory } from "@enesis/editor"
+import { parseAttrs } from "@enesis/lezer-comark"
+import { createQueryRenderer } from "~/lib/comark-renderers/query"
+import { QueryWidget } from "~/lib/comark-renderers/query-widget"
+import type { GraphService } from "~/services/graph"
+
+export function setupComarkComponents(graph: GraphService) {
+  registerComarkComponent("query", createQueryRenderer())
+
+  registerWidgetFactory("query", (rawAttrs: string) => {
+    const parsed = parseAttrs(rawAttrs)
+    const widget = new QueryWidget({
+      type: parsed.type as string | undefined,
+      tag: parsed.tag as string | undefined,
+      property: parsed.property as string | undefined,
+      equals: parsed.equals as string | undefined,
+    }, graph)
+    return { dom: widget.dom, destroy: () => widget.destroy() }
+  })
+}

+ 11 - 0
apps/tauri/src/lib/indexer.ts

@@ -19,6 +19,7 @@ import {
   detectBlockType,
   extractLinks,
   extractProperties,
+  extractTaskState,
   toRelativePath,
 } from "./parse"
 import { SCHEMA_SQL } from "./schema"
@@ -215,6 +216,16 @@ export async function indexFile(
         [block.id, filePath, prop.key, prop.value],
       )
     }
+
+    // Index task state as a queryable property (e.g. task-state:: TODO)
+    const taskState = extractTaskState(block.content)
+    if (taskState) {
+      await db.execute(
+        `INSERT OR REPLACE INTO properties (block_id, page_path, key, value)
+         VALUES ($1, $2, $3, $4)`,
+        [block.id, filePath, "task-state", taskState],
+      )
+    }
   }
 
   // Remove stale blocks

+ 24 - 0
apps/tauri/src/lib/parse.test.ts

@@ -6,6 +6,7 @@ import {
   escapeHtml,
   extractLinks,
   extractProperties,
+  extractTaskState,
   extractTitle,
   toRelativePath,
 } from "./parse"
@@ -318,3 +319,26 @@ describe("extractProperties", () => {
     expect(extractProperties("")).toEqual([])
   })
 })
+
+describe("extractTaskState", () => {
+  it("detects TODO", () => {
+    expect(extractTaskState("TODO buy milk")).toBe("TODO")
+    expect(extractTaskState("DOING implement")).toBe("DOING")
+    expect(extractTaskState("DONE ship it")).toBe("DONE")
+    expect(extractTaskState("LATER maybe")).toBe("LATER")
+    expect(extractTaskState("NOW urgent")).toBe("NOW")
+    expect(extractTaskState("WAITING on review")).toBe("WAITING")
+    expect(extractTaskState("CANCELLED wontfix")).toBe("CANCELLED")
+  })
+
+  it("returns null for non-task content", () => {
+    expect(extractTaskState("Just a paragraph")).toBeNull()
+    expect(extractTaskState("# Heading")).toBeNull()
+    expect(extractTaskState("> Blockquote")).toBeNull()
+  })
+
+  it("handles task state at start of line", () => {
+    expect(extractTaskState("TODO  ")).toBe("TODO")
+    expect(extractTaskState("TODO")).toBe("TODO")
+  })
+})

+ 26 - 0
apps/tauri/src/lib/parse.ts

@@ -116,6 +116,32 @@ export interface ExtractedProperty {
  * Matches the same pattern as the decoration engine's parseProperties
  * in packages/editor/src/lib/markdown-rules/engine.ts.
  */
+// Mirrors TASK_RE in packages/editor/src/lib/markdown-rules/block-classifier.ts.
+// Keep both in sync if adding/changing task states.
+const TASK_STATES = [
+  "TODO",
+  "DOING",
+  "DONE",
+  "LATER",
+  "NOW",
+  "WAITING",
+  "CANCELLED",
+]
+
+/**
+ * Extract the task state keyword from block content if present.
+ * Returns the state keyword (e.g. "TODO") or null.
+ */
+export function extractTaskState(content: string): string | null {
+  const trimmed = content.trimStart()
+  for (const state of TASK_STATES) {
+    if (trimmed.startsWith(`${state} `) || trimmed === state) {
+      return state
+    }
+  }
+  return null
+}
+
 export function extractProperties(content: string): ExtractedProperty[] {
   const results: ExtractedProperty[] = []
   const lines = content.split("\n")

+ 58 - 0
packages/editor/src/assets/style.css

@@ -656,3 +656,61 @@
     border: 1px solid ButtonText;
   }
 }
+
+/* ── Query widget (::query{...} results) ────────────────────────────── */
+
+.md-query-block {
+  @apply border-l-2 border-(--ui-border) pl-3 my-2;
+}
+
+.md-query-hidden {
+  display: none;
+}
+
+.md-query-widget {
+  display: inline-block;
+  vertical-align: top;
+  line-height: 0;
+  min-width: 100%;
+  font-size: 0.875rem;
+}
+
+.md-query-widget ~ .ProseMirror-trailingBreak,
+.md-query-widget ~ .ProseMirror-separator {
+  display: none;
+}
+
+.md-query-loading {
+  @apply space-y-1.5 py-1;
+}
+
+.md-query-loading-item {
+  @apply animate-pulse rounded-md bg-elevated h-7 w-full;
+}
+
+.md-query-list {
+  @apply space-y-1 py-1;
+}
+
+.md-query-item {
+  @apply block px-3 py-1.5 rounded-md text-sm text-muted
+         hover:text-default hover:bg-elevated/50
+         transition-colors duration-100 cursor-pointer no-underline;
+}
+
+.md-query-empty {
+  @apply flex flex-col items-center justify-center gap-4 rounded-lg p-4
+         ring ring-default bg-elevated/50 min-w-0 text-center;
+}
+
+.md-query-empty-icon {
+  @apply size-10 text-xl;
+}
+
+.md-query-empty-title {
+  @apply text-sm font-medium text-highlighted;
+}
+
+.md-query-empty-description {
+  @apply text-xs text-muted;
+}

+ 55 - 0
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -18,6 +18,7 @@ import type { Tree } from "@lezer/common"
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
+import { getWidgetFactory } from "@/lib/comark-registry"
 import { inlineToString } from "@/lib/content-model"
 import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
@@ -431,6 +432,49 @@ function applyTableDecorations(
   }
 }
 
+/**
+ * Apply decorations for a ::query{...} block (blurred state only).
+ * Creates a widget DOM element via the registered factory
+ * so query results render inline inside the ProseMirror view.
+ */
+function applyQueryDecorations(
+  block: ParsedToken,
+  sorted: DecorationRange[],
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+  decorationSpecs: Decoration[],
+) {
+  // Hide raw syntax (same as tables)
+  for (const range of sorted) {
+    const specs = buildDecorationsForRange(range, boundaries, paragraphs)
+    for (const spec of specs) {
+      decorationSpecs.push(
+        Decoration.inline(spec.from, spec.to, { class: "md-query-hidden" }),
+      )
+    }
+  }
+
+  // Insert widget after the block if a factory is registered
+  const factory = getWidgetFactory("query")
+  const rawAttrs = block.wrapperAttrs?.["data-query"]
+  if (factory && rawAttrs) {
+    const lastRange = sorted[sorted.length - 1]
+    if (lastRange) {
+      const resolved = resolveOffset(lastRange.to, boundaries, paragraphs)
+      if (resolved) {
+        let widgetDestroy: (() => void) | undefined
+        decorationSpecs.push(
+          Decoration.widget(resolved.pmPos, () => {
+            const { dom, destroy } = factory(rawAttrs)
+            widgetDestroy = destroy
+            return dom
+          }, { side: 1, destroy: () => widgetDestroy?.() }),
+        )
+      }
+    }
+  }
+}
+
 /**
  * Apply block-level decorations (node wrapper + hidden/visible ranges).
  */
@@ -459,6 +503,17 @@ function applyBlockDecorations(
     return
   }
 
+  if (block.type === "query" && !isFocused) {
+    applyQueryDecorations(
+      block,
+      sorted,
+      boundaries,
+      paragraphs,
+      decorationSpecs,
+    )
+    return
+  }
+
   // ── Blockquote / callout continuation merge support ──
   if (block.nodeWrapper) {
     const firstRange = sorted[0]

+ 6 - 0
packages/editor/src/index.ts

@@ -62,6 +62,12 @@ export type {
   Property,
   Relation,
 } from "./services/graph"
+export {
+  registerComarkComponent,
+  registerWidgetFactory,
+  getWidgetFactory,
+} from "./lib/comark-registry"
+export { findChildByTypeName, findChildrenByTypeName } from "./lib/markdown-rules/inline-rules"
 export { registerNodeView } from "./lib/node-view-registry"
 export type {
   DeleteBlockOp,

+ 18 - 0
packages/editor/src/lib/comark-registry.ts

@@ -34,3 +34,21 @@ export function registerComarkComponent(
 export function getComarkRenderer(name: string): ComarkRenderer | undefined {
   return comarkRegistry.get(name)
 }
+
+/**
+ * Widget factory for rich-content rendering inside PM decorations.
+ * Keyed by component name (e.g. "query"). When the decoration pipeline
+ * encounters a component block, it looks up the factory by name and
+ * calls it to produce a DOM element + destroy callback.
+ */
+export type WidgetFactory = (attrs: string) => { dom: HTMLElement; destroy: () => void }
+
+const widgetFactories = new Map<string, WidgetFactory>()
+
+export function registerWidgetFactory(name: string, factory: WidgetFactory) {
+  widgetFactories.set(name, factory)
+}
+
+export function getWidgetFactory(name: string): WidgetFactory | null {
+  return widgetFactories.get(name) ?? null
+}