diff --git a/api/crates/application/src/plugins/ports/plugin_repository.rs b/api/crates/application/src/plugins/ports/plugin_repository.rs index 2bd064eb..f70113d6 100644 --- a/api/crates/application/src/plugins/ports/plugin_repository.rs +++ b/api/crates/application/src/plugins/ports/plugin_repository.rs @@ -52,6 +52,14 @@ pub trait PluginRepository: Send + Sync { patch: &JsonValue, ) -> PortResult>; + async fn append_record_array_item( + &self, + record_id: Uuid, + field: &str, + item: &JsonValue, + patch: &JsonValue, + ) -> PortResult>; + async fn delete_record(&self, record_id: Uuid) -> PortResult; async fn get_record(&self, record_id: Uuid) -> PortResult>; diff --git a/api/crates/application/src/plugins/use_cases/exec_action.rs b/api/crates/application/src/plugins/use_cases/exec_action.rs index 3550a0a5..131706df 100644 --- a/api/crates/application/src/plugins/use_cases/exec_action.rs +++ b/api/crates/application/src/plugins/use_cases/exec_action.rs @@ -329,6 +329,68 @@ where .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))?; } } + "appendRecordArrayItem" => { + policy::ensure_plugin_permission( + permissions, + policy::PLUGIN_PERMISSION_DOC_WRITE, + )?; + policy::ensure_workspace_can_edit_documents(workspace_permissions)?; + let Some(record_id) = effect + .get("recordId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + else { + continue; + }; + let Some(field) = effect + .get("field") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 128) + else { + continue; + }; + let Some(rec) = self + .plugin_repo + .get_record(record_id) + .await + .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))? + else { + continue; + }; + policy::ensure_record_owned_by_plugin(&rec.plugin, plugin)?; + if rec.scope != PluginRecordScope::Doc { + continue; + } + self.validate_doc_scope( + workspace_id, + Some(rec.scope_id), + allowed_doc_id, + doc_id_created, + actor, + true, + ) + .await?; + let item = effect + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null); + let mut patch = effect + .get("patch") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if !patch.is_object() { + patch = serde_json::json!({}); + } + if let Some(obj) = patch.as_object_mut() { + obj.remove(field); + } + let _ = self + .plugin_repo + .append_record_array_item(record_id, field, &item, &patch) + .await + .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))?; + } "deleteRecord" => { policy::ensure_plugin_permission( permissions, diff --git a/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs b/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs index f817f54f..ace5d619 100644 --- a/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs +++ b/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs @@ -146,6 +146,59 @@ impl PluginRepository for SqlxPluginRepository { out.map_err(Into::into) } + async fn append_record_array_item( + &self, + record_id: Uuid, + field: &str, + item: &JsonValue, + patch: &JsonValue, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"UPDATE plugin_records + SET data = jsonb_set( + data || $4::jsonb, + ARRAY[$2], + COALESCE( + CASE + WHEN jsonb_typeof(data -> $2) = 'array' THEN data -> $2 + ELSE '[]'::jsonb + END, + '[]'::jsonb + ) || jsonb_build_array($3::jsonb), + true + ), + updated_at = now() + WHERE id = $1 + RETURNING id, plugin, scope, scope_id, kind, data, created_at, updated_at"#, + ) + .bind(record_id) + .bind(field) + .bind(item) + .bind(patch) + .fetch_optional(&self.pool) + .await?; + row.map(|r| { + let scope_raw: String = r.get("scope"); + let scope = PluginRecordScope::parse(&scope_raw) + .ok_or_else(|| anyhow::anyhow!("invalid_plugin_record_scope"))?; + Ok(PluginRecord { + id: r.get("id"), + plugin: r.get("plugin"), + scope, + scope_id: r.get("scope_id"), + kind: r.get("kind"), + data: r.get("data"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + }) + }) + .transpose() + } + .await; + out.map_err(Into::into) + } + async fn delete_record(&self, record_id: Uuid) -> PortResult { let out: anyhow::Result = async { let res = sqlx::query("DELETE FROM plugin_records WHERE id = $1") diff --git a/app/src/entities/plugin/api/index.ts b/app/src/entities/plugin/api/index.ts index 4a336ad7..9ba00125 100644 --- a/app/src/entities/plugin/api/index.ts +++ b/app/src/entities/plugin/api/index.ts @@ -14,6 +14,10 @@ import { import type { ManifestItem as ClientManifestItem } from '@/shared/api/client' export type PluginManifestItem = ClientManifestItem +export type PluginRecordListOptions = { + limit?: number | null + offset?: number | null +} export const pluginKeys = { manifest: () => ['plugins', 'manifest'] as const, @@ -59,9 +63,20 @@ export async function listPluginRecords( pluginId: string, docId: string, kind: string, + optionsOrToken?: PluginRecordListOptions | string, token?: string, ) { - return withShareAuthorization(token, () => apiListRecords({ plugin: pluginId, docId, kind })) + const options = typeof optionsOrToken === 'string' ? undefined : optionsOrToken + const authToken = typeof optionsOrToken === 'string' ? optionsOrToken : token + return withShareAuthorization(authToken, () => + apiListRecords({ + plugin: pluginId, + docId, + kind, + limit: options?.limit ?? undefined, + offset: options?.offset ?? undefined, + }), + ) } export async function createPluginRecord( @@ -76,12 +91,14 @@ export async function createPluginRecord( ) } -export async function updatePluginRecord(pluginId: string, id: string, patch: unknown) { - return apiPluginsUpdateRecord({ plugin: pluginId, id, requestBody: { patch } }) +export async function updatePluginRecord(pluginId: string, id: string, patch: unknown, token?: string) { + return withShareAuthorization(token, () => + apiPluginsUpdateRecord({ plugin: pluginId, id, requestBody: { patch } }), + ) } -export async function deletePluginRecord(pluginId: string, id: string) { - return apiPluginsDeleteRecord({ plugin: pluginId, id }) +export async function deletePluginRecord(pluginId: string, id: string, token?: string) { + return withShareAuthorization(token, () => apiPluginsDeleteRecord({ plugin: pluginId, id })) } export async function getPluginKv( diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 5cb4852c..5596c7a5 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -25,6 +25,8 @@ import { ensureRefmdThemes, REFMD_DARK_THEME, REFMD_LIGHT_THEME } from '@/featur import { registerWikiLinkCompletion } from '@/features/edit-document/lib/monaco/wiki-link-provider' import { useEditorContext } from '@/features/edit-document/model/editor-context' import { useViewContext } from '@/features/edit-document/model/view-context' +import { useDocumentEditorPlugins, type DocumentEditorApi, type DocumentEditorDocumentApi, type DocumentEditorRange, type DocumentEditorSelection } from '@/features/plugins' +import type { DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' import { loadMonacoVim } from '../lib/monaco/vim-loader' @@ -63,6 +65,11 @@ export type MarkdownEditorProps = { userName?: string userId?: string documentId: string + documentTitle?: string | null + documentType?: string | null + documentEditorPluginsEnabled?: boolean + documentEditorPanePlacement?: 'extraRight' | 'mosaic' + onDocumentEditorPaneHostChange?: (host: DocumentEditorPaneHostState | null) => void readOnly?: boolean extraRight?: React.ReactNode conflictControls?: React.ReactNode @@ -102,6 +109,11 @@ export function MarkdownEditor(props: MarkdownEditorProps) { userId, userName, documentId, + documentTitle, + documentType, + documentEditorPluginsEnabled = true, + documentEditorPanePlacement = 'extraRight', + onDocumentEditorPaneHostChange, readOnly = false, extraRight, conflictControls, @@ -178,6 +190,8 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const unregisterEditorRef = useRef void)>(null) const focusDisposableRef = useRef void }>(null) const blurDisposableRef = useRef void }>(null) + const pluginDecorationIdsRef = useRef>(new Map()) + const pluginHiddenRangeSourcesRef = useRef>(new Map()) const isThisEditorActive = useCallback(() => { const ed = editorRef.current @@ -517,6 +531,31 @@ export function MarkdownEditor(props: MarkdownEditorProps) { safeExecute('dispose monaco markdown handler', () => (anyEditor as any)?.__disposeMonacoMd?.()) safeExecute('dispose keydown handler', () => (anyEditor as any)?.__disposeKeydown?.()) safeExecute('dispose dirty tracker', () => (anyEditor as any)?.__disposeDirtyTracker?.()) + safeExecute('dispose plugin decorations', () => { + for (const ids of pluginDecorationIdsRef.current.values()) { + try { + anyEditor?.deltaDecorations(ids, []) + } catch { + /* noop */ + } + } + pluginDecorationIdsRef.current.clear() + }) + safeExecute('dispose plugin hidden ranges', () => { + const setHiddenAreas = (anyEditor as any)?.setHiddenAreas + if (typeof setHiddenAreas !== 'function') { + pluginHiddenRangeSourcesRef.current.clear() + return + } + for (const source of pluginHiddenRangeSourcesRef.current.values()) { + try { + setHiddenAreas.call(anyEditor, [], source) + } catch { + /* noop */ + } + } + pluginHiddenRangeSourcesRef.current.clear() + }) safeExecute('dispose read-only overlay', () => { if (anyEditor?.__readOnlyOverlay) { try { anyEditor.removeOverlayWidget(anyEditor.__readOnlyOverlay.widget) } catch {} @@ -770,6 +809,248 @@ export function MarkdownEditor(props: MarkdownEditorProps) { [ensureThisEditorActive, uploadFiles], ) + const documentEditorApi = useMemo(() => { + const editorInstance = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __monaco?: typeof monacoNs }) | null + const monacoInstance = editorInstance?.__monaco + if (!editorInstance || !monacoInstance) return null + + const toRange = (range: DocumentEditorRange) => + new monacoInstance.Range( + range.startLineNumber, + range.startColumn, + range.endLineNumber, + range.endColumn, + ) + + const toSelection = (): DocumentEditorSelection | null => { + const selection = editorInstance.getSelection() + const model = editorInstance.getModel() + if (!selection || !model) return null + return { + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn, + text: model.getValueInRange(selection), + isEmpty: selection.isEmpty(), + } + } + + const applyEdits = (edits: Array<{ range: DocumentEditorRange; text: string; forceMoveMarkers?: boolean }>) => { + if (readOnly) { + emitReadOnlyWarning() + return false + } + const nextEdits = edits + .filter((edit) => edit && edit.range) + .map((edit) => ({ + range: toRange(edit.range), + text: String(edit.text ?? ''), + forceMoveMarkers: edit.forceMoveMarkers !== false, + })) + if (!nextEdits.length) return false + const applied = editorInstance.executeEdits('refmd-plugin', nextEdits) + editorInstance.pushUndoStop() + try { + ;(editorInstance as any).__refmdUserEditIntent = true + ;(editorInstance as any).__refmdMarkDirty?.() + } catch { + /* noop */ + } + return applied + } + + const applyTextAtSelection = (text: string) => { + const selection = editorInstance.getSelection() + if (!selection) return false + return applyEdits([{ range: selection, text, forceMoveMarkers: true }]) + } + + return { + focus: () => editorInstance.focus(), + getSelection: toSelection, + setSelection: (range) => { + const next = toRange(range) + editorInstance.setSelection(next) + editorInstance.revealRangeInCenterIfOutsideViewport(next) + }, + applyEdits, + replaceSelection: applyTextAtSelection, + insertText: applyTextAtSelection, + revealLine: (line) => { + if (!Number.isFinite(line)) return + editorInstance.revealLineInCenterIfOutsideViewport(Math.max(1, Math.floor(line))) + }, + revealRange: (range) => { + editorInstance.revealRangeInCenterIfOutsideViewport(toRange(range)) + }, + getRangeFromOffset: (offset, length = 0) => { + const model = editorInstance.getModel() + if (!model) return null + const contentLength = model.getValueLength() + const startOffset = Math.max(0, Math.min(contentLength, Math.floor(offset))) + const endOffset = Math.max(startOffset, Math.min(contentLength, startOffset + Math.max(0, Math.floor(length)))) + const start = model.getPositionAt(startOffset) + const end = model.getPositionAt(endOffset) + return { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + } + }, + getOffsetFromPosition: (position) => { + const model = editorInstance.getModel() + if (!model) return null + const lineNumber = Math.max(1, Math.floor(position.lineNumber)) + const column = Math.max(1, Math.floor(position.column)) + try { + return model.getOffsetAt({ lineNumber, column }) + } catch { + return null + } + }, + onSelectionChange: (callback) => { + const disposable = editorInstance.onDidChangeCursorSelection(() => { + callback(toSelection()) + }) + return () => { + try { + disposable.dispose() + } catch { + /* noop */ + } + } + }, + setDecorations: (ownerId, decorations) => { + const owner = String(ownerId || 'default') + const previous = pluginDecorationIdsRef.current.get(owner) ?? [] + const nextDecorations = decorations.map((decoration) => ({ + range: toRange(decoration.range), + options: { + className: decoration.className, + inlineClassName: decoration.inlineClassName, + glyphMarginClassName: decoration.glyphMarginClassName, + hoverMessage: decoration.hoverMessage ? { value: decoration.hoverMessage } : undefined, + overviewRuler: decoration.overviewRulerColor + ? { + color: decoration.overviewRulerColor, + position: monacoInstance.editor.OverviewRulerLane.Right, + } + : undefined, + minimap: decoration.minimapColor + ? { + color: decoration.minimapColor, + position: monacoInstance.editor.MinimapPosition.Inline, + } + : undefined, + }, + })) + const nextIds = editorInstance.deltaDecorations(previous, nextDecorations) + pluginDecorationIdsRef.current.set(owner, nextIds) + return () => { + const current = pluginDecorationIdsRef.current.get(owner) + if (!current) return + try { + editorInstance.deltaDecorations(current, []) + } catch { + /* noop */ + } + pluginDecorationIdsRef.current.delete(owner) + } + }, + setHiddenRanges: (ownerId, ranges) => { + const owner = String(ownerId || 'default') + const setHiddenAreas = (editorInstance as any).setHiddenAreas + if (typeof setHiddenAreas !== 'function') { + return () => { + pluginHiddenRangeSourcesRef.current.delete(owner) + } + } + + const model = editorInstance.getModel() + if (!model) return () => {} + let source = pluginHiddenRangeSourcesRef.current.get(owner) + if (!source) { + source = {} + pluginHiddenRangeSourcesRef.current.set(owner, source) + } + + const lineCount = model.getLineCount() + const nextRanges = ranges + .map((item) => item?.range) + .filter((range): range is DocumentEditorRange => Boolean(range)) + .map((range) => { + const startLine = Math.min(lineCount, Math.max(1, Math.floor(range.startLineNumber))) + const endLine = Math.min(lineCount, Math.max(startLine, Math.floor(range.endLineNumber || startLine))) + return new monacoInstance.Range(startLine, 1, endLine, model.getLineMaxColumn(endLine)) + }) + + try { + setHiddenAreas.call(editorInstance, nextRanges, source) + } catch { + return () => {} + } + + return () => { + const current = pluginHiddenRangeSourcesRef.current.get(owner) + if (current !== source) return + try { + setHiddenAreas.call(editorInstance, [], source) + } catch { + /* noop */ + } + pluginHiddenRangeSourcesRef.current.delete(owner) + } + }, + } + }, [editorMountNonce, editorRef, emitReadOnlyWarning, readOnly]) + + const documentEditorDocument = useMemo(() => { + const ytext = doc.getText('content') + return { + id: documentId, + type: documentType ?? 'markdown', + title: documentTitle ?? null, + token: shareToken ?? null, + readOnly, + getContent: () => ytext.toString(), + setContent: (value) => { + if (readOnly) { + emitReadOnlyWarning() + return false + } + const next = String(value ?? '') + doc.transact(() => { + ytext.delete(0, ytext.length) + ytext.insert(0, next) + }) + markDocumentContentDirty(documentId, next) + return true + }, + onContentChange: (callback) => { + const observer = () => callback(ytext.toString()) + ytext.observe(observer) + return () => { + try { + ytext.unobserve(observer) + } catch { + /* noop */ + } + } + }, + } + }, [doc, documentId, documentTitle, documentType, emitReadOnlyWarning, readOnly, shareToken]) + + const pluginPanes = useDocumentEditorPlugins({ + enabled: documentEditorPluginsEnabled && !conflictView, + document: documentEditorDocument, + editor: documentEditorApi, + onPaneHostChange: onDocumentEditorPaneHostChange, + }) + + const resolvedExtraRight = extraRight ?? (documentEditorPanePlacement === 'extraRight' ? pluginPanes.extraRight : undefined) + return ( @@ -796,7 +1077,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { void): () => void + setDecorations(ownerId: string, decorations: DocumentEditorDecorationInput[]): () => void + setHiddenRanges(ownerId: string, ranges: DocumentEditorHiddenRangeInput[]): () => void +} + +export type DocumentEditorDocumentApi = { + id: string + type?: string | null + title?: string | null + token?: string | null + readOnly: boolean + getContent(): string + setContent(value: string): boolean + onContentChange(callback: (content: string) => void): () => void +} + +export type DocumentEditorUserApi = { + id: string + name: string +} + +export type DocumentEditorPaneRenderContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + user: DocumentEditorUserApi | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + pane: { + id: string + active: boolean + close(): void + onActiveChange(callback: (active: boolean) => void): () => void + } +} + +export type DocumentEditorPaneContribution = { + id: string + title: string + order?: number + icon?: string + render( + container: HTMLElement, + ctx: DocumentEditorPaneRenderContext, + ): void | (() => void) +} + +export type DocumentEditorPaneRegistration = { + dispose(): void + setBadge(value: string | number | null): void + setTitle(title: string): void + open(): void +} + +export type DocumentEditorRecordApi = { + list(kind: string, options?: { limit?: number; offset?: number }): Promise +} + +export type DocumentEditorKvApi = { + get(key: string): Promise +} + +export type DocumentEditorActionApi = { + exec(action: string, payload?: Record): Promise +} + +export type DocumentEditorActivationContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + user: DocumentEditorUserApi | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + documentPanes: { + register(pane: DocumentEditorPaneContribution): DocumentEditorPaneRegistration + } + records: DocumentEditorRecordApi + kv: DocumentEditorKvApi + actions: DocumentEditorActionApi + toast(level: 'info' | 'success' | 'warning' | 'error', message: string): void +} + +export type DocumentEditorPluginMatch = { + manifest: ManifestItem + module: any +} + +export type RegisteredDocumentEditorPane = { + key: string + pluginId: string + pluginVersion: string + pluginManifest: ManifestItem + id: string + title: string + order: number + icon?: string + badge: string | number | null + contribution: DocumentEditorPaneContribution +} diff --git a/app/src/features/plugins/lib/pane-icons.tsx b/app/src/features/plugins/lib/pane-icons.tsx new file mode 100644 index 00000000..27b565cc --- /dev/null +++ b/app/src/features/plugins/lib/pane-icons.tsx @@ -0,0 +1,44 @@ +"use client" + +import { + AlertTriangle, + BookOpen, + Bug, + CheckCircle2, + FileText, + List, + ListTree, + MessageSquare, + PanelRight, + Search, + Tags, + type LucideIcon, +} from 'lucide-react' +import type { ReactNode } from 'react' + +const documentPaneIcons: Record = { + 'alert-triangle': AlertTriangle, + 'book-open': BookOpen, + bug: Bug, + 'check-circle': CheckCircle2, + 'check-circle-2': CheckCircle2, + 'file-text': FileText, + list: List, + 'list-tree': ListTree, + 'message-square': MessageSquare, + 'panel-right': PanelRight, + search: Search, + tags: Tags, +} + +function normalizeDocumentPaneIconName(icon?: string | null) { + return String(icon ?? '') + .trim() + .toLowerCase() + .replace(/[_\s]+/g, '-') +} + +export function renderDocumentPaneIcon(icon?: string | null, className = 'h-4 w-4'): ReactNode { + const Icon = documentPaneIcons[normalizeDocumentPaneIconName(icon)] ?? PanelRight + return