From 9bf1aa55907a7774ba2608c7635ed2439052253d Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Thu, 11 Dec 2025 20:58:50 +0300 Subject: [PATCH 1/2] Supporting Ciado FIles Intigration --- packages/backend/src/api/file.ts | 18 + packages/backend/src/api/index.ts | 2 + packages/backend/src/index.ts | 3 + packages/frontend/package.json | 9 + .../components/content/editor/NoteEditor.vue | 6 +- .../components/content/editor/TableMenu.vue | 6 +- .../editor/extensions/mentions/FileList.vue | 106 ++++ .../extensions/mentions/mention-file.ts | 323 ++++++++++++ .../editor/extensions/slash-commands.ts | 490 ++++++++++++++---- packages/frontend/src/utils/jsonToMarkdown.ts | 133 ++++- pnpm-lock.yaml | 196 ++++++- 11 files changed, 1165 insertions(+), 127 deletions(-) create mode 100644 packages/backend/src/api/file.ts create mode 100644 packages/frontend/src/components/content/editor/extensions/mentions/FileList.vue create mode 100644 packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts diff --git a/packages/backend/src/api/file.ts b/packages/backend/src/api/file.ts new file mode 100644 index 0000000..342ae2b --- /dev/null +++ b/packages/backend/src/api/file.ts @@ -0,0 +1,18 @@ +import * as fs from "fs"; + +import { type SDK } from "caido:plugin"; + +import type { API } from "../index"; +import type { BackendEvents } from "../types/events"; + +export function getFileContent( + _sdk: SDK, + filePath: string, +): string { + try { + const content = fs.readFileSync(filePath, "utf-8"); + return content; + } catch (error) { + return `Error reading file: ${error}`; + } +} diff --git a/packages/backend/src/api/index.ts b/packages/backend/src/api/index.ts index 100e4fe..788c4c4 100644 --- a/packages/backend/src/api/index.ts +++ b/packages/backend/src/api/index.ts @@ -1,3 +1,4 @@ +import { getFileContent } from "./file"; import { createFolder, deleteFolder } from "./folder"; import { getLegacyNotes, migrateNote } from "./migration"; import { @@ -24,4 +25,5 @@ export { getCurrentProjectId, getLegacyNotes, migrateNote, + getFileContent, }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4021bff..dfa2745 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,6 +6,7 @@ import { deleteFolder, deleteNote, getCurrentProjectId, + getFileContent, getLegacyNotes, getNote, getTree, @@ -31,6 +32,7 @@ export type API = DefineAPI<{ getCurrentProjectId: typeof getCurrentProjectId; getLegacyNotes: typeof getLegacyNotes; migrateNote: typeof migrateNote; + getFileContent: typeof getFileContent; }>; export function init(sdk: SDK) { @@ -46,6 +48,7 @@ export function init(sdk: SDK) { sdk.api.register("getCurrentProjectId", getCurrentProjectId); sdk.api.register("getLegacyNotes", getLegacyNotes); sdk.api.register("migrateNote", migrateNote); + sdk.api.register("getFileContent", getFileContent); sdk.events.onProjectChange((sdk, project) => { sdk.api.send("notes++:projectChange", project?.getId()); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e1f17d2..2dd4683 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -7,6 +7,15 @@ }, "dependencies": { "@caido/primevue": "0.1.13", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.11.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.39.3", + "@lezer/highlight": "^1.2.3", "@tiptap/core": "^2.12.0", "@tiptap/extension-image": "^2.12.0", "@tiptap/extension-mention": "^2.12.0", diff --git a/packages/frontend/src/components/content/editor/NoteEditor.vue b/packages/frontend/src/components/content/editor/NoteEditor.vue index c7915d8..c5ced99 100644 --- a/packages/frontend/src/components/content/editor/NoteEditor.vue +++ b/packages/frontend/src/components/content/editor/NoteEditor.vue @@ -30,6 +30,7 @@ import "./editor.css"; import { ArrowKeysFix } from "./extensions/arrows-fix"; import { MarkdownHeading } from "./extensions/markdown-heading"; import MarkdownStyling from "./extensions/markdown-styling"; +import { createFileMention } from "./extensions/mentions/mention-file"; import { createSessionMention } from "./extensions/mentions/mention-request"; import createSuggestion from "./extensions/mentions/suggestion"; import { Search } from "./extensions/search"; @@ -46,6 +47,7 @@ const sdk = useSDK(); const notesStore = useNotesStore(); const suggestion = createSuggestion(sdk); const SessionMention = createSessionMention(sdk); +const FileMention = createFileMention(sdk); const MAX_IMAGE_SIZE_MB = 30; const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"]; @@ -185,7 +187,8 @@ const editor = useEditor({ TableRow, TableHeader, TableCell, - SlashCommands, + FileMention, + SlashCommands.configure({ sdk }), ], editorProps: { attributes: { @@ -255,7 +258,6 @@ let previousNotePath: string | undefined; watch( () => notesStore.currentNote, (newNote, oldNote) => { - // Save cursor position of the previous note before switching if (previousNotePath && editor.value) { const { from, to } = editor.value.state.selection; cursorPositions.set(previousNotePath, { from, to }); diff --git a/packages/frontend/src/components/content/editor/TableMenu.vue b/packages/frontend/src/components/content/editor/TableMenu.vue index 5df0915..d13d9b7 100644 --- a/packages/frontend/src/components/content/editor/TableMenu.vue +++ b/packages/frontend/src/components/content/editor/TableMenu.vue @@ -7,6 +7,8 @@ const props = defineProps<{ const shouldShowMenu = () => { if (!props.editor.isActive("table")) return false; + const { from, to } = props.editor.state.selection; + if (from === to) return false; return ( props.editor.isActive("tableCell") || props.editor.isActive("tableHeader") ); @@ -50,7 +52,9 @@ const deleteTable = () => { > - + diff --git a/packages/frontend/src/components/content/editor/extensions/mentions/FileList.vue b/packages/frontend/src/components/content/editor/extensions/mentions/FileList.vue new file mode 100644 index 0000000..e4e8059 --- /dev/null +++ b/packages/frontend/src/components/content/editor/extensions/mentions/FileList.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts b/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts new file mode 100644 index 0000000..0a7e583 --- /dev/null +++ b/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts @@ -0,0 +1,323 @@ +import { css } from "@codemirror/lang-css"; +import { html } from "@codemirror/lang-html"; +import { javascript } from "@codemirror/lang-javascript"; +import { json } from "@codemirror/lang-json"; +import { markdown } from "@codemirror/lang-markdown"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView, lineNumbers } from "@codemirror/view"; +import { tags } from "@lezer/highlight"; +import { mergeAttributes, Node } from "@tiptap/core"; + +import { type FrontendSDK } from "@/types"; + +const MAX_FILE_SIZE_BYTES = 150 * 1024 * 1024; +const shownLargeFileWarnings = new Set(); + +const githubDarkHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: "#ff7b72" }, + { tag: tags.operator, color: "#79c0ff" }, + { tag: tags.special(tags.variableName), color: "#ffa657" }, + { tag: tags.typeName, color: "#ffa657" }, + { tag: tags.atom, color: "#79c0ff" }, + { tag: tags.number, color: "#79c0ff" }, + { tag: tags.bool, color: "#79c0ff" }, + { tag: tags.string, color: "#a5d6ff" }, + { tag: tags.special(tags.string), color: "#a5d6ff" }, + { tag: tags.comment, color: "#8b949e", fontStyle: "italic" }, + { tag: tags.variableName, color: "#c9d1d9" }, + { tag: tags.function(tags.variableName), color: "#d2a8ff" }, + { tag: tags.definition(tags.variableName), color: "#ffa657" }, + { tag: tags.propertyName, color: "#79c0ff" }, + { tag: tags.tagName, color: "#7ee787" }, + { tag: tags.attributeName, color: "#79c0ff" }, + { tag: tags.attributeValue, color: "#a5d6ff" }, + { tag: tags.className, color: "#ffa657" }, + { tag: tags.labelName, color: "#ffa657" }, + { tag: tags.namespace, color: "#ff7b72" }, + { tag: tags.macroName, color: "#ffa657" }, + { tag: tags.literal, color: "#79c0ff" }, + { tag: tags.null, color: "#79c0ff" }, + { tag: tags.punctuation, color: "#c9d1d9" }, + { tag: tags.bracket, color: "#c9d1d9" }, + { tag: tags.heading, color: "#79c0ff", fontWeight: "bold" }, + { tag: tags.link, color: "#58a6ff" }, + { tag: tags.url, color: "#58a6ff" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { tag: tags.strong, fontWeight: "bold" }, +]); + +const githubDarkTheme = EditorView.theme( + { + "&": { + backgroundColor: "#0d1117", + color: "#c9d1d9", + }, + ".cm-content": { + caretColor: "#c9d1d9", + fontFamily: "'Consolas', 'Monaco', 'Menlo', monospace", + fontSize: "13px", + padding: "0", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "#c9d1d9", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { + backgroundColor: "#264f78", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-gutters": { + backgroundColor: "#0d1117", + color: "#6e7681", + border: "none", + borderRight: "1px solid #30363d", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 12px 0 8px", + minWidth: "40px", + }, + }, + { dark: true }, +); + +const styleId = "embedded-file-style"; +if (!document.getElementById(styleId)) { + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + .embedded-file { + display: inline-block; + margin: 12px 0; + border: 2px solid #30363d; + border-radius: 6px; + overflow: hidden; + width: 100%; + min-height: 50px; + max-height: 300px; + position: relative; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: box-shadow 0.2s ease, border-color 0.2s ease; + cursor: pointer; + background: #0d1117; + } + .embedded-file:hover { + border-color: #58a6ff; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); + } + .embedded-file-label { + position: absolute; + top: 4px; + right: 4px; + background: rgba(13, 17, 23, 0.9); + color: #c9d1d9; + font-size: 12px; + padding: 3px 8px; + border-radius: 4px; + z-index: 10; + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + border: 1px solid #30363d; + transition: background 0.2s ease, border-color 0.2s ease; + } + .embedded-file:hover .embedded-file-label { + background: #238636; + border-color: #238636; + color: #fff; + } + .embedded-file-content { + width: 100%; + height: 100%; + max-height: 300px; + overflow: auto; + } + .embedded-file-content .cm-editor { + height: 100%; + } + .embedded-file-content .cm-scroller { + overflow: auto; + } + .embedded-file-error { + padding: 12px; + color: #f85149; + display: flex; + align-items: center; + gap: 8px; + background: rgba(248, 81, 73, 0.1); + } + .embedded-file-large { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + color: #8b949e; + font-size: 13px; + background: #0d1117; + } + .embedded-file-large-icon { + font-size: 20px; + color: #58a6ff; + flex-shrink: 0; + } + .embedded-file-large-text { + flex: 1; + } + `; + document.head.appendChild(style); +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +function getFileExtension(filename: string): string { + const parts = filename.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; +} + +function getLanguageExtension(ext: string) { + switch (ext) { + case "json": + return json(); + case "js": + case "mjs": + return javascript(); + case "ts": + case "tsx": + return javascript({ typescript: true }); + case "jsx": + return javascript({ jsx: true }); + case "html": + case "htm": + case "xml": + case "svg": + return html(); + case "css": + return css(); + case "md": + case "markdown": + return markdown(); + default: + return null; + } +} + +export const createFileMention = (sdk: FrontendSDK) => { + return Node.create({ + name: "fileMention", + group: "block", + atom: true, + + addAttributes() { + return { + id: { default: "" }, + name: { default: "" }, + size: { default: 0 }, + path: { default: "" }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-file-mention]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes({ "data-file-mention": "" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return (nodeViewProps) => { + const { id, name, size, path } = nodeViewProps.node.attrs; + const isLargeFile = size > MAX_FILE_SIZE_BYTES; + + const container = document.createElement("div"); + container.className = "embedded-file"; + container.contentEditable = "false"; + + const label = document.createElement("div"); + label.className = "embedded-file-label"; + label.textContent = `${name} ${formatFileSize(size)}`; + container.appendChild(label); + + if (isLargeFile) { + if (!shownLargeFileWarnings.has(id)) { + shownLargeFileWarnings.add(id); + sdk.window.showToast( + `File "${name}" is too large (${formatFileSize(size)}). Double-click to view in Files.`, + { variant: "warning", duration: 5000 }, + ); + } + + const largeDiv = document.createElement("div"); + largeDiv.className = "embedded-file-large"; + largeDiv.innerHTML = ` + +
File too large to display. Double-click to open in Files.
+ `; + container.appendChild(largeDiv); + } else { + const contentWrapper = document.createElement("div"); + contentWrapper.className = "embedded-file-content"; + container.appendChild(contentWrapper); + + const loadFileContent = async () => { + try { + const content = await sdk.backend.getFileContent(path); + const ext = getFileExtension(name); + const langExt = getLanguageExtension(ext); + + const extensions = [ + githubDarkTheme, + syntaxHighlighting(githubDarkHighlight), + EditorView.editable.of(false), + EditorState.readOnly.of(true), + lineNumbers(), + ]; + + if (langExt) { + extensions.push(langExt); + } + + new EditorView({ + state: EditorState.create({ + doc: content, + extensions, + }), + parent: contentWrapper, + }); + } catch { + contentWrapper.innerHTML = ` +
+ + Error loading file content +
+ `; + } + }; + + loadFileContent(); + } + + container.addEventListener("dblclick", () => { + sdk.navigation.goTo("/files"); + }); + + return { + dom: container, + }; + }; + }, + }); +}; diff --git a/packages/frontend/src/components/content/editor/extensions/slash-commands.ts b/packages/frontend/src/components/content/editor/extensions/slash-commands.ts index 997bd27..65d7112 100644 --- a/packages/frontend/src/components/content/editor/extensions/slash-commands.ts +++ b/packages/frontend/src/components/content/editor/extensions/slash-commands.ts @@ -3,116 +3,16 @@ import { type Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { DecorationSet } from "@tiptap/pm/view"; +import { type FrontendSDK } from "@/types"; + interface SlashCommand { name: string; description: string; icon: string; - action: (editor: Editor) => void; + action: (editor: Editor, sdk?: FrontendSDK) => void; + hasSubmenu?: boolean; } -const commands: SlashCommand[] = [ - { - name: "table", - description: "Insert a table", - icon: "fa-table", - action: (editor) => { - editor - .chain() - .focus() - .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) - .run(); - }, - }, - { - name: "h1", - description: "Large heading", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 1 }).run(); - }, - }, - { - name: "h2", - description: "Medium heading", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 2 }).run(); - }, - }, - { - name: "h3", - description: "Small heading", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 3 }).run(); - }, - }, - { - name: "h4", - description: "Heading 4", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 4 }).run(); - }, - }, - { - name: "h5", - description: "Heading 5", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 5 }).run(); - }, - }, - { - name: "h6", - description: "Heading 6", - icon: "fa-heading", - action: (editor) => { - editor.chain().focus().toggleMarkdownHeading({ level: 6 }).run(); - }, - }, - { - name: "bullet", - description: "Bullet list", - icon: "fa-list-ul", - action: (editor) => { - editor.chain().focus().toggleBulletList().run(); - }, - }, - { - name: "numbered", - description: "Numbered list", - icon: "fa-list-ol", - action: (editor) => { - editor.chain().focus().toggleOrderedList().run(); - }, - }, - { - name: "code", - description: "Code block", - icon: "fa-code", - action: (editor) => { - editor.chain().focus().toggleCodeBlock().run(); - }, - }, - { - name: "quote", - description: "Block quote", - icon: "fa-quote-left", - action: (editor) => { - editor.chain().focus().toggleBlockquote().run(); - }, - }, - { - name: "divider", - description: "Horizontal rule", - icon: "fa-minus", - action: (editor) => { - editor.chain().focus().setHorizontalRule().run(); - }, - }, -]; - const styleId = "slash-command-style"; if (!document.getElementById(styleId)) { const style = document.createElement("style"); @@ -154,10 +54,77 @@ if (!document.getElementById(styleId)) { color: #a1a1aa; margin-left: auto; } + .file-picker-menu { + position: absolute; + z-index: 1000; + background: #27272a; + border: 1px solid #3f3f46; + border-radius: 4px; + padding: 2px; + width: 280px; + max-height: 208px; + overflow-y: auto; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + .file-picker-header { + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + color: #a1a1aa; + } + .file-picker-divider { + height: 1px; + background: #3f3f46; + margin-bottom: 4px; + } + .file-picker-item { + display: flex; + align-items: center; + width: 100%; + padding: 2px 8px; + cursor: pointer; + color: #e5e5e5; + font-size: 13px; + border-radius: 2px; + transition: background 0.1s; + text-align: left; + background: none; + border: none; + gap: 8px; + } + .file-picker-item:hover, + .file-picker-item.selected { + background: #3f3f46; + } + .file-picker-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-picker-size { + flex-shrink: 0; + font-size: 12px; + color: #71717a; + } + .file-picker-empty { + padding: 4px 8px; + text-align: center; + color: #71717a; + font-size: 13px; + } `; document.head.appendChild(style); } +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + const isAtLineStart = (state: { doc: { resolve: (pos: number) => { @@ -173,15 +140,147 @@ const isAtLineStart = (state: { return textBefore.trim() === ""; }; -export const SlashCommands = Extension.create({ +export interface SlashCommandsOptions { + sdk?: FrontendSDK; +} + +export const SlashCommands = Extension.create({ name: "slashCommands", + addOptions() { + return { + sdk: undefined, + }; + }, + addProseMirrorPlugins() { const editor = this.editor; + const sdk = this.options.sdk; + + const commands: SlashCommand[] = [ + { + name: "files", + description: "Embed a hosted file", + icon: "fa-file", + hasSubmenu: true, + action: () => {}, + }, + { + name: "request", + description: "Embed a replay session", + icon: "fa-paper-plane", + action: (ed) => { + ed.chain().focus().insertContent("@").run(); + }, + }, + { + name: "table", + description: "Insert a table", + icon: "fa-table", + action: (ed) => { + ed.chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + }, + }, + { + name: "h1", + description: "Large heading", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 1 }).run(); + }, + }, + { + name: "h2", + description: "Medium heading", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 2 }).run(); + }, + }, + { + name: "h3", + description: "Small heading", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 3 }).run(); + }, + }, + { + name: "h4", + description: "Heading 4", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 4 }).run(); + }, + }, + { + name: "h5", + description: "Heading 5", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 5 }).run(); + }, + }, + { + name: "h6", + description: "Heading 6", + icon: "fa-heading", + action: (ed) => { + ed.chain().focus().toggleMarkdownHeading({ level: 6 }).run(); + }, + }, + { + name: "bullet", + description: "Bullet list", + icon: "fa-list-ul", + action: (ed) => { + ed.chain().focus().toggleBulletList().run(); + }, + }, + { + name: "numbered", + description: "Numbered list", + icon: "fa-list-ol", + action: (ed) => { + ed.chain().focus().toggleOrderedList().run(); + }, + }, + { + name: "code", + description: "Code block", + icon: "fa-code", + action: (ed) => { + ed.chain().focus().toggleCodeBlock().run(); + }, + }, + { + name: "quote", + description: "Block quote", + icon: "fa-quote-left", + action: (ed) => { + ed.chain().focus().toggleBlockquote().run(); + }, + }, + { + name: "divider", + description: "Horizontal rule", + icon: "fa-minus", + action: (ed) => { + ed.chain().focus().setHorizontalRule().run(); + }, + }, + ]; + let menuElement: HTMLElement | undefined = undefined; + let filePickerElement: HTMLElement | undefined = undefined; let selectedIndex = 0; + let fileSelectedIndex = 0; let filteredCommands: SlashCommand[] = []; let slashPos: number | undefined = undefined; + let showingFilePicker = false; const updateMenu = (query: string) => { filteredCommands = commands.filter((cmd) => @@ -212,7 +311,8 @@ export const SlashCommands = Extension.create({ selectedIndex = parseInt(item.getAttribute("data-index") || "0"); updateMenu(query); }); - item.addEventListener("click", () => { + item.addEventListener("mousedown", (event) => { + event.preventDefault(); executeCommand(); }); }); @@ -239,8 +339,130 @@ export const SlashCommands = Extension.create({ menuElement.remove(); menuElement = undefined; } + hideFilePicker(); slashPos = undefined; filteredCommands = []; + showingFilePicker = false; + }; + + const hideFilePicker = () => { + if (filePickerElement) { + filePickerElement.remove(); + filePickerElement = undefined; + } + showingFilePicker = false; + }; + + const showFilePicker = () => { + if (!sdk) { + return; + } + + if (!menuElement || slashPos === undefined) return; + + const files = sdk.files.getAll(); + + const coords = editor.view.coordsAtPos(slashPos); + + menuElement.remove(); + menuElement = undefined; + + filePickerElement = document.createElement("div"); + filePickerElement.className = "file-picker-menu"; + filePickerElement.style.left = `${coords.left}px`; + filePickerElement.style.top = `${coords.bottom + 8}px`; + + if (files.length === 0) { + filePickerElement.innerHTML = ` +
Select a hosted file
+
+
No files found
+ `; + } else { + fileSelectedIndex = 0; + filePickerElement.innerHTML = ` +
Select a hosted file
+
+ ${files + .map( + (file, i) => ` + + `, + ) + .join("")} + `; + + filePickerElement + .querySelectorAll(".file-picker-item") + .forEach((item) => { + item.addEventListener("mousedown", (event) => { + event.preventDefault(); + const index = parseInt(item.getAttribute("data-index") || "0"); + const file = files[index]; + if (file) { + insertFile(file); + } + }); + item.addEventListener("mouseenter", () => { + fileSelectedIndex = parseInt( + item.getAttribute("data-index") || "0", + ); + updateFilePickerSelection(files); + }); + }); + } + + document.body.appendChild(filePickerElement); + showingFilePicker = true; + }; + + const updateFilePickerSelection = ( + files: { id: string; name: string; size: number; path: string }[], + ) => { + if (!filePickerElement) return; + filePickerElement + .querySelectorAll(".file-picker-item") + .forEach((item, i) => { + if (i === fileSelectedIndex) { + item.classList.add("selected"); + } else { + item.classList.remove("selected"); + } + }); + }; + + const insertFile = (file: { + id: string; + name: string; + size: number; + path: string; + }) => { + if (slashPos === undefined) return; + + const { state } = editor.view; + const from = slashPos; + const to = state.selection.from; + + editor.view.dispatch(state.tr.delete(from, to)); + + editor + .chain() + .focus() + .insertContent({ + type: "fileMention", + attrs: { + id: file.id, + name: file.name, + size: file.size, + path: file.path, + }, + }) + .run(); + + hideMenu(); }; const executeCommand = () => { @@ -249,13 +471,18 @@ export const SlashCommands = Extension.create({ const command = filteredCommands[selectedIndex]; if (!command) return; + if (command.name === "files") { + showFilePicker(); + return; + } + const { state } = editor.view; const from = slashPos; const to = state.selection.from; editor.view.dispatch(state.tr.delete(from, to)); - command.action(editor); + command.action(editor, sdk); hideMenu(); }; @@ -270,6 +497,46 @@ export const SlashCommands = Extension.create({ }, }, handleKeyDown(view, event) { + if (showingFilePicker && sdk) { + const files = sdk.files.getAll(); + + if (event.key === "ArrowDown") { + event.preventDefault(); + fileSelectedIndex = (fileSelectedIndex + 1) % files.length; + updateFilePickerSelection(files); + return true; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + fileSelectedIndex = + (fileSelectedIndex - 1 + files.length) % files.length; + updateFilePickerSelection(files); + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + const file = files[fileSelectedIndex]; + if (file) { + insertFile(file); + } + return true; + } + + if (event.key === "Escape") { + hideFilePicker(); + return true; + } + + if (event.key === "ArrowLeft" || event.key === "Backspace") { + hideFilePicker(); + return false; + } + + return false; + } + if (!menuElement) { if (event.key === "/" && isAtLineStart(view.state)) { setTimeout(() => { @@ -309,6 +576,15 @@ export const SlashCommands = Extension.create({ return true; } + if (event.key === "ArrowRight") { + const command = filteredCommands[selectedIndex]; + if (command?.hasSubmenu && command.name === "files") { + event.preventDefault(); + showFilePicker(); + return true; + } + } + return false; }, decorations(state) { diff --git a/packages/frontend/src/utils/jsonToMarkdown.ts b/packages/frontend/src/utils/jsonToMarkdown.ts index 7fcfe0a..209e898 100644 --- a/packages/frontend/src/utils/jsonToMarkdown.ts +++ b/packages/frontend/src/utils/jsonToMarkdown.ts @@ -34,6 +34,15 @@ async function fetchReplaySessionContent( } } +const MAX_FILE_SIZE_BYTES = 150 * 1024 * 1024; + +interface FileMention { + id: string; + path: string; + size: number; + name: string; +} + function collectMentionIds(content: NoteContentItem[]): string[] { const ids: string[] = []; @@ -49,9 +58,35 @@ function collectMentionIds(content: NoteContentItem[]): string[] { return ids; } -function replaceMentionsWithCodeBlocks( +function collectFileMentions(content: NoteContentItem[]): FileMention[] { + const files: FileMention[] = []; + + for (const node of content) { + if (node.type === "fileMention" && node.attrs) { + files.push({ + id: node.attrs.id as string, + path: node.attrs.path as string, + size: node.attrs.size as number, + name: node.attrs.name as string, + }); + } + if (node.content) { + files.push(...collectFileMentions(node.content)); + } + } + + return files; +} + +function getFileExtension(filename: string): string { + const parts = filename.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; +} + +function processContentTokens( content: NoteContentItem[], httpContentMap: Map, + fileContentMap: Map, ): NoteContentItem[] { const result: NoteContentItem[] = []; @@ -82,10 +117,55 @@ function replaceMentionsWithCodeBlocks( ], }); } + } else if (node.type === "fileMention" && node.attrs) { + const path = node.attrs.path as string; + const size = node.attrs.size as number; + const name = node.attrs.name as string; + + if (size > MAX_FILE_SIZE_BYTES) { + result.push({ + type: "paragraph", + content: [ + { + type: "text", + text: `File: ${path}`, + }, + ], + }); + } else { + const fileContent = fileContentMap.get(path); + if (fileContent !== undefined) { + result.push({ + type: "codeBlock", + attrs: { language: getFileExtension(name) }, + content: [ + { + type: "text", + text: fileContent, + }, + ], + }); + } else { + // Fallback if content fetch failed but wasn't large + result.push({ + type: "paragraph", + content: [ + { + type: "text", + text: `File: ${path} (content unavailable)`, + }, + ], + }); + } + } } else if (node.content) { result.push({ ...node, - content: replaceMentionsWithCodeBlocks(node.content, httpContentMap), + content: processContentTokens( + node.content, + httpContentMap, + fileContentMap, + ), }); } else { result.push(node); @@ -100,23 +180,56 @@ export async function convertTipTapToMarkdown( sdk: FrontendSDK, ): Promise { const mentionIds = collectMentionIds(content.content || []); + const fileMentions = collectFileMentions(content.content || []); const httpContentMap = new Map(); + const fileContentMap = new Map(); + + const promises: Promise[] = []; + + // Fetch replay sessions if (mentionIds.length > 0) { - const fetchPromises = mentionIds.map(async (id) => { - const httpContent = await fetchReplaySessionContent(sdk, id); - if (httpContent) { - httpContentMap.set(id, httpContent); - } - }); - await Promise.all(fetchPromises); + const p = (async () => { + const fetchPromises = mentionIds.map(async (id) => { + const httpContent = await fetchReplaySessionContent(sdk, id); + if (httpContent) { + httpContentMap.set(id, httpContent); + } + }); + await Promise.all(fetchPromises); + })(); + promises.push(p); } + // Fetch file contents + if (fileMentions.length > 0) { + const p = (async () => { + const fetchPromises = fileMentions.map(async (file) => { + if (file.size <= MAX_FILE_SIZE_BYTES) { + try { + const content = await sdk.backend.getFileContent(file.path); + fileContentMap.set(file.path, content); + } catch (err) { + console.error( + `Failed to fetch content for file ${file.path}:`, + err, + ); + } + } + }); + await Promise.all(fetchPromises); + })(); + promises.push(p); + } + + await Promise.all(promises); + const processedContent: NoteContent = { ...content, - content: replaceMentionsWithCodeBlocks( + content: processContentTokens( content.content || [], httpContentMap, + fileContentMap, ), }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d96fd26..7305de4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,7 +47,7 @@ importers: version: 0.48.0 '@caido/sdk-frontend': specifier: 0.48.0 - version: 0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + version: 0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.39.3) shared: specifier: workspace:* version: link:../shared @@ -57,6 +57,33 @@ importers: '@caido/primevue': specifier: 0.1.13 version: 0.1.13(primevue@4.1.0(vue@3.5.13(typescript@5.8.3))) + '@codemirror/lang-css': + specifier: ^6.3.1 + version: 6.3.1 + '@codemirror/lang-html': + specifier: ^6.4.11 + version: 6.4.11 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 + '@codemirror/language': + specifier: ^6.11.3 + version: 6.11.3 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/view': + specifier: ^6.39.3 + version: 6.39.3 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@tiptap/core': specifier: ^2.12.0 version: 2.12.0(@tiptap/pm@2.12.0) @@ -120,7 +147,7 @@ importers: version: 0.48.0 '@caido/sdk-frontend': specifier: ^0.48.0 - version: 0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + version: 0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.39.3) backend: specifier: workspace:* version: link:../backend @@ -190,11 +217,35 @@ packages: '@caido/tailwindcss@0.0.1': resolution: {integrity: sha512-BGp7s8BiZv6eBV8x/j0t5nPBVKP7Bm+gJVY4APcFgFkNkrRSRDo0VuXN52OhiHc/+vTg85lrmLO8IWMM5bcJrQ==} + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/language@6.11.3': + resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} + + '@codemirror/lint@6.9.2': + resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} + '@codemirror/state@6.5.2': resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} - '@codemirror/view@6.36.5': - resolution: {integrity: sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==} + '@codemirror/view@6.39.3': + resolution: {integrity: sha512-ZR32LYnPMpf7XZcrYJpSrHJUHNZPTj73/amTtZLhAwzYhSKiDI2OZmCiXbTRvxL1T8X7QTHnCG+KfnRJvH/QsA==} '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} @@ -433,6 +484,30 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@lezer/common@1.4.0': + resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==} + + '@lezer/css@1.3.0': + resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.12': + resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.5': + resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==} + + '@lezer/markdown@1.6.1': + resolution: {integrity: sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -2935,10 +3010,10 @@ snapshots: '@caido/quickjs-types': 0.18.0 '@caido/sdk-shared': 0.1.1 - '@caido/sdk-frontend@0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)': + '@caido/sdk-frontend@0.48.0(@codemirror/state@6.5.2)(@codemirror/view@6.39.3)': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.5 + '@codemirror/view': 6.39.3 '@caido/sdk-shared@0.1.1': {} @@ -2948,13 +3023,81 @@ snapshots: transitivePeerDependencies: - ts-node + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + '@lezer/common': 1.4.0 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@lezer/common': 1.4.0 + '@lezer/css': 1.3.0 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + '@lezer/common': 1.4.0 + '@lezer/css': 1.3.0 + '@lezer/html': 1.3.12 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.11.3 + '@codemirror/lint': 6.9.2 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + '@lezer/common': 1.4.0 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.11.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + '@lezer/common': 1.4.0 + '@lezer/markdown': 1.6.1 + + '@codemirror/language@6.11.3': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + style-mod: 4.1.2 + + '@codemirror/lint@6.9.2': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.39.3 + crelt: 1.0.6 + '@codemirror/state@6.5.2': dependencies: '@marijn/find-cluster-break': 1.0.2 - '@codemirror/view@6.36.5': + '@codemirror/view@6.39.3': dependencies: '@codemirror/state': 6.5.2 + crelt: 1.0.6 style-mod: 4.1.2 w3c-keyname: 2.2.8 @@ -3124,6 +3267,45 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@lezer/common@1.4.0': {} + + '@lezer/css@1.3.0': + dependencies: + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.4.0 + + '@lezer/html@1.3.12': + dependencies: + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/lr@1.4.5': + dependencies: + '@lezer/common': 1.4.0 + + '@lezer/markdown@1.6.1': + dependencies: + '@lezer/common': 1.4.0 + '@lezer/highlight': 1.2.3 + '@marijn/find-cluster-break@1.0.2': {} '@mdn/browser-compat-data@5.7.6': {} From 907423847bf25ff6929dd2e57e122152464764e2 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Thu, 11 Dec 2025 21:07:21 +0300 Subject: [PATCH 2/2] fix typecheck --- .../content/editor/extensions/mentions/mention-file.ts | 2 +- packages/frontend/src/utils/jsonToMarkdown.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts b/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts index 0a7e583..f9eeddc 100644 --- a/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts +++ b/packages/frontend/src/components/content/editor/extensions/mentions/mention-file.ts @@ -181,7 +181,7 @@ function formatFileSize(bytes: number): string { function getFileExtension(filename: string): string { const parts = filename.split("."); - return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; + return parts.length > 1 ? parts[parts.length - 1]!.toLowerCase() : ""; } function getLanguageExtension(ext: string) { diff --git a/packages/frontend/src/utils/jsonToMarkdown.ts b/packages/frontend/src/utils/jsonToMarkdown.ts index 209e898..48daa49 100644 --- a/packages/frontend/src/utils/jsonToMarkdown.ts +++ b/packages/frontend/src/utils/jsonToMarkdown.ts @@ -80,7 +80,7 @@ function collectFileMentions(content: NoteContentItem[]): FileMention[] { function getFileExtension(filename: string): string { const parts = filename.split("."); - return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; + return parts.length > 1 ? parts[parts.length - 1]!.toLowerCase() : ""; } function processContentTokens(