diff --git a/README.md b/README.md index c7b0df94..aa43bda0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Work with code and text, run processes, and automate tasks, going far beyond oth - [How to install](#how-to-install) - [Getting Started](#getting-started) - [Usage](#usage) +- [File Preview UI & Markdown Editor](#file-preview-ui--markdown-editor) - [Handling Long-Running Commands](#handling-long-running-commands) - [Work in Progress and TODOs](#roadmap) - [Sponsors and Supporters](#support-desktop-commander) @@ -54,7 +55,7 @@ Execute long-running terminal commands on your computer and manage processes thr ## Features - **Remote AI Control** - Use Desktop Commander from ChatGPT, Claude web, and other AI services via [Remote MCP](https://mcp.desktopcommander.app) -- **File Preview UI** - Visual file previews in Claude Desktop with rendered markdown, inline images, expandable content, and quick "Open in folder" access +- **File Preview UI** - Visual file previews in Claude Desktop with rendered markdown, inline images, expandable content, built-in markdown editor, and quick "Open in folder" access - **Enhanced terminal commands with interactive process control** - **Execute code in memory (Python, Node.js, R) without saving files** - **Instant data analysis - just ask to analyze CSV/JSON/Excel files** @@ -694,6 +695,56 @@ Desktop Commander can be run in Docker containers for **complete isolation from - Claude can see and analyze the actual image content - Default 30-second timeout for URL requests +## File Preview UI & Markdown Editor + +Desktop Commander includes a rich file preview widget in Claude Desktop that renders files visually as AI works with them. + +### Supported file types +- **Markdown** — rendered preview with a built-in editor +- **Images** — inline display (PNG, JPEG, GIF, WebP, etc.) +- **Code files** — syntax-highlighted source view +- **HTML** — rendered preview with toggle to source view +- **Directories** — interactive tree with expand/collapse and lazy loading +- **PDF, Excel, DOCX** — native content extraction and display + +### Markdown Editor + +When viewing a `.md` file in Claude Desktop, you can edit it directly inside the preview panel — no need to open a separate app. + +**How to use:** +1. Ask Claude to read or create a markdown file +2. Expand the file preview to fullscreen using the **⤢ Expand** button +3. The editor activates automatically in fullscreen mode +4. Edit your content with a live preview toggle, copy, undo, and save controls +5. Changes are saved back to disk; collapse to return to inline view + +**Editor features:** +- Live **edit / preview toggle** — switch between raw markdown and rendered output +- **Auto-save** to disk with save status indicator +- **Undo** support to revert unsaved changes +- **Copy** button to grab the full markdown source +- **Open in editor** — launch your default markdown app directly from the panel +- Partial-file awareness — loads and merges surrounding lines when the file was only partially read +- Text selection context — select text in preview mode and the AI can reference your selection + +### Directory Browser + +When Claude runs `list_directory`, the result opens as an interactive file tree inside the preview panel — not just raw text output. + +**Features:** +- **Expandable tree** — folders expand and collapse on click; top-level contents shown immediately +- **Lazy loading** — subfolders load on demand to keep the initial view fast +- **Large directory handling** — directories with many items show a `⚠ click to load all` button instead of overwhelming the view +- **Open in Finder/Explorer** — each folder has a quick-open button to reveal it in your file manager +- **Click to preview** — clicking any file in the tree opens it in the file preview panel directly +- **Back navigation** — after opening a file from the tree, a ← Back button returns you to the directory view + +### Other preview features +- **Expand / collapse** — toggle between compact summary row and full panel +- **Open in folder** — reveal the file in Finder/Explorer with one click +- **Load more lines** — incrementally load content above or below a partial read window +- **Text selection** — highlight text in any preview; the AI can see and reference your selection + ## Fuzzy Search Log Analysis (npm scripts) The fuzzy search logging system includes convenient npm scripts for analyzing logs outside of the MCP environment: diff --git a/package-lock.json b/package-lock.json index a5bfd8ea..c2386930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,10 @@ "@modelcontextprotocol/sdk": "^1.9.0", "@opendocsg/pdf2md": "^0.2.2", "@supabase/supabase-js": "^2.89.0", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-image": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "@vscode/ripgrep": "^1.15.9", "cross-fetch": "^4.1.0", "exceljs": "^4.4.0", @@ -30,6 +34,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "sharp": "^0.34.5", + "tiptap-markdown": "^0.9.0", "unified": "^11.0.5", "unpdf": "^1.4.0", "zod": "^3.24.1", @@ -2138,6 +2143,12 @@ "node": ">=18" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", @@ -2348,6 +2359,396 @@ "node": ">=14.16" } }, + "node_modules/@tiptap/core": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", + "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.3.tgz", + "integrity": "sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.3.tgz", + "integrity": "sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.3.tgz", + "integrity": "sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.3.tgz", + "integrity": "sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz", + "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.3.tgz", + "integrity": "sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.3.tgz", + "integrity": "sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.3.tgz", + "integrity": "sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.3.tgz", + "integrity": "sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.3.tgz", + "integrity": "sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.3.tgz", + "integrity": "sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.22.3.tgz", + "integrity": "sha512-Qpp8c5LOQaNpHrzjqZtoxtIR+8sSqJ7k8v+8anmYw3nxjvt2kpfT28Vd7aWMX55ZS43LaxMx+MkZqbmgUmMP0w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.3.tgz", + "integrity": "sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.3.tgz", + "integrity": "sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", + "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.3.tgz", + "integrity": "sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.3.tgz", + "integrity": "sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.3.tgz", + "integrity": "sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.3.tgz", + "integrity": "sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.3.tgz", + "integrity": "sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.3.tgz", + "integrity": "sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.3.tgz", + "integrity": "sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", + "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", + "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.3.tgz", + "integrity": "sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/extension-blockquote": "^3.22.3", + "@tiptap/extension-bold": "^3.22.3", + "@tiptap/extension-bullet-list": "^3.22.3", + "@tiptap/extension-code": "^3.22.3", + "@tiptap/extension-code-block": "^3.22.3", + "@tiptap/extension-document": "^3.22.3", + "@tiptap/extension-dropcursor": "^3.22.3", + "@tiptap/extension-gapcursor": "^3.22.3", + "@tiptap/extension-hard-break": "^3.22.3", + "@tiptap/extension-heading": "^3.22.3", + "@tiptap/extension-horizontal-rule": "^3.22.3", + "@tiptap/extension-italic": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-list": "^3.22.3", + "@tiptap/extension-list-item": "^3.22.3", + "@tiptap/extension-list-keymap": "^3.22.3", + "@tiptap/extension-ordered-list": "^3.22.3", + "@tiptap/extension-paragraph": "^3.22.3", + "@tiptap/extension-strike": "^3.22.3", + "@tiptap/extension-text": "^3.22.3", + "@tiptap/extension-underline": "^3.22.3", + "@tiptap/extensions": "^3.22.3", + "@tiptap/pm": "^3.22.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -2464,6 +2865,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2473,6 +2890,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2494,6 +2917,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2874,7 +3298,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -2889,6 +3312,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2936,6 +3360,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3222,8 +3647,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-union": { "version": "2.1.0", @@ -3485,7 +3909,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -3510,7 +3933,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -3520,7 +3942,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3532,15 +3953,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -3592,6 +4011,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4304,8 +4724,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/core-util-is": { "version": "1.0.3", @@ -4402,6 +4821,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -4883,7 +5308,6 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -4915,7 +5339,8 @@ "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "4.0.2", @@ -5614,7 +6039,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5676,7 +6100,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5685,8 +6108,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ext-list": { "version": "2.2.2", @@ -5975,7 +6397,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -5994,7 +6415,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6003,8 +6423,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/find-up": { "version": "4.1.0", @@ -6100,7 +6519,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -7551,6 +7969,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", @@ -7562,6 +7986,7 @@ "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", "license": "MIT", + "peer": true, "dependencies": { "@samverschueren/stream-to-observable": "^0.3.0", "is-observable": "^1.1.0", @@ -8088,6 +8513,12 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "license": "ISC" + }, "node_modules/markdown-it/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8377,7 +8808,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8387,7 +8817,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -8424,7 +8853,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9011,7 +9439,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "peer": true, "bin": { "mime": "cli.js" }, @@ -9203,7 +9630,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9728,6 +10154,12 @@ "node": ">=4" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -9986,8 +10418,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -10159,6 +10590,216 @@ "node": ">=0.4.0" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.1.tgz", + "integrity": "sha512-2OSIKBFyLo2iqDpjQHEC7tKt3lluhY7L44pcRai8EpoU9R7cZDj/dklEsOOIubNKWUXab6dL7y4JtAWnrlR4lA==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "peer": true, + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-trailing-node/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -10716,6 +11357,12 @@ "node": "*" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10940,7 +11587,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -10965,7 +11611,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10974,8 +11619,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -11083,7 +11727,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -11997,6 +12640,46 @@ "node": ">=0.10.0" } }, + "node_modules/tiptap-markdown": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", + "integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "@types/markdown-it": "^13.0.7", + "markdown-it": "^14.1.0", + "markdown-it-task-lists": "^2.1.1", + "prosemirror-markdown": "^1.11.1" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.1" + } + }, + "node_modules/tiptap-markdown/node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT" + }, + "node_modules/tiptap-markdown/node_modules/@types/markdown-it": { + "version": "13.0.9", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", + "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^3", + "@types/mdurl": "^1" + } + }, + "node_modules/tiptap-markdown/node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -12254,7 +12937,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", - "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -12290,6 +12972,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12572,7 +13255,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -12630,6 +13312,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz", @@ -12672,6 +13360,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12721,6 +13410,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -13233,6 +13923,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f038f7b2..3bb42044 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,10 @@ "@modelcontextprotocol/sdk": "^1.9.0", "@opendocsg/pdf2md": "^0.2.2", "@supabase/supabase-js": "^2.89.0", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-image": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "@vscode/ripgrep": "^1.15.9", "cross-fetch": "^4.1.0", "exceljs": "^4.4.0", @@ -103,6 +107,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "sharp": "^0.34.5", + "tiptap-markdown": "^0.9.0", "unified": "^11.0.5", "unpdf": "^1.4.0", "zod": "^3.24.1", diff --git a/scripts/build-ui-runtime.cjs b/scripts/build-ui-runtime.cjs index 9a7fafe4..7d944cb5 100644 --- a/scripts/build-ui-runtime.cjs +++ b/scripts/build-ui-runtime.cjs @@ -16,7 +16,6 @@ const TARGETS = { styleLayers: [ 'src/ui/styles/base.css', 'src/ui/styles/components/compact-row.css', - 'src/ui/styles/components/tool-header.css', 'src/ui/styles/apps/file-preview.css' ] }, @@ -73,7 +72,7 @@ async function buildTarget(targetName) { platform: 'browser', target: ['es2020'], outfile: outputPath, - minify: false, + minify: true, sourcemap: false }); } diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index e9c5b33a..ce002d97 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -45,7 +45,7 @@ function expandHome(filePath: string): string { * Resolve a file path to an absolute path for use in structured content. * This ensures "Open in folder" always has a valid absolute path. */ -function resolveAbsolutePath(filePath: string): string { +export function resolveAbsolutePath(filePath: string): string { const expanded = expandHome(filePath); return path.isAbsolute(expanded) ? path.resolve(expanded) @@ -286,12 +286,18 @@ export async function handleWriteFile(args: unknown): Promise { // Provide more informative message based on mode const modeMessage = parsed.mode === 'append' ? 'appended to' : 'wrote to'; + const resolvedWritePath = resolveAbsolutePath(parsed.path); return { content: [{ type: "text", text: `Successfully ${modeMessage} ${parsed.path} (${lineCount} lines) ${errorMessage}` }], + structuredContent: { + fileName: path.basename(resolvedWritePath), + filePath: resolvedWritePath, + fileType: resolvePreviewFileType(resolvedWritePath), + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/server.ts b/src/server.ts index abc8d2be..fa03b9f7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -405,6 +405,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ${PATH_GUIDANCE} ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(WriteFileArgsSchema), + _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true), annotations: { title: "Write File", readOnlyHint: false, @@ -784,6 +785,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ${PATH_GUIDANCE} ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(EditBlockArgsSchema), + _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true), annotations: { title: "Edit Block", readOnlyHint: false, diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 5933b9cc..489bf6df 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -26,6 +26,8 @@ import path from 'path'; import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js'; import { configManager } from '../config-manager.js'; import { fuzzySearchLogger, type FuzzySearchLogEntry } from '../utils/fuzzySearchLogger.js'; +import { resolvePreviewFileType } from '../ui/file-preview/shared/preview-file-types.js'; +import { resolveAbsolutePath } from '../handlers/filesystem-handlers.js'; interface SearchReplace { search: string; @@ -200,11 +202,32 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into await writeFile(filePath, newContent); capture('server_edit_block_exact_success', {fileExtension: fileExtension, expectedReplacements, hasWarning: warningMessage !== ""}); + const resolvedEditPath = resolveAbsolutePath(filePath); + + // Show a partial preview centered on the edited area + const newLines = newContent.split('\n'); + const totalLines = newLines.length; + const changePos = content.indexOf(normalizedSearch); + const changeStartLine = changePos >= 0 ? newContent.substring(0, changePos).split('\n').length - 1 : 0; + const changeLineCount = block.replace.split('\n').length; + const contextLines = 10; + const previewStart = Math.max(0, changeStartLine - contextLines); + const previewEnd = Math.min(totalLines, changeStartLine + changeLineCount + contextLines); + const previewContent = newLines.slice(previewStart, previewEnd).join('\n'); + const previewLineCount = previewEnd - previewStart; + const remaining = totalLines - previewEnd; + const statusLine = `[Reading ${previewLineCount} lines from ${previewStart === 0 ? 'start' : `line ${previewStart}`} (total: ${totalLines} lines, ${remaining} remaining)]\n\n`; + return { - content: [{ - type: "text", - text: `Successfully applied ${expectedReplacements} edit${expectedReplacements > 1 ? 's' : ''} to ${filePath}${warningMessage}` + content: [{ + type: "text", + text: `${statusLine}${previewContent}` }], + structuredContent: { + fileName: path.basename(resolvedEditPath), + filePath: resolvedEditPath, + fileType: resolvePreviewFileType(resolvedEditPath), + }, }; } @@ -405,11 +428,17 @@ export async function handleEditBlock(args: unknown): Promise { try { // parsed.range is guaranteed non-empty string by hasRange check above await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options); + const resolvedRangePath = resolveAbsolutePath(parsed.file_path); return { content: [{ type: "text", text: `Successfully updated range ${parsed.range} in ${parsed.file_path}` }], + structuredContent: { + fileName: path.basename(resolvedRangePath), + filePath: resolvedRangePath, + fileType: resolvePreviewFileType(resolvedRangePath), + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -437,11 +466,17 @@ export async function handleEditBlock(args: unknown): Promise { }); if (result.success) { + const resolvedEditRangePath = resolveAbsolutePath(parsed.file_path); return { content: [{ type: "text", text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}` }], + structuredContent: { + fileName: path.basename(resolvedEditRangePath), + filePath: resolvedEditRangePath, + fileType: resolvePreviewFileType(resolvedEditRangePath), + }, }; } diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index a3ed2054..825f68c0 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -1,782 +1,185 @@ /** - * Top-level controller for the File Preview app. It routes structured content into the appropriate renderer, handles host events, and coordinates user-facing state changes. + * Composition root for the File Preview app. It wires host services, file-type handlers, and specialized controllers together without owning feature logic inline. */ -import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from './components/code-viewer.js'; -import { renderHtmlPreview } from './components/html-renderer.js'; -import { renderMarkdown } from './components/markdown-renderer.js'; -import { escapeHtml } from './components/highlighting.js'; -import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js'; -import type { FilePreviewStructuredContent } from '../../../types.js'; -import type { HtmlPreviewMode } from './types.js'; +import { App } from '@modelcontextprotocol/ext-apps'; import { createCompactRowShellController, type ToolShellController } from '../../shared/tool-shell.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { renderCompactRow } from '../../shared/compact-row.js'; -import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { connectWithSharedHostContext, type UiChromeState } from '../../shared/host-context.js'; import { createUiEventTracker } from '../../shared/ui-event-tracker.js'; -import { App } from '@modelcontextprotocol/ext-apps'; +import { attachDirectoryHandlers } from './directory-controller.js'; +import { buildDocumentLayout } from './document-layout.js'; +import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js'; +import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js'; +import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js'; +import { attachSelectionContext } from './host/selection-context.js'; +import { createMarkdownController } from './markdown/controller.js'; +import { + createConflictDialogController, + renderConflictDialogMarkup, + type ConflictDialogController, +} from './markdown/conflict-dialog.js'; +import type { RenderPayload } from './model.js'; +import { attachPanelActions } from './panel-actions.js'; +import { extractRenderPayload, extractToolText, getFileExtensionForAnalytics, isLikelyUrl, isPreviewStructuredContent } from './payload-utils.js'; +import type { HtmlPreviewMode } from './types.js'; let isExpanded = false; let hideSummaryRow = false; let previewShownFired = false; let onRender: (() => void) | undefined; let trackUiEvent: ((event: string, params?: Record) => void) | undefined; +let conflictDialogController: ConflictDialogController | undefined; let rpcCallTool: ((name: string, args: Record) => Promise) | undefined; let rpcUpdateContext: ((text: string) => void) | undefined; +let openExternalLink: ((url: string) => Promise) | undefined; +let requestDisplayMode: ((mode: 'inline' | 'fullscreen') => Promise) | undefined; let shellController: ToolShellController | undefined; +let currentPayload: RenderPayload | undefined; +let currentHtmlMode: HtmlPreviewMode = 'rendered'; +let currentHostContext: Record | undefined; +let rerenderCurrent: (() => void) | undefined; +let syncPayload: ((payload?: RenderPayload) => void) | undefined; +let persistPayload: ((payload: RenderPayload) => void) | undefined; +let localPayloadOverride: RenderPayload | undefined; +let hostPayload: RenderPayload | undefined; +let inlinePayloadBeforeFullscreen: RenderPayload | undefined; let directoryBackPayload: RenderPayload | undefined; +let selectionAbortController: AbortController | null = null; +const markdownEditorAppCache = new Map(); +const markdownEditorAppPending = new Set(); -function getFileExtensionForAnalytics(filePath: string): string { - const normalizedPath = filePath.trim().replace(/\\/g, '/'); - const fileName = normalizedPath.split('/').pop() ?? normalizedPath; - const dotIndex = fileName.lastIndexOf('.'); - if (dotIndex <= 0 || dotIndex === fileName.length - 1) { - return 'none'; - } - return fileName.slice(dotIndex + 1).toLowerCase(); -} - -// Internal type used only for rendering — extends the public type with the -// text content sourced from the MCP content array (not structuredContent). -type RenderPayload = FilePreviewStructuredContent & { content: string }; - -function isPreviewStructuredContent(value: unknown): value is FilePreviewStructuredContent { - if (!isObjectRecord(value)) { - return false; - } - - return ( - typeof value.fileName === 'string' && - typeof value.filePath === 'string' && - typeof value.fileType === 'string' - ); -} - -function buildRenderPayload( - meta: FilePreviewStructuredContent, - text: string -): RenderPayload { - return { ...meta, content: text }; -} - -function extractRenderPayload(value: unknown): RenderPayload | undefined { - if (!isObjectRecord(value)) { - return undefined; - } - const meta = isPreviewStructuredContent(value.structuredContent) - ? value.structuredContent - : isPreviewStructuredContent(value) - ? value - : null; - if (!meta) return undefined; - const text = extractToolText(value) ?? extractToolText(value.structuredContent) ?? ''; - return buildRenderPayload(meta, text); -} - -function extractToolText(value: unknown): string | undefined { - if (!isObjectRecord(value)) { - return undefined; - } - const content = value.content; - if (!Array.isArray(content)) { - return undefined; - } - for (const item of content) { - if (!isObjectRecord(item)) { - continue; - } - if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { - return item.text; - } - } - return undefined; -} - -function isLikelyUrl(filePath: string): boolean { - return /^https?:\/\//i.test(filePath); -} - -function buildBreadcrumb(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const parts = normalized.split('/').filter(Boolean); - // Show last 3-4 meaningful segments as breadcrumb - const tail = parts.slice(-4); - return tail.map(p => escapeHtml(p)).join(' '); -} - -function getParentDirectory(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const lastSlash = normalized.lastIndexOf('/'); - if (lastSlash <= 0) { - return filePath; - } - return normalized.slice(0, lastSlash); -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function encodePowerShellCommand(script: string): string { - // PowerShell -EncodedCommand expects UTF-16LE bytes. - const utf16leBytes: number[] = []; - for (let index = 0; index < script.length; index += 1) { - const codeUnit = script.charCodeAt(index); - utf16leBytes.push(codeUnit & 0xff, codeUnit >> 8); - } - - let binary = ''; - for (const byte of utf16leBytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary); +async function callToolIfReady(name: string, args: Record): Promise { + return rpcCallTool ? rpcCallTool(name, args) : undefined; } -function buildOpenInFolderCommand(filePath: string): string | undefined { - const trimmedPath = filePath.trim(); - if (!trimmedPath || isLikelyUrl(trimmedPath)) { - return undefined; - } - - const userAgent = navigator.userAgent.toLowerCase(); - if (userAgent.includes('win')) { - const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); - const script = `Start-Process -FilePath explorer.exe -ArgumentList @('/select,','${escapedForPowerShell}')`; - return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; - } - if (userAgent.includes('mac')) { - return `open -R ${shellQuote(trimmedPath)}`; +function getAvailableDisplayModes(): string[] { + const rawModes = currentHostContext?.availableDisplayModes; + if (!Array.isArray(rawModes)) { + return []; } - return `xdg-open ${shellQuote(getParentDirectory(trimmedPath))}`; + return rawModes.filter((mode): mode is string => typeof mode === 'string'); } -function renderRawFallback(source: string): string { - return `
${escapeHtml(source)}
`; +function getCurrentDisplayMode(): string | null { + return typeof currentHostContext?.displayMode === 'string' + ? currentHostContext.displayMode + : null; } -interface DirEntry { - name: string; - isDir: boolean; - isDenied: boolean; - isWarning: boolean; - warningText: string; - depth: number; - children: DirEntry[]; - relativePath: string; +function storePayloadOverride(payload: RenderPayload): void { + localPayloadOverride = payload; + currentPayload = payload; + persistPayload?.(payload); } -function parseDirectoryEntries(content: string): { hint: string; entries: DirEntry[] } { - const lines = content.split('\n'); - // First line(s) before listing are the hint message - const hintLines: string[] = []; - const entryLines: string[] = []; - for (const line of lines) { - if (/^\[(DIR|FILE|DENIED|WARNING)\]/.test(line.trim())) { - entryLines.push(line.trim()); - } else if (entryLines.length === 0) { - hintLines.push(line); - } +function getEffectiveIncomingPayload(payload: RenderPayload): RenderPayload { + if (!localPayloadOverride) { + return payload; } - // Build flat list - const flat: { name: string; fullPath: string; isDir: boolean; isDenied: boolean; isWarning: boolean; warningText: string; depth: number }[] = []; - for (const line of entryLines) { - if (line.startsWith('[WARNING]')) { - // Format: [WARNING] dirName: N items hidden (showing first M of T total) - const warnBody = line.replace(/^\[WARNING\]\s*/, ''); - const colonIdx = warnBody.indexOf(':'); - const dirName = colonIdx >= 0 ? warnBody.slice(0, colonIdx).trim() : ''; - const msg = colonIdx >= 0 ? warnBody.slice(colonIdx + 1).trim() : warnBody; - // Depth matches the directory it belongs to — infer from dirName path segments - const parts = dirName.replace(/\\/g, '/').split('/').filter(Boolean); - const depth = parts.length; // warning sits inside the dir, so same depth as children - flat.push({ name: dirName, fullPath: dirName, isDir: false, isDenied: false, isWarning: true, warningText: msg, depth }); - continue; - } - const isDir = line.startsWith('[DIR]'); - const isDenied = line.startsWith('[DENIED]'); - const name = line.replace(/^\[(DIR|FILE|DENIED)\]\s*/, ''); - const parts = name.replace(/\\/g, '/').split('/'); - flat.push({ name, fullPath: name, isDir, isDenied, isWarning: false, warningText: '', depth: parts.length - 1 }); + if (localPayloadOverride.filePath !== payload.filePath) { + localPayloadOverride = undefined; + return payload; } - // Build tree from flat list - const root: DirEntry[] = []; - const stack: DirEntry[][] = [root]; - - for (const item of flat) { - const baseName = item.fullPath.replace(/\\/g, '/').split('/').pop() ?? item.fullPath; - const entry: DirEntry = { name: baseName, isDir: item.isDir, isDenied: item.isDenied, isWarning: item.isWarning, warningText: item.warningText, depth: item.depth, children: [], relativePath: item.fullPath }; - - // Adjust stack to match depth - while (stack.length > item.depth + 1) stack.pop(); - - const parent = stack[stack.length - 1]; - parent.push(entry); - - if (item.isDir) { - stack.push(entry.children); - } + const incomingContent = stripReadStatusLine(payload.content); + const overriddenContent = stripReadStatusLine(localPayloadOverride.content); + if (incomingContent === overriddenContent) { + return payload; } - return { hint: hintLines.join('\n').trim(), entries: root }; + return localPayloadOverride; } -let dirEntryIdCounter = 0; - -function renderDirTree(entries: DirEntry[], rootPath: string): string { - if (entries.length === 0) return '
Empty directory
'; - - function renderEntries(items: DirEntry[]): string { - return items.map(item => { - const id = `de-${dirEntryIdCounter++}`; - const fullPath = rootPath + '/' + item.relativePath.replace(/\\/g, '/'); - const ep = escapeHtml(fullPath); - - if (item.isWarning) { - const parentPath = rootPath + '/' + item.relativePath.replace(/\\/g, '/'); - const epp = escapeHtml(parentPath); - return `
`; +function updateSaveStatusDOM(label: string, statusClass: string): void { + const existing = document.querySelector('.panel-save-status') as HTMLElement | null; + if (label) { + if (existing) { + existing.textContent = label; + existing.className = `panel-save-status panel-save-status--${statusClass}`; + } else { + const actions = document.querySelector('.panel-topbar-actions') as HTMLElement | null; + if (actions) { + const span = document.createElement('span'); + span.className = `panel-save-status panel-save-status--${statusClass}`; + span.textContent = label; + actions.prepend(span); } - if (item.isDenied) { - return `
🚫 ${escapeHtml(item.name)}
`; - } - if (item.isDir) { - const has = item.children.length > 0; - const chev = `${has ? '▼' : '▶'}`; - const openBtn = ``; - const ch = has ? `
${renderEntries(item.children)}
` : ''; - return `
${chev} 📁 ${escapeHtml(item.name)}${openBtn}
${ch}
`; - } - return `
📄 ${escapeHtml(item.name)}
`; - }).join(''); - } - - return `
${renderEntries(entries)}
`; -} - -function renderDirectoryBody(content: string, rootPath: string): { html: string; notice?: string } { - dirEntryIdCounter = 0; - const { hint, entries } = parseDirectoryEntries(content); - const treeHtml = renderDirTree(entries, rootPath); - return { - notice: hint || undefined, - html: `
${treeHtml}
` - }; -} - -function attachDirectoryHandlers(container: HTMLElement, rootPayload: RenderPayload): void { - const tree = container.querySelector('.dir-tree'); - if (!tree) return; - - tree.addEventListener('click', async (e) => { - // Handle "open in finder" button — stop propagation so folder doesn't toggle - const openBtn = (e.target as HTMLElement).closest('.dir-open-btn') as HTMLElement | null; - if (openBtn) { - e.stopPropagation(); - const openPath = openBtn.dataset.openpath; - if (!openPath) return; - const cmd = buildOpenInFolderCommand(openPath); - if (cmd) { - try { await rpcCallTool?.('start_process', { command: cmd, timeout_ms: 12000 }); } catch {} - } - return; - } - - // Handle "load more" warning button — reload parent directory fully - const loadMoreBtn = (e.target as HTMLElement).closest('.dir-load-more') as HTMLElement | null; - if (loadMoreBtn) { - e.stopPropagation(); - const loadPath = loadMoreBtn.dataset.loadpath; - if (!loadPath) return; - loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Loading…'; - (loadMoreBtn as HTMLButtonElement).disabled = true; - try { - const result = await rpcCallTool?.('list_directory', { path: loadPath, depth: 1 }); - const text = (result as any)?.content?.[0]?.text; - if (text && typeof text === 'string') { - const parsed = parseDirectoryEntries(text); - const html = renderDirTree(parsed.entries, loadPath); - // Replace the parent .dir-children container contents - const parentChildren = loadMoreBtn.closest('.dir-children'); - if (parentChildren) { - const temp = document.createElement('div'); - temp.innerHTML = html; - const inner = temp.querySelector('.dir-tree'); - parentChildren.innerHTML = inner ? inner.innerHTML : ''; - } - } - } catch { - loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Failed to load'; - (loadMoreBtn as HTMLButtonElement).disabled = false; - } - return; } - - const target = (e.target as HTMLElement).closest('.dir-row') as HTMLElement | null; - if (!target) return; - const fullPath = target.dataset.path; - if (!fullPath) return; - - if (target.classList.contains('dir-row-folder')) { - const eid = target.dataset.eid; - if (!eid) return; - const childrenEl = document.getElementById(`${eid}-ch`); - const chevron = target.querySelector('.dir-chevron'); - - if (childrenEl) { - const hidden = childrenEl.classList.toggle('dir-collapsed'); - chevron?.classList.toggle('expanded', !hidden); - if (chevron) chevron.textContent = hidden ? '▶' : '▼'; - } else { - if (target.dataset.loaded === 'true') return; - if (chevron) chevron.textContent = '⏳'; - try { - const result = await rpcCallTool?.('list_directory', { path: fullPath, depth: 2 }); - const text = (result as any)?.content?.[0]?.text; - if (text && typeof text === 'string') { - target.dataset.loaded = 'true'; - const parsed = parseDirectoryEntries(text); - const html = renderDirTree(parsed.entries, fullPath); - const wrapper = document.createElement('div'); - wrapper.className = 'dir-children'; - wrapper.id = `${eid}-ch`; - const temp = document.createElement('div'); - temp.innerHTML = html; - const inner = temp.querySelector('.dir-tree'); - wrapper.innerHTML = inner ? inner.innerHTML : 'Empty'; - target.parentElement?.appendChild(wrapper); - chevron?.classList.add('expanded'); - if (chevron) chevron.textContent = '▼'; - } - } catch { - if (chevron) chevron.textContent = '⚠'; - } - } + } else if (existing) { + existing.remove(); + } +} + +const markdownController = createMarkdownController({ + callTool: callToolIfReady, + openExternalLink: async (url) => (openExternalLink ? openExternalLink(url) : undefined), + requestDisplayMode: async (mode) => (requestDisplayMode ? requestDisplayMode(mode) : undefined), + getAvailableDisplayModes, + getCurrentDisplayMode, + getCurrentPayload: () => currentPayload, + setExpanded: (expanded) => { + isExpanded = expanded; + }, + syncPayload: (payload) => syncPayload?.(payload), + storePayloadOverride, + rerender: () => { + rerenderCurrent?.(); + }, + updateSaveStatus: updateSaveStatusDOM, + trackUiEvent: (event, params) => trackUiEvent?.(event, params), + showConflictDialog: (options) => { + if (conflictDialogController) { + conflictDialogController.open(options); return; } - - if (target.classList.contains('dir-row-file')) { - target.classList.add('dir-loading'); - try { - const result = await rpcCallTool?.('read_file', { path: fullPath }); - const r = result as any; - if (r?.structuredContent) { - directoryBackPayload = rootPayload; - const text = r.content?.[0]?.text ?? ''; - const newPayload = buildRenderPayload(r.structuredContent, text); - renderApp(container.closest('#app') as HTMLElement, newPayload, 'rendered', true); - } - } catch { - target.classList.remove('dir-loading'); - } - } - }); -} - -function stripReadStatusLine(content: string): string { - // Remove the synthetic read status header shown by read_file pagination. - return content.replace(/^\[Reading [^\]]+\]\r?\n?/, ''); -} - -function renderImageBody(payload: RenderPayload): { html: string; notice?: string } { - const mimeType = normalizeImageMimeType(payload.mimeType); - if (!isAllowedImageMimeType(mimeType)) { - return { - notice: 'Preview is unavailable for this image format.', - html: '
' - }; - } - - if (!payload.imageData || payload.imageData.trim().length === 0) { - return { - notice: 'Preview is unavailable because image data is missing.', - html: '
' - }; - } - - const src = `data:${mimeType};base64,${payload.imageData}`; - return { - html: `
${escapeHtml(payload.fileName)}
` - }; -} - -function countContentLines(content: string): number { - const cleaned = stripReadStatusLine(content); - if (cleaned === '') return 0; - const lines = cleaned.split('\n'); - return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; -} - -interface ReadRange { - fromLine: number; - toLine: number; - totalLines: number; - isPartial: boolean; -} - -function parseReadRange(content: string): ReadRange | undefined { - // Parse "[Reading N lines from line M (total: T lines, R remaining)]" - // or "[Reading N lines from start (total: T lines, R remaining)]" - const match = content.match(/^\[Reading (\d+) lines from (?:line )?(\d+|start) \(total: (\d+) lines/); - if (!match) return undefined; - const count = parseInt(match[1], 10); - const from = match[2] === 'start' ? 1 : parseInt(match[2], 10); - const total = parseInt(match[3], 10); - return { - fromLine: from, - toLine: from + count - 1, - totalLines: total, - isPartial: count < total - }; -} - -function renderBody(payload: RenderPayload, htmlMode: HtmlPreviewMode, startLine = 1): { html: string; notice?: string } { - const cleanedContent = stripReadStatusLine(payload.content); - - if (payload.fileType === 'image') { - return renderImageBody(payload); - } - - if (payload.fileType === 'directory') { - return renderDirectoryBody(cleanedContent, payload.filePath); - } - - if (payload.fileType === 'unsupported') { - return { - notice: 'Preview is not available for this file type.', - html: '
' - }; - } - - if (payload.fileType === 'html') { - return renderHtmlPreview(cleanedContent, htmlMode); - } - - if (payload.fileType !== 'markdown') { - const detectedLanguage = inferLanguageFromPath(payload.filePath); - const formatted = formatJsonIfPossible(cleanedContent, payload.filePath); - return { - notice: formatted.notice, - html: `
${renderCodeViewer(formatted.content, detectedLanguage, startLine)}
` - }; - } - - try { - return { - html: `
${renderMarkdown(cleanedContent)}
` - }; - } catch { - return { - notice: 'Markdown renderer failed. Showing raw source instead.', - html: `
${renderRawFallback(cleanedContent)}
` - }; - } -} - -function attachCopyHandler(payload: RenderPayload): void { - const copyButton = document.getElementById('copy-source'); - if (!copyButton) { - return; - } - - const fallbackCopy = (text: string): boolean => { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.setAttribute('readonly', ''); - textArea.style.position = 'fixed'; - textArea.style.top = '-9999px'; - document.body.appendChild(textArea); - textArea.select(); - const success = document.execCommand('copy'); - document.body.removeChild(textArea); - return success; - }; - - const setButtonState = (label: string, revertMs?: number): void => { - copyButton.setAttribute('title', label); - copyButton.setAttribute('aria-label', label); - copyButton.textContent = label; - if (revertMs) { - setTimeout(() => { - copyButton.textContent = 'Copy'; - copyButton.setAttribute('title', 'Copy source'); - copyButton.setAttribute('aria-label', 'Copy source'); - }, revertMs); - } - }; - - const copyTextData = async (text: string): Promise => { - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - return fallbackCopy(text); - } catch { - return fallbackCopy(text); - } - }; - - copyButton.addEventListener('click', async () => { - trackUiEvent?.('copy_clicked', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - const cleanedContent = stripReadStatusLine(payload.content); - - const copied = await copyTextData(cleanedContent); - setButtonState(copied ? 'Copied!' : 'Copy failed', 1500); - }); -} - -function attachHtmlToggleHandler(container: HTMLElement, payload: RenderPayload, htmlMode: HtmlPreviewMode): void { - const toggleButton = document.getElementById('toggle-html-mode'); - if (!toggleButton || payload.fileType !== 'html') { - return; - } - toggleButton.addEventListener('click', () => { - const nextMode: HtmlPreviewMode = htmlMode === 'rendered' ? 'source' : 'rendered'; - trackUiEvent?.('html_view_toggled', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - renderApp(container, payload, nextMode, isExpanded); - }); -} - -function attachOpenInFolderHandler(payload: RenderPayload): void { - const openButton = document.getElementById('open-in-folder') as HTMLButtonElement | null; - if (!openButton) { - return; - } - - const command = buildOpenInFolderCommand(payload.filePath); - if (!command) { - openButton.disabled = true; - return; - } - - openButton.addEventListener('click', async () => { - trackUiEvent?.('open_in_folder', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - try { - await rpcCallTool?.('start_process', { - command, - timeout_ms: 12000 - }); - } catch { - // Keep UI stable if opening folder fails. - } - }); -} - -function attachLoadAllHandler( - container: HTMLElement, - payload: RenderPayload, - htmlMode: HtmlPreviewMode -): void { - const beforeBtn = document.getElementById('load-before') as HTMLButtonElement | null; - const afterBtn = document.getElementById('load-after') as HTMLButtonElement | null; - if (!beforeBtn && !afterBtn) { - return; - } - - const range = parseReadRange(payload.content); - if (!range?.isPartial) return; - - const currentContent = stripReadStatusLine(payload.content); - - const loadLines = async (btn: HTMLButtonElement, direction: 'before' | 'after'): Promise => { - const originalText = btn.textContent; - btn.textContent = 'Loading…'; - btn.disabled = true; - - trackUiEvent?.(direction === 'before' ? 'load_lines_before' : 'load_lines_after', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - try { - // Load only the missing portion - const readArgs = direction === 'before' - ? { path: payload.filePath, offset: 0, length: range.fromLine - 1 } - : { path: payload.filePath, offset: range.toLine }; - - const result = await rpcCallTool?.('read_file', readArgs); - const resultObj = result as { content?: Array<{ text?: string }> } | undefined; - const newText = resultObj?.content?.[0]?.text; - - if (newText && typeof newText === 'string') { - const cleanNew = stripReadStatusLine(newText); - - // Merge: prepend or append the new lines - const merged = direction === 'before' - ? cleanNew + (cleanNew.endsWith('\n') ? '' : '\n') + currentContent - : currentContent + (currentContent.endsWith('\n') ? '' : '\n') + cleanNew; - - // Build updated status line reflecting the new range - const newFrom = direction === 'before' ? 1 : range.fromLine; - const newTo = direction === 'after' ? range.totalLines : range.toLine; - const lineCount = newTo - newFrom + 1; - const remaining = range.totalLines - newTo; - const isStillPartial = newFrom > 1 || newTo < range.totalLines; - const statusLine = isStillPartial - ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` - : ''; - - const mergedPayload: RenderPayload = { - ...payload, - content: statusLine + merged - }; - renderApp(container, mergedPayload, htmlMode, isExpanded); - } else { - btn.textContent = 'Failed to load'; - setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); - } - } catch { - btn.textContent = 'Failed to load'; - setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); - } - }; - - beforeBtn?.addEventListener('click', () => void loadLines(beforeBtn, 'before')); - afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); -} + // Dialog not yet initialized (would only happen if the save failure + // somehow fires before bootstrapApp). Fall back to the cancel callback + // so the editor still shows its inline note instead of silently no-op'ing. + console.warn('[file-preview] conflictDialogController not ready; firing onCancel fallback'); + options.onCancel?.(); + }, +}); /** - * Tracks native text selection and pushes it to the host via ui/update-model-context. - * - * How it works: - * 1. User drags to select text anywhere in the preview (markdown, code, HTML). - * 2. The selectionchange event fires; we extract the selected string. - * 3. We call rpcUpdateContext() which sends a ui/update-model-context JSON-RPC - * request to the host with the selected text + file path (+ line numbers for code). - * 4. The host stores this as widget context. - * 5. The LLM can access it by calling read_widget_context(tool_name="desktop-commander:read_file"). - * - * Note: as of Feb 2025, Claude does NOT auto-inject ui/update-model-context into - * the LLM's context window. The LLM must actively call read_widget_context to see - * the selection. A floating tooltip near the selection tells the user this is working. + * Check if a payload needs its file content to be read. + * Tool results from edit_block/write_file include structuredContent but + * their text is a success message, not file content. Detect this by + * checking for the absence of the read status line that read_file always includes. + * URL payloads are fetched remotely by read_file(isUrl:true); we can't + * re-fetch them from here (no isUrl flag on the refresh path), so skip. */ -let selectionAbortController: AbortController | null = null; - -function attachTextSelectionHandler(payload: RenderPayload): void { - const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - if (!contentWrapper) return; - - // Abort any previous selectionchange listener to avoid leaking listeners/closures - if (selectionAbortController) { - selectionAbortController.abort(); - selectionAbortController = null; - } - selectionAbortController = new AbortController(); - - let hintEl: HTMLElement | null = null; - let lastSelectedText = ''; - let hideTimer: ReturnType | null = null; - - function positionHint(selection: Selection): void { - if (!hintEl) return; - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); - const wrapperRect = contentWrapper!.getBoundingClientRect(); - - // Position above the selection, centered horizontally - let left = rect.left + rect.width / 2 - wrapperRect.left; - let top = rect.top - wrapperRect.top + contentWrapper!.scrollTop - 32; - - // Clamp within wrapper bounds - const hintWidth = hintEl.offsetWidth || 200; - left = Math.max(8, Math.min(left - hintWidth / 2, contentWrapper!.clientWidth - hintWidth - 8)); - top = Math.max(4, top); - - hintEl.style.left = `${left}px`; - hintEl.style.top = `${top}px`; - } - - function showHint(selection: Selection): void { - if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } - - if (!hintEl) { - hintEl = document.createElement('div'); - hintEl.className = 'selection-hint'; - hintEl.textContent = 'AI can see your selection'; - contentWrapper!.appendChild(hintEl); - } - hintEl.classList.add('visible'); - positionHint(selection); - } - - function hideHint(): void { - if (!hintEl) return; - hintEl.classList.remove('visible'); - hideTimer = setTimeout(() => { hintEl?.remove(); hintEl = null; }, 200); +function needsContentRead(payload: RenderPayload): boolean { + if (payload.fileType === 'directory' || payload.fileType === 'image' || payload.fileType === 'unsupported') { + return false; } - - function getLineInfo(selection: Selection): string { - const anchorRow = selection.anchorNode?.parentElement?.closest('.code-line') as HTMLElement | null; - const focusRow = selection.focusNode?.parentElement?.closest('.code-line') as HTMLElement | null; - if (anchorRow && focusRow) { - const a = parseInt(anchorRow.dataset.line ?? '', 10); - const f = parseInt(focusRow.dataset.line ?? '', 10); - if (!isNaN(a) && !isNaN(f)) { - const low = Math.min(a, f); - const high = Math.max(a, f); - return low === high ? `line ${low}` : `lines ${low}–${high}`; - } - } - return ''; + if (/^https?:\/\//i.test(payload.filePath)) { + return false; } + return !parseReadRange(payload.content); +} - document.addEventListener('selectionchange', () => { - const selection = document.getSelection(); - if (!selection || selection.isCollapsed) { - if (lastSelectedText) { - lastSelectedText = ''; - rpcUpdateContext?.(''); - hideHint(); - } - return; - } - - const text = selection.toString().trim(); - if (!text || text === lastSelectedText) return; - - // Only act on selections within our content area - const anchorInContent = contentWrapper!.contains(selection.anchorNode); - const focusInContent = contentWrapper!.contains(selection.focusNode); - if (!anchorInContent && !focusInContent) { - if (lastSelectedText) { - lastSelectedText = ''; - rpcUpdateContext?.(''); - hideHint(); +async function readAndResolvePayload( + payload: RenderPayload, + onReady: (payload: RenderPayload) => void +): Promise { + try { + const freshPayload = await markdownController.readPayload(payload.filePath); + if (freshPayload) { + onReady(freshPayload); + if (freshPayload.fileType === 'markdown') { + void markdownController.refreshFromDisk(freshPayload); } return; } - - lastSelectedText = text; - - const lineInfo = getLineInfo(selection); - const locationPart = lineInfo ? ` (${lineInfo})` : ''; - const context = `User selected text from file ${payload.filePath}${locationPart}:\n\`\`\`\n${text}\n\`\`\``; - - rpcUpdateContext?.(context); - showHint(selection); - - trackUiEvent?.('text_selected', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath), - char_count: text.length - }); - }, { signal: selectionAbortController!.signal }); + } catch { + // Fall through to original payload. + } + onReady(payload); } - function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = `
@@ -802,102 +205,123 @@ export function renderApp( expandedState = false ): void { isExpanded = expandedState; + currentHtmlMode = htmlMode; shellController?.dispose(); shellController = undefined; + if (!payload || payload.fileType !== 'markdown') { + markdownController.clear(); + } else { + markdownController.disposeHandles(); + } + if (!payload) { + selectionAbortController?.abort(); + selectionAbortController = null; + currentPayload = undefined; renderStatusState(container, 'No preview available for this response.'); onRender?.(); return; } - const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image'; - const canOpenInFolder = !isLikelyUrl(payload.filePath); - const fileExtension = getFileExtensionForAnalytics(payload.filePath); - const supportsPreview = payload.fileType !== 'unsupported'; - - // In DC app (hideSummaryRow), no reason to auto-expand when there's nothing to preview — - // the host header already shows the file name and path. - if (!supportsPreview && hideSummaryRow) { + currentPayload = payload; + const capabilities = getFileTypeCapabilities(payload); + if (!capabilities.supportsPreview && hideSummaryRow) { isExpanded = false; } + const range = parseReadRange(payload.content); - const body = renderBody(payload, htmlMode, range?.fromLine ?? 1); - const notice = body.notice ? `
${body.notice}
` : ''; + const body = renderPayloadBody({ + payload, + htmlMode, + startLine: range?.fromLine ?? 1, + markdownController, + }); + const markdownWorkspace = payload.fileType === 'markdown' ? markdownController.getState(payload) : undefined; + const fileExtension = getFileExtensionForAnalytics(payload.filePath); + const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; + const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({ + availableDisplayModes: getAvailableDisplayModes(), + }).canFullscreen; + + const defaultMarkdownEditor = payload.fileType === 'markdown' + ? markdownEditorAppCache.get(payload.filePath) + : undefined; + if (payload.fileType === 'markdown' && !defaultMarkdownEditor) { + void detectDefaultMarkdownEditor({ + filePath: payload.filePath, + editorAppCache: markdownEditorAppCache, + editorAppPending: markdownEditorAppPending, + callTool: callToolIfReady, + extractToolText, + onDetected: () => { + rerenderCurrent?.(); + }, + }); + } - const breadcrumb = buildBreadcrumb(payload.filePath); - const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(payload.content); - const fileTypeLabel = payload.fileType === 'markdown' ? 'MARKDOWN' - : payload.fileType === 'html' ? 'HTML' - : payload.fileType === 'image' ? 'IMAGE' - : payload.fileType === 'directory' ? 'DIRECTORY' - : fileExtension !== 'none' ? fileExtension.toUpperCase() - : 'TEXT'; + const layout = buildDocumentLayout({ + payload, + body, + capabilities, + fileExtension, + htmlMode, + currentDisplayMode: getCurrentDisplayMode(), + isExpanded, + hideSummaryRow, + markdownWorkspace, + canGoFullscreen, + isMarkdownUndoAvailable: markdownWorkspace ? markdownController.isUndoAvailable(markdownWorkspace) : false, + defaultMarkdownEditorName: defaultMarkdownEditor?.appName, + markdownEditorAppIcon: renderMarkdownEditorAppIcon(), + hasDirectoryBackButton: Boolean(directoryBackPayload), + }); - const compactLabel = range?.isPartial - ? `View lines ${range.fromLine}–${range.toLine}` - : payload.fileType === 'directory' ? 'View directory' - : 'View file'; - const footerLabel = range?.isPartial - ? `${escapeHtml(fileTypeLabel)} • LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` - : `${escapeHtml(fileTypeLabel)} • ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`; + container.innerHTML = layout.html; + document.body.classList.add('dc-ready'); - const htmlToggle = payload.fileType === 'html' - ? `` - : ''; + attachPanelActions({ + container, + payload, + htmlMode, + getIsExpanded: () => isExpanded, + callTool: callToolIfReady, + trackUiEvent, + getFileExtensionForAnalytics, + buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl), + buildOpenInEditorCommand: (filePath) => buildOpenInEditorCommand(filePath, isLikelyUrl, markdownEditorAppCache), + render: (nextPayload, nextHtmlMode = 'rendered', nextExpanded = isExpanded) => { + renderApp(container, nextPayload, nextHtmlMode, nextExpanded); + }, + updateSaveStatus: updateSaveStatusDOM, + markdownController, + }); - const copyIcon = ``; - const folderIcon = ``; - - // Content-area banners for missing lines - const hasMissingBefore = range?.isPartial && range.fromLine > 1; - const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1; - const loadBeforeBanner = hasMissingBefore - ? `` - : ''; - const loadAfterBanner = hasMissingAfter - ? `` - : ''; + if (payload.fileType === 'markdown') { + markdownController.attachHandlers(payload); + } - const backButton = (directoryBackPayload && payload.fileType !== 'directory') - ? `` - : ''; + selectionAbortController = attachSelectionContext({ + payload, + isMarkdownEditing: payload.fileType === 'markdown' && !!markdownWorkspace, + updateContext: rpcUpdateContext, + trackUiEvent, + getFileExtensionForAnalytics, + previousAbortController: selectionAbortController, + }); - container.innerHTML = ` -
- ${renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })} -
-
- ${backButton} - ${breadcrumb} - - ${htmlToggle} - ${canOpenInFolder ? `` : ''} - ${canCopy && supportsPreview ? `` : ''} - -
- ${notice} -
- ${loadBeforeBanner} - ${body.html} - ${loadAfterBanner} -
- -
-
- `; - document.body.classList.add('dc-ready'); - attachCopyHandler(payload); - attachHtmlToggleHandler(container, payload, htmlMode); - attachOpenInFolderHandler(payload); - attachLoadAllHandler(container, payload, htmlMode); - attachTextSelectionHandler(payload); if (payload.fileType === 'directory') { - attachDirectoryHandlers(container, payload); + attachDirectoryHandlers({ + container, + callTool: callToolIfReady, + buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl), + onOpenPayload: (nextPayload) => { + directoryBackPayload = payload; + renderApp(container, nextPayload, 'rendered', true); + }, + }); } - // Back to directory navigation + const backBtn = document.getElementById('dir-back'); if (backBtn && directoryBackPayload) { const savedPayload = directoryBackPayload; @@ -906,38 +330,36 @@ export function renderApp( renderApp(container, savedPayload, 'rendered', true); }); } - // Clear back state when showing a directory if (payload.fileType === 'directory') { directoryBackPayload = undefined; } const compactRow = document.getElementById('compact-toggle') as HTMLElement | null; - shellController = createCompactRowShellController({ shell: document.getElementById('tool-shell'), compactRow, - initialExpanded: isExpanded, + initialExpanded: layout.effectiveExpanded, onToggle: (expanded) => { isExpanded = expanded; trackUiEvent?.(expanded ? 'expand' : 'collapse', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); }, onScrollAfterExpand: () => { trackUiEvent?.('scroll_after_expand', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); }, - onRender + onRender, }); onRender?.(); if (!previewShownFired) { previewShownFired = true; trackUiEvent?.('preview_shown', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); } } @@ -949,8 +371,19 @@ export function bootstrapApp(): void { } renderLoadingState(container); - // Use the official App class – it connects to the host via PostMessageTransport - // (window.parent by default) and speaks standard MCP JSON-RPC 2.0 over postMessage. + // Mount the conflict dialog once at body level. It's position: fixed and + // must live outside the app container so that re-renders of the document + // body never wipe it while it's open. + if (!document.getElementById('md-conflict-modal')) { + const dialogHost = document.createElement('div'); + dialogHost.innerHTML = renderConflictDialogMarkup(); + const dialogRoot = dialogHost.firstElementChild; + if (dialogRoot) { + document.body.appendChild(dialogRoot); + } + } + conflictDialogController = createConflictDialogController({ container: document }); + const app = new App( { name: 'Desktop Commander File Preview', version: '1.0.0' }, { updateModelContext: { text: {} } }, @@ -966,9 +399,8 @@ export function bootstrapApp(): void { hideSummaryRow = chrome.hideSummaryRow; }; - // Widget state for cross-host persistence (survives page refresh) const widgetState = createWidgetStateStorage( - (v): v is RenderPayload => isPreviewStructuredContent(v) && typeof (v as any).content === 'string' + (value): value is RenderPayload => isPreviewStructuredContent(value) && typeof (value as any).content === 'string' ); const renderAndSync = (payload?: RenderPayload): void => { @@ -977,7 +409,32 @@ export function bootstrapApp(): void { } renderApp(container, payload, 'rendered', isExpanded); }; + const syncFromPersistedWidgetState = (): void => { + const persistedPayload = widgetState.read(); + if (!persistedPayload) { + return; + } + + if ( + currentPayload + && currentPayload.filePath === persistedPayload.filePath + && stripReadStatusLine(currentPayload.content) === stripReadStatusLine(persistedPayload.content) + ) { + return; + } + + renderAndSync(persistedPayload); + }; + syncPayload = renderAndSync; + persistPayload = (payload: RenderPayload) => { + widgetState.write(payload); + }; + rerenderCurrent = () => { + renderApp(container, currentPayload, currentHtmlMode, isExpanded); + }; + + let pendingCachedPayload: RenderPayload | undefined; let initialStateResolved = false; const resolveInitialState = (payload?: RenderPayload, message?: string): void => { if (initialStateResolved) { @@ -985,31 +442,41 @@ export function bootstrapApp(): void { } initialStateResolved = true; if (payload) { + hostPayload = payload; renderAndSync(payload); + if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') { + void markdownController.requestEditMode(payload); + } + if (payload.fileType === 'markdown') { + void markdownController.refreshFromDisk(payload); + } return; } renderStatusState(container, message ?? 'No preview available for this response.'); onRender?.(); }; - // autoResize handles size reporting; onRender can be a no-op onRender = () => {}; - // Wire rpcCallTool through the App's callServerTool proxy rpcCallTool = (name: string, args: Record): Promise => ( app.callServerTool({ name, arguments: args }) ); - - // Wire rpcUpdateContext through the App's updateModelContext rpcUpdateContext = (text: string): void => { const params = text ? { content: [{ type: 'text' as const, text }] } : { content: [] as [] }; app.updateModelContext(params).catch(() => { - // Host may not support updateModelContext + // Host may not support updateModelContext. }); }; - + openExternalLink = async (url: string): Promise => { + const result = await app.openLink({ url }); + return result.isError !== true; + }; + requestDisplayMode = async (mode: 'inline' | 'fullscreen'): Promise => { + const result = await app.requestDisplayMode({ mode }); + return typeof result.mode === 'string' ? result.mode : null; + }; trackUiEvent = createUiEventTracker( (name, args) => app.callServerTool({ name, arguments: args }), { @@ -1018,26 +485,35 @@ export function bootstrapApp(): void { } ); - // Register ALL handlers BEFORE connect - app.onteardown = async () => { - shellController?.dispose(); - return {}; - }; + app.ontoolinput = (params) => { + const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined; + if ( + !initialStateResolved + && pendingCachedPayload + && requestedPath + && pendingCachedPayload.filePath === requestedPath + ) { + const cached = pendingCachedPayload; + pendingCachedPayload = undefined; + resolveInitialState(cached); + return; + } - app.ontoolinput = (_params) => { - // Tool is executing – show loading state renderLoadingState(container); onRender?.(); }; app.ontoolresult = (result) => { + pendingCachedPayload = undefined; const payload = extractRenderPayload(result); const message = extractToolText(result as unknown as Record); if (!initialStateResolved) { if (payload) { - renderLoadingState(container); - onRender?.(); - window.setTimeout(() => resolveInitialState(payload), 120); + if (needsContentRead(payload)) { + void readAndResolvePayload(payload, (p) => resolveInitialState(getEffectiveIncomingPayload(p))); + return; + } + resolveInitialState(getEffectiveIncomingPayload(payload)); return; } if (message) { @@ -1045,8 +521,14 @@ export function bootstrapApp(): void { } return; } + if (payload) { - renderAndSync(payload); + if (needsContentRead(payload)) { + renderLoadingState(container); + void readAndResolvePayload(payload, (p) => renderAndSync(getEffectiveIncomingPayload(p))); + } else { + renderAndSync(getEffectiveIncomingPayload(payload)); + } } else if (message) { renderStatusState(container, message); onRender?.(); @@ -1057,19 +539,94 @@ export function bootstrapApp(): void { resolveInitialState(undefined, params.reason ?? 'Tool was cancelled.'); }; - // Connect to the host (defaults to window.parent via PostMessageTransport) + const handleVisibilitySync = (): void => { + if (document.visibilityState === 'visible') { + syncFromPersistedWidgetState(); + } + }; + const handleFocusSync = (): void => { + // Only sync cross-tab state if the page was hidden (tab switch). + // Simple focus changes within the same page should not trigger a re-render + // as it destroys the active editor. + if (document.visibilityState !== 'visible') { + syncFromPersistedWidgetState(); + } + }; + + const teardown = (): void => { + shellController?.dispose(); + shellController = undefined; + markdownController.disposeHandles(); + selectionAbortController?.abort(); + selectionAbortController = null; + document.removeEventListener('visibilitychange', handleVisibilitySync); + window.removeEventListener('focus', handleFocusSync); + }; + + document.addEventListener('visibilitychange', handleVisibilitySync); + window.addEventListener('focus', handleFocusSync); + + app.onteardown = async () => { + teardown(); + return {}; + }; + void connectWithSharedHostContext({ app, chrome, - onContextApplied: syncChromeState, - onConnected: () => { - // Try to restore from persisted widget state (survives refresh on some hosts) - const cachedPayload = widgetState.read(); - if (cachedPayload) { - window.setTimeout(() => resolveInitialState(cachedPayload), 50); + onContextApplied: () => { + const previousDisplayMode = getCurrentDisplayMode(); + syncChromeState(); + currentHostContext = app.getHostContext() as Record | undefined; + const nextDisplayMode = getCurrentDisplayMode(); + const displayModeChanged = previousDisplayMode !== nextDisplayMode; + // Clicking a display-mode button blurs the editor first, and the + // editor's onBlur handler already persists dirty drafts, so there + // is nothing additional to save here. + if ( + previousDisplayMode === 'fullscreen' + && nextDisplayMode === 'inline' + && currentPayload?.fileType === 'markdown' + ) { + isExpanded = true; + chrome.expanded = true; + const restorePayload = inlinePayloadBeforeFullscreen ?? hostPayload; + const restoreWasPartial = restorePayload ? parseReadRange(restorePayload.content)?.isPartial === true : false; + if (restoreWasPartial && restorePayload) { + localPayloadOverride = restorePayload; + currentPayload = restorePayload; + widgetState.write(restorePayload); + void markdownController.handleInlineExitFromFullscreen(restorePayload).then((freshPayload) => { + if (freshPayload) { + currentPayload = freshPayload; + localPayloadOverride = freshPayload; + widgetState.write(freshPayload); + rerenderCurrent?.(); + } + }); + } else { + void markdownController.handleInlineExitFromFullscreen(); + } + inlinePayloadBeforeFullscreen = undefined; + } + if ( + previousDisplayMode !== 'fullscreen' + && nextDisplayMode === 'fullscreen' + && currentPayload?.fileType === 'markdown' + ) { + inlinePayloadBeforeFullscreen = currentPayload; + if (parseReadRange(currentPayload.content)?.isPartial) { + void markdownController.requestEditMode(currentPayload); + } } + if (initialStateResolved && displayModeChanged) { + rerenderCurrent?.(); + } + }, + onConnected: () => { + currentHostContext = app.getHostContext() as Record | undefined; + pendingCachedPayload = widgetState.read() ?? undefined; - // Fallback: if no tool data arrives, show a helpful status message window.setTimeout(() => { if (!initialStateResolved) { resolveInitialState( @@ -1085,6 +642,6 @@ export function bootstrapApp(): void { }); window.addEventListener('beforeunload', () => { - shellController?.dispose(); + teardown(); }, { once: true }); } diff --git a/src/ui/file-preview/src/components/markdown-renderer.ts b/src/ui/file-preview/src/components/markdown-renderer.ts index 0eaa648c..5f7d0aa0 100644 --- a/src/ui/file-preview/src/components/markdown-renderer.ts +++ b/src/ui/file-preview/src/components/markdown-renderer.ts @@ -1,28 +1,11 @@ /** * Markdown rendering pipeline for preview mode. It configures markdown-it and highlighting so markdown content is rendered consistently with code block support. */ -// markdown-it is intentionally typed locally here to avoid maintaining global ambient module declarations. -// @ts-expect-error markdown-it does not provide local TypeScript typings in this setup. -import MarkdownIt from 'markdown-it'; import { highlightSource } from './highlighting.js'; +import { createMarkdownIt, prepareMarkdownSource, readHeadingProjection, type MarkdownToken } from '../markdown/parser.js'; +import { createSlugTracker } from '../markdown/slugify.js'; -interface MarkdownRenderer { - render: (source: string) => string; -} - -type MarkdownItConstructor = new (options?: { - html?: boolean; - linkify?: boolean; - typographer?: boolean; - highlight?: (code: string, language: string) => string; -}) => MarkdownRenderer; - -const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; - -const markdown = new MarkdownItCtor({ - html: false, - linkify: true, - typographer: false, +const markdown = createMarkdownIt({ highlight(code: string, language: string): string { const normalizedLanguage = (language || 'text').toLowerCase(); const highlighted = highlightSource(code, normalizedLanguage); @@ -30,6 +13,56 @@ const markdown = new MarkdownItCtor({ } }); +const renderHeadingOpen = markdown.renderer.rules.heading_open; +markdown.renderer.rules.heading_open = (...args: unknown[]): string => { + const tokens = args[0] as MarkdownToken[]; + const index = args[1] as number; + const options = args[2] as unknown; + const environment = (args[3] as Record | undefined) ?? {}; + const self = args[4] as { renderToken: (tokens: Array>, index: number, options: unknown) => string }; + const nextSlug = typeof environment.nextSlug === 'function' + ? environment.nextSlug as (text: string) => string + : createSlugTracker(); + environment.nextSlug = nextSlug; + + const heading = readHeadingProjection(tokens, index, nextSlug); + const token = tokens[index] as { attrSet?: (name: string, value: string) => void }; + if (heading) { + token.attrSet?.('id', heading.id); + token.attrSet?.('data-heading-id', heading.id); + } + + if (typeof renderHeadingOpen === 'function') { + return renderHeadingOpen(...args); + } + + return self.renderToken(tokens as Array>, index, options); +}; + +const renderLinkOpen = markdown.renderer.rules.link_open; +markdown.renderer.rules.link_open = (...args: unknown[]): string => { + const tokens = args[0] as Array>; + const index = args[1] as number; + const options = args[2] as unknown; + const self = args[4] as { renderToken: (tokens: Array>, index: number, options: unknown) => string }; + const token = tokens[index] as { attrSet?: (name: string, value: string) => void; attrGet?: (name: string) => string | null; attrs?: Array<[string, string]> }; + token.attrSet?.('data-markdown-link', 'true'); + const title = token.attrGet?.('title'); + if (title?.startsWith('mcp-wiki:')) { + const rawWikiLink = decodeURIComponent(title.slice('mcp-wiki:'.length)); + token.attrSet?.('data-wiki-link', rawWikiLink); + if (Array.isArray(token.attrs)) { + token.attrs = token.attrs.filter(([name]) => name !== 'title'); + } + } + + if (typeof renderLinkOpen === 'function') { + return renderLinkOpen(...args); + } + + return self.renderToken(tokens, index, options); +}; + export function renderMarkdown(content: string): string { - return markdown.render(content); + return markdown.render(prepareMarkdownSource(content), { nextSlug: createSlugTracker() }); } diff --git a/src/ui/file-preview/src/components/toolbar.ts b/src/ui/file-preview/src/components/toolbar.ts deleted file mode 100644 index 5298a4bc..00000000 --- a/src/ui/file-preview/src/components/toolbar.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Toolbar component for preview controls (view mode, metadata, actions). It isolates UI control rendering and event plumbing from core preview orchestration. - */ -import type { FilePreviewStructuredContent } from '../../../../types.js'; -import type { HtmlPreviewMode } from '../types.js'; -import { renderToolHeader } from '../../../shared/tool-header.js'; - -function inferFilePill(payload: FilePreviewStructuredContent): { label: string; className: string } { - if (payload.fileType === 'markdown') { - return { label: 'MD', className: 'file-pill--md' }; - } - if (payload.fileType === 'html') { - return { label: 'HTML', className: 'file-pill--html' }; - } - - const extensionMatch = payload.filePath.toLowerCase().match(/\.([a-z0-9]+)$/); - const extension = extensionMatch ? extensionMatch[1] : 'txt'; - - if (extension === 'json') { - return { label: 'JSON', className: 'file-pill--json' }; - } - - return { label: extension.slice(0, 4).toUpperCase(), className: 'file-pill--text' }; -} - -export function renderToolbar( - payload: FilePreviewStructuredContent, - canCopy: boolean, - htmlMode: HtmlPreviewMode, - isExpanded: boolean, - canOpenInFolder: boolean -): string { - const supportsPreview = payload.fileType !== 'unsupported'; - const copyDisabled = canCopy ? '' : 'disabled'; - const copyTitle = canCopy ? 'Copy source' : 'Copy unavailable'; - const copyIcon = ` - - `; - const folderDisabled = canOpenInFolder ? '' : 'disabled'; - const folderTitle = canOpenInFolder ? 'Open in folder' : 'Open in folder unavailable'; - const folderIcon = ` - - `; - const previewIcon = isExpanded - ? `` - : ``; - - const htmlModeButton = payload.fileType === 'html' - ? ` - - ` - : ''; - - const filePill = inferFilePill(payload); - const leadingActions = supportsPreview - ? ` - - ` - : ''; - const trailingActions = supportsPreview - ? ` - ${htmlModeButton} - - ` - : ''; - - return renderToolHeader({ - pillLabel: filePill.label, - pillClassName: filePill.className, - title: payload.fileName, - subtitle: payload.filePath, - badges: [], - actionsHtml: ` - ${leadingActions} - - ${trailingActions} - ` - }); -} diff --git a/src/ui/file-preview/src/directory-controller.ts b/src/ui/file-preview/src/directory-controller.ts new file mode 100644 index 00000000..75feca32 --- /dev/null +++ b/src/ui/file-preview/src/directory-controller.ts @@ -0,0 +1,272 @@ +import { escapeHtml } from './components/highlighting.js'; +import type { RenderBodyResult, RenderPayload } from './model.js'; +import { buildRenderPayload, extractToolText } from './payload-utils.js'; + +interface DirEntry { + name: string; + isDir: boolean; + isDenied: boolean; + isWarning: boolean; + warningText: string; + children: DirEntry[]; + relativePath: string; +} + +function parseDirectoryEntries(content: string): { hint: string; entries: DirEntry[] } { + const lines = content.split('\n'); + const hintLines: string[] = []; + const entryLines: string[] = []; + for (const line of lines) { + if (/^\[(DIR|FILE|DENIED|WARNING)\]/.test(line.trim())) { + entryLines.push(line.trim()); + } else if (entryLines.length === 0) { + hintLines.push(line); + } + } + + const flat: Array<{ + name: string; + fullPath: string; + isDir: boolean; + isDenied: boolean; + isWarning: boolean; + warningText: string; + depth: number; + }> = []; + for (const line of entryLines) { + if (line.startsWith('[WARNING]')) { + const warnBody = line.replace(/^\[WARNING\]\s*/, ''); + const colonIdx = warnBody.indexOf(':'); + const dirName = colonIdx >= 0 ? warnBody.slice(0, colonIdx).trim() : ''; + const msg = colonIdx >= 0 ? warnBody.slice(colonIdx + 1).trim() : warnBody; + const parts = dirName.replace(/\\/g, '/').split('/').filter(Boolean); + flat.push({ + name: dirName, + fullPath: dirName, + isDir: false, + isDenied: false, + isWarning: true, + warningText: msg, + depth: parts.length, + }); + continue; + } + + const isDir = line.startsWith('[DIR]'); + const isDenied = line.startsWith('[DENIED]'); + const name = line.replace(/^\[(DIR|FILE|DENIED)\]\s*/, ''); + const parts = name.replace(/\\/g, '/').split('/'); + flat.push({ + name, + fullPath: name, + isDir, + isDenied, + isWarning: false, + warningText: '', + depth: parts.length - 1, + }); + } + + const root: DirEntry[] = []; + const stack: DirEntry[][] = [root]; + + for (const item of flat) { + const baseName = item.fullPath.replace(/\\/g, '/').split('/').pop() ?? item.fullPath; + const entry: DirEntry = { + name: baseName, + isDir: item.isDir, + isDenied: item.isDenied, + isWarning: item.isWarning, + warningText: item.warningText, + children: [], + relativePath: item.fullPath, + }; + + while (stack.length > item.depth + 1) { + stack.pop(); + } + + const parent = stack[stack.length - 1]; + parent.push(entry); + + if (item.isDir) { + stack.push(entry.children); + } + } + + return { hint: hintLines.join('\n').trim(), entries: root }; +} + +let dirEntryIdCounter = 0; + +function renderDirTree(entries: DirEntry[], rootPath: string): string { + if (entries.length === 0) { + return '
Empty directory
'; + } + + function renderEntries(items: DirEntry[]): string { + return items.map((item) => { + const id = `de-${dirEntryIdCounter++}`; + const fullPath = `${rootPath}/${item.relativePath.replace(/\\/g, '/')}`; + const escapedPath = escapeHtml(fullPath); + + if (item.isWarning) { + return `
`; + } + if (item.isDenied) { + return `
🚫 ${escapeHtml(item.name)}
`; + } + if (item.isDir) { + const hasChildren = item.children.length > 0; + const chevron = `${hasChildren ? '▼' : '▶'}`; + const openButton = ``; + const childrenHtml = hasChildren ? `
${renderEntries(item.children)}
` : ''; + return `
${chevron} 📁 ${escapeHtml(item.name)}${openButton}
${childrenHtml}
`; + } + + return `
📄 ${escapeHtml(item.name)}
`; + }).join(''); + } + + return `
${renderEntries(entries)}
`; +} + +export function renderDirectoryBody(content: string, rootPath: string): RenderBodyResult { + dirEntryIdCounter = 0; + const { hint, entries } = parseDirectoryEntries(content); + return { + notice: hint || undefined, + html: `
${renderDirTree(entries, rootPath)}
`, + }; +} + +export function attachDirectoryHandlers(options: { + container: HTMLElement; + callTool?: (name: string, args: Record) => Promise; + buildOpenInFolderCommand: (filePath: string) => string | undefined; + onOpenPayload: (payload: RenderPayload) => void; +}): void { + const tree = options.container.querySelector('.dir-tree'); + if (!tree) { + return; + } + + tree.addEventListener('click', async (event) => { + const openBtn = (event.target as HTMLElement).closest('.dir-open-btn') as HTMLElement | null; + if (openBtn) { + event.stopPropagation(); + const openPath = openBtn.dataset.openpath; + if (!openPath) { + return; + } + const command = options.buildOpenInFolderCommand(openPath); + if (command) { + try { + await options.callTool?.('start_process', { command, timeout_ms: 12000 }); + } catch { + // Keep UI stable if opening folder fails. + } + } + return; + } + + const loadMoreBtn = (event.target as HTMLElement).closest('.dir-load-more') as HTMLElement | null; + if (loadMoreBtn) { + event.stopPropagation(); + const loadPath = loadMoreBtn.dataset.loadpath; + if (!loadPath) { + return; + } + loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Loading…'; + (loadMoreBtn as HTMLButtonElement).disabled = true; + try { + const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1 }); + const text = extractToolText(result) ?? ''; + if (text) { + const parsed = parseDirectoryEntries(text); + const html = renderDirTree(parsed.entries, loadPath); + const parentChildren = loadMoreBtn.closest('.dir-children'); + if (parentChildren) { + const temp = document.createElement('div'); + temp.innerHTML = html; + const inner = temp.querySelector('.dir-tree'); + parentChildren.innerHTML = inner ? inner.innerHTML : ''; + } + } + } catch { + loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Failed to load'; + (loadMoreBtn as HTMLButtonElement).disabled = false; + } + return; + } + + const target = (event.target as HTMLElement).closest('.dir-row') as HTMLElement | null; + if (!target) { + return; + } + const fullPath = target.dataset.path; + if (!fullPath) { + return; + } + + if (target.classList.contains('dir-row-folder')) { + const entryId = target.dataset.eid; + if (!entryId) { + return; + } + const childrenEl = document.getElementById(`${entryId}-ch`); + const chevron = target.querySelector('.dir-chevron'); + + if (childrenEl) { + const hidden = childrenEl.classList.toggle('dir-collapsed'); + chevron?.classList.toggle('expanded', !hidden); + if (chevron) chevron.textContent = hidden ? '▶' : '▼'; + return; + } + + if (target.dataset.loaded === 'true') { + return; + } + if (chevron) chevron.textContent = '⏳'; + try { + const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2 }); + const text = extractToolText(result) ?? ''; + if (text) { + target.dataset.loaded = 'true'; + const parsed = parseDirectoryEntries(text); + const html = renderDirTree(parsed.entries, fullPath); + const wrapper = document.createElement('div'); + wrapper.className = 'dir-children'; + wrapper.id = `${entryId}-ch`; + const temp = document.createElement('div'); + temp.innerHTML = html; + const inner = temp.querySelector('.dir-tree'); + wrapper.innerHTML = inner ? inner.innerHTML : 'Empty'; + target.parentElement?.appendChild(wrapper); + chevron?.classList.add('expanded'); + if (chevron) chevron.textContent = '▼'; + } + } catch { + if (chevron) chevron.textContent = '⚠'; + } + return; + } + + if (target.classList.contains('dir-row-file')) { + target.classList.add('dir-loading'); + try { + const result = await options.callTool?.('read_file', { path: fullPath }); + if (!result || typeof result !== 'object' || result === null) { + return; + } + const structuredContent = (result as { structuredContent?: unknown }).structuredContent; + if (structuredContent && typeof structuredContent === 'object') { + const text = extractToolText(result) ?? ''; + options.onOpenPayload(buildRenderPayload(structuredContent as any, text)); + } + } catch { + target.classList.remove('dir-loading'); + } + } + }); +} diff --git a/src/ui/file-preview/src/document-layout.ts b/src/ui/file-preview/src/document-layout.ts new file mode 100644 index 00000000..d8364a5b --- /dev/null +++ b/src/ui/file-preview/src/document-layout.ts @@ -0,0 +1,140 @@ +import { renderCompactRow } from '../../shared/compact-row.js'; +import { escapeHtml } from '../../shared/escape-html.js'; +import { parseReadRange, stripReadStatusLine } from './document-workspace.js'; +import type { FileTypeCapabilities, MarkdownWorkspaceState, RenderBodyResult, RenderPayload } from './model.js'; +import { renderMarkdownCopyButton, renderMarkdownModeToggle } from './markdown/editor.js'; +import { buildBreadcrumb, countContentLines } from './payload-utils.js'; + +function renderCopyIcon(): string { + return ''; +} + +function renderFolderIcon(): string { + return ''; +} + +function renderUndoIcon(): string { + return ''; +} + +function renderExpandIcon(): string { + return ''; +} + +function renderMarkdownSaveStatus(workspace: MarkdownWorkspaceState): string { + if (workspace.fileDeleted) { + return 'File deleted'; + } + + if (workspace.saveIndicator !== 'saved') { + return ''; + } + + const variant = workspace.saving ? 'saving' : 'saved'; + return `Saved`; +} + +export function buildDocumentLayout(options: { + payload: RenderPayload; + body: RenderBodyResult; + capabilities: FileTypeCapabilities; + fileExtension: string; + htmlMode: 'rendered' | 'source'; + currentDisplayMode: string | null; + isExpanded: boolean; + hideSummaryRow: boolean; + markdownWorkspace?: MarkdownWorkspaceState; + canGoFullscreen: boolean; + isMarkdownUndoAvailable: boolean; + defaultMarkdownEditorName?: string; + markdownEditorAppIcon: string; + hasDirectoryBackButton: boolean; +}): { html: string; effectiveExpanded: boolean } { + const range = parseReadRange(options.payload.content); + const notice = options.body.notice ? `
${options.body.notice}
` : ''; + const breadcrumb = buildBreadcrumb(options.payload.filePath); + const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(options.payload.content); + const fileTypeLabel = options.payload.fileType === 'markdown' ? 'MARKDOWN' + : options.payload.fileType === 'html' ? 'HTML' + : options.payload.fileType === 'image' ? 'IMAGE' + : options.payload.fileType === 'directory' ? 'DIRECTORY' + : options.fileExtension !== 'none' ? options.fileExtension.toUpperCase() + : 'TEXT'; + + const compactLabel = range?.isPartial + ? `View lines ${range.fromLine}–${range.toLine}` + : options.payload.fileType === 'directory' ? 'View directory' + : 'View file'; + let footerLabel = range?.isPartial + ? `${fileTypeLabel} • LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` + : `${fileTypeLabel} • ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`; + + if (options.markdownWorkspace) { + const source = stripReadStatusLine(options.markdownWorkspace.draftContent); + const markdownWordCount = source.trim().split(/\s+/).filter(Boolean).length; + const markdownLineCount = countContentLines(source); + footerLabel = `${fileTypeLabel} • EDIT MODE • ${markdownLineCount} LINES • ${markdownWordCount} WORDS`; + } + + const isFullscreen = options.currentDisplayMode === 'fullscreen'; + const htmlToggle = options.payload.fileType === 'html' + ? `` + : ''; + const backButton = options.hasDirectoryBackButton && options.payload.fileType !== 'directory' + ? '' + : ''; + + const isMarkdown = options.payload.fileType === 'markdown'; + const isMarkdownEdit = isMarkdown && !!options.markdownWorkspace; + const revertDisabled = isMarkdownEdit && (options.markdownWorkspace!.fileDeleted || options.markdownWorkspace!.loadingDocument || !options.isMarkdownUndoAvailable); + const fileDeleted = isMarkdownEdit && options.markdownWorkspace!.fileDeleted; + + const hasMissingBefore = range?.isPartial && range.fromLine > 1; + const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1; + const loadBeforeBanner = hasMissingBefore + ? `` + : ''; + const loadAfterBanner = hasMissingAfter + ? `` + : ''; + + const effectiveExpanded = options.isExpanded || isFullscreen; + const canOpenInFolder = options.capabilities.canOpenInFolder; + const canCopy = options.capabilities.canCopy; + + return { + effectiveExpanded, + html: ` +
+ ${isFullscreen ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: options.payload.fileName, variant: 'ready', expandable: true, expanded: options.isExpanded, interactive: true })} +
+
+ ${backButton} + ${options.hideSummaryRow ? '' : `${breadcrumb}`} + + ${isMarkdownEdit ? renderMarkdownSaveStatus(options.markdownWorkspace!) : ''} + ${htmlToggle} + ${isMarkdownEdit && isFullscreen ? renderMarkdownModeToggle(options.markdownWorkspace!.editorView) : ''} + ${isMarkdown && !isFullscreen && options.canGoFullscreen ? `` : ''} + ${isMarkdownEdit ? `` : ''} + ${isMarkdownEdit && isFullscreen ? renderMarkdownCopyButton() : ''} + ${isMarkdown && !isFullscreen ? `` : ''} + ${canCopy && options.capabilities.supportsPreview && !isMarkdown ? `` : ''} + ${canOpenInFolder && isMarkdownEdit && isFullscreen ? `` : ''} + ${canOpenInFolder && !(isMarkdownEdit && isFullscreen) ? `` : ''} + +
+ ${notice} +
+ ${loadBeforeBanner} + ${options.body.html} + ${loadAfterBanner} +
+ +
+
+ `, + }; +} diff --git a/src/ui/file-preview/src/document-outline.ts b/src/ui/file-preview/src/document-outline.ts new file mode 100644 index 00000000..30a61f2d --- /dev/null +++ b/src/ui/file-preview/src/document-outline.ts @@ -0,0 +1,130 @@ +import { escapeHtml } from '../../shared/escape-html.js'; + +export interface DocumentOutlineItem { + id: string; + text: string; + level: number; + line?: number; +} + +export interface DocumentOutlineHandle { + dispose: () => void; + refresh: (outline: DocumentOutlineItem[], activeHeadingId?: string | null) => void; +} + +function setActiveItem(nav: HTMLElement, activeId: string | null): void { + const buttons = Array.from(nav.querySelectorAll('[data-toc-id]')); + buttons.forEach((button) => { + const isActive = button.dataset.tocId === activeId; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-current', isActive ? 'location' : 'false'); + }); +} + +function renderDocumentOutlineItems(outline: DocumentOutlineItem[], activeHeadingId?: string | null): string { + return outline.map((item) => { + const activeClass = item.id === activeHeadingId ? ' is-active' : ''; + return ``; + }).join(''); +} + +function renderCollapseIcon(): string { + return ''; +} + +function renderExpandIcon(): string { + return ''; +} + +export function renderDocumentOutline(outline: DocumentOutlineItem[], activeHeadingId?: string | null): string { + if (outline.length === 0) { + return ''; + } + + return ` + + `; +} + +export function attachDocumentOutline(options: { + shell: HTMLElement; + outline: DocumentOutlineItem[]; + scrollContainer: HTMLElement; + onSelect: (headingId: string) => void; +}): DocumentOutlineHandle | null { + const nav = options.shell.querySelector('.document-outline-nav') as HTMLElement | null; + if (!nav) { + return null; + } + let currentOutline = options.outline; + + const handleClick = (event: Event): void => { + const target = event.target as HTMLElement | null; + const button = target?.closest('[data-toc-id]'); + const headingId = button?.dataset.tocId; + if (!headingId) { + return; + } + + options.onSelect(headingId); + setActiveItem(nav, headingId); + }; + + const updateActiveHeading = (): void => { + const headings = currentOutline + .map((item) => { + const element = document.getElementById(item.id); + return element ? { item, element } : null; + }) + .filter((entry): entry is { item: DocumentOutlineItem; element: HTMLElement } => entry !== null); + + if (headings.length === 0) { + return; + } + + const scrollTop = options.scrollContainer.scrollTop; + const nextActive = headings.reduce((activeId, current) => { + if (current.element.offsetTop - scrollTop <= 96) { + return current.item.id; + } + return activeId; + }, headings[0].item.id); + + setActiveItem(nav, nextActive); + }; + + const toggleButton = options.shell.querySelector('#toc-toggle') as HTMLButtonElement | null; + const handleToggle = (): void => { + const isCollapsed = options.shell.classList.toggle('markdown-toc-collapsed'); + if (toggleButton) { + toggleButton.innerHTML = isCollapsed ? renderExpandIcon() : renderCollapseIcon(); + toggleButton.title = isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'; + toggleButton.setAttribute('aria-label', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'); + } + }; + toggleButton?.addEventListener('click', handleToggle); + + nav.addEventListener('click', handleClick); + options.scrollContainer.addEventListener('scroll', updateActiveHeading, { passive: true }); + updateActiveHeading(); + + return { + dispose: () => { + nav.removeEventListener('click', handleClick); + options.scrollContainer.removeEventListener('scroll', updateActiveHeading); + toggleButton?.removeEventListener('click', handleToggle); + }, + refresh: (outline, activeHeadingId) => { + currentOutline = outline; + nav.innerHTML = renderDocumentOutlineItems(currentOutline, activeHeadingId); + setActiveItem(nav, activeHeadingId ?? null); + updateActiveHeading(); + }, + }; +} diff --git a/src/ui/file-preview/src/document-workspace.ts b/src/ui/file-preview/src/document-workspace.ts new file mode 100644 index 00000000..bc2dd94e --- /dev/null +++ b/src/ui/file-preview/src/document-workspace.ts @@ -0,0 +1,49 @@ +export interface ReadRange { + fromLine: number; + toLine: number; + totalLines: number; + isPartial: boolean; + /** 0-based offset for read_file calls. "from start" → 0, "from line N" → N. */ + readOffset: number; +} + +export function stripReadStatusLine(content: string): string { + return content.replace(/^\[Reading [^\]]+\]\r?\n(?:\r?\n)?/, ''); +} + +export function parseReadRange(content: string): ReadRange | undefined { + const match = content.match(/^\[Reading (\d+) lines from (?:line )?(\d+|start) \(total: (\d+) lines/); + if (!match) { + return undefined; + } + + const count = Number.parseInt(match[1], 10); + const isFromStart = match[2] === 'start'; + const readOffset = isFromStart ? 0 : Number.parseInt(match[2], 10); + const fromLine = isFromStart ? 1 : readOffset; + const totalLines = Number.parseInt(match[3], 10); + return { + fromLine, + toLine: fromLine + count - 1, + totalLines, + isPartial: count < totalLines, + readOffset, + }; +} + +export function getDocumentFullscreenAvailability(options: { + availableDisplayModes?: string[]; +}): { canFullscreen: true } | { canFullscreen: false; reason: string } { + if (!options.availableDisplayModes?.includes('fullscreen')) { + return { + canFullscreen: false, + reason: 'Fullscreen editing is unavailable in this host.', + }; + } + + return { canFullscreen: true }; +} + +export function shouldAutoLoadDocumentOnEnterFullscreen(content: string): boolean { + return parseReadRange(content)?.isPartial === true; +} diff --git a/src/ui/file-preview/src/file-type-handlers.ts b/src/ui/file-preview/src/file-type-handlers.ts new file mode 100644 index 00000000..887c134f --- /dev/null +++ b/src/ui/file-preview/src/file-type-handlers.ts @@ -0,0 +1,123 @@ +import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from './components/code-viewer.js'; +import { escapeHtml } from './components/highlighting.js'; +import { renderHtmlPreview } from './components/html-renderer.js'; +import { renderDirectoryBody } from './directory-controller.js'; +import { stripReadStatusLine } from './document-workspace.js'; +import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js'; +import type { FileTypeCapabilities, RenderBodyResult, RenderPayload } from './model.js'; +import type { MarkdownController } from './markdown/controller.js'; +import { isLikelyUrl } from './payload-utils.js'; +import type { HtmlPreviewMode } from './types.js'; + +function renderRawFallback(source: string): string { + return `
${escapeHtml(source)}
`; +} + +function renderImageBody(payload: RenderPayload): RenderBodyResult { + const mimeType = normalizeImageMimeType(payload.mimeType); + if (!isAllowedImageMimeType(mimeType)) { + return { + notice: 'Preview is unavailable for this image format.', + html: '
', + }; + } + + if (!payload.imageData || payload.imageData.trim().length === 0) { + return { + notice: 'Preview is unavailable because image data is missing.', + html: '
', + }; + } + + const src = `data:${mimeType};base64,${payload.imageData}`; + return { + html: `
${escapeHtml(payload.fileName)}
`, + }; +} + +interface FileTypeHandler { + getCapabilities: (payload: RenderPayload) => FileTypeCapabilities; + renderBody: (options: { + payload: RenderPayload; + htmlMode: HtmlPreviewMode; + startLine: number; + markdownController: MarkdownController; + }) => RenderBodyResult; +} + +function buildPreviewCapabilities(payload: RenderPayload, canCopy: boolean): FileTypeCapabilities { + return { + supportsPreview: true, + canCopy, + canOpenInFolder: !isLikelyUrl(payload.filePath), + }; +} + +const handlerRegistry: Partial> = { + directory: { + getCapabilities: (payload) => buildPreviewCapabilities(payload, false), + renderBody: ({ payload }) => renderDirectoryBody(stripReadStatusLine(payload.content), payload.filePath), + }, + html: { + getCapabilities: (payload) => buildPreviewCapabilities(payload, true), + renderBody: ({ payload, htmlMode }) => renderHtmlPreview(stripReadStatusLine(payload.content), htmlMode), + }, + image: { + getCapabilities: (payload) => buildPreviewCapabilities(payload, false), + renderBody: ({ payload }) => renderImageBody(payload), + }, + markdown: { + getCapabilities: (payload) => buildPreviewCapabilities(payload, false), + renderBody: ({ payload, markdownController }) => { + try { + return markdownController.buildBody(payload); + } catch { + return { + notice: 'Markdown renderer failed. Showing raw source instead.', + html: `
${renderRawFallback(stripReadStatusLine(payload.content))}
`, + }; + } + }, + }, + text: { + getCapabilities: (payload) => buildPreviewCapabilities(payload, true), + renderBody: ({ payload, startLine }) => { + const cleanedContent = stripReadStatusLine(payload.content); + const detectedLanguage = inferLanguageFromPath(payload.filePath); + const formatted = formatJsonIfPossible(cleanedContent, payload.filePath); + return { + notice: formatted.notice, + html: `
${renderCodeViewer(formatted.content, detectedLanguage, startLine)}
`, + }; + }, + }, + unsupported: { + getCapabilities: () => ({ + supportsPreview: false, + canCopy: false, + canOpenInFolder: true, + }), + renderBody: () => ({ + notice: 'Preview is not available for this file type.', + html: '
', + }), + }, +}; + +export function getFileTypeCapabilities(payload: RenderPayload): FileTypeCapabilities { + return handlerRegistry[payload.fileType]?.getCapabilities(payload) ?? { + supportsPreview: false, + canCopy: false, + canOpenInFolder: !isLikelyUrl(payload.filePath), + }; +} + +export function renderPayloadBody(options: { + payload: RenderPayload; + htmlMode: HtmlPreviewMode; + startLine: number; + markdownController: MarkdownController; +}): RenderBodyResult { + const handler = handlerRegistry[options.payload.fileType] ?? handlerRegistry.text!; + return handler.renderBody(options); +} diff --git a/src/ui/file-preview/src/host/external-actions.ts b/src/ui/file-preview/src/host/external-actions.ts new file mode 100644 index 00000000..ff29c7bc --- /dev/null +++ b/src/ui/file-preview/src/host/external-actions.ts @@ -0,0 +1,117 @@ +import { getParentDirectory } from '../path-utils.js'; + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function encodePowerShellCommand(script: string): string { + const utf16leBytes: number[] = []; + for (let index = 0; index < script.length; index += 1) { + const codeUnit = script.charCodeAt(index); + utf16leBytes.push(codeUnit & 0xff, codeUnit >> 8); + } + + let binary = ''; + for (const byte of utf16leBytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export function buildOpenInFolderCommand(filePath: string, isLikelyUrl: (filePath: string) => boolean): string | undefined { + const trimmedPath = filePath.trim(); + if (!trimmedPath || isLikelyUrl(trimmedPath)) { + return undefined; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes('win')) { + const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); + const script = `Start-Process -FilePath explorer.exe -ArgumentList @('/select,','${escapedForPowerShell}')`; + return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; + } + if (userAgent.includes('mac')) { + return `open -R ${shellQuote(trimmedPath)}`; + } + + return `xdg-open ${shellQuote(getParentDirectory(trimmedPath))}`; +} + +export function buildOpenInEditorCommand( + filePath: string, + isLikelyUrl: (filePath: string) => boolean, + editorAppCache: Map +): string | undefined { + const trimmedPath = filePath.trim(); + if (!trimmedPath || isLikelyUrl(trimmedPath)) { + return undefined; + } + + const cachedApp = editorAppCache.get(trimmedPath); + if (cachedApp?.appPath && navigator.userAgent.toLowerCase().includes('mac')) { + return `open -a ${shellQuote(cachedApp.appPath)} ${shellQuote(trimmedPath)}`; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes('win')) { + const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); + const script = `Start-Process -FilePath '${escapedForPowerShell}'`; + return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; + } + if (userAgent.includes('mac')) { + return `open ${shellQuote(trimmedPath)}`; + } + + return `xdg-open ${shellQuote(trimmedPath)}`; +} + +export async function detectDefaultMarkdownEditor(options: { + filePath: string; + editorAppCache: Map; + editorAppPending: Set; + callTool?: (name: string, args: Record) => Promise; + extractToolText: (value: unknown) => string | undefined; + onDetected?: () => void; +}): Promise { + const trimmedPath = options.filePath.trim(); + if (!trimmedPath || options.editorAppCache.has(trimmedPath) || options.editorAppPending.has(trimmedPath)) { + return; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (!userAgent.includes('mac')) { + return; + } + + options.editorAppPending.add(trimmedPath); + try { + const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}") +return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`; + const detectResult = await options.callTool?.('start_process', { + command: detectCommand, + timeout_ms: 12000, + }); + const text = options.extractToolText(detectResult) ?? ''; + if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) { + return; + } + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? ''; + const appPath = lines[lines.length - 1] ?? ''; + if (appName && appPath.startsWith('/')) { + options.editorAppCache.set(trimmedPath, { + appName, + appPath, + }); + options.onDetected?.(); + } + } catch { + // Fall back to generic editor label. + } finally { + options.editorAppPending.delete(trimmedPath); + } +} + +export function renderMarkdownEditorAppIcon(): string { + return ''; +} diff --git a/src/ui/file-preview/src/host/selection-context.ts b/src/ui/file-preview/src/host/selection-context.ts new file mode 100644 index 00000000..18b62a3a --- /dev/null +++ b/src/ui/file-preview/src/host/selection-context.ts @@ -0,0 +1,131 @@ +import type { RenderPayload } from '../model.js'; + +export function attachSelectionContext(options: { + payload: RenderPayload; + isMarkdownEditing: boolean; + updateContext?: (text: string) => void; + trackUiEvent?: (event: string, params?: Record) => void; + getFileExtensionForAnalytics: (filePath: string) => string; + previousAbortController: AbortController | null; +}): AbortController | null { + if (options.isMarkdownEditing) { + if (options.previousAbortController) { + options.previousAbortController.abort(); + } + return null; + } + + const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + if (!contentWrapper) { + return options.previousAbortController; + } + const wrapper = contentWrapper; + + options.previousAbortController?.abort(); + const abortController = new AbortController(); + + let hintEl: HTMLElement | null = null; + let lastSelectedText = ''; + let hideTimer: ReturnType | null = null; + + function positionHint(selection: Selection): void { + if (!hintEl) return; + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); + + let left = rect.left + rect.width / 2 - wrapperRect.left; + let top = rect.top - wrapperRect.top + wrapper.scrollTop - 32; + + const hintWidth = hintEl.offsetWidth || 200; + left = Math.max(8, Math.min(left - hintWidth / 2, wrapper.clientWidth - hintWidth - 8)); + top = Math.max(4, top); + + hintEl.style.left = `${left}px`; + hintEl.style.top = `${top}px`; + } + + function showHint(selection: Selection): void { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + + if (!hintEl) { + hintEl = document.createElement('div'); + hintEl.className = 'selection-hint'; + hintEl.textContent = 'AI can see your selection'; + wrapper.appendChild(hintEl); + } + hintEl.classList.add('visible'); + positionHint(selection); + } + + function hideHint(): void { + if (!hintEl) return; + hintEl.classList.remove('visible'); + hideTimer = setTimeout(() => { + hintEl?.remove(); + hintEl = null; + }, 200); + } + + function getLineInfo(selection: Selection): string { + const anchorRow = selection.anchorNode?.parentElement?.closest('.code-line') as HTMLElement | null; + const focusRow = selection.focusNode?.parentElement?.closest('.code-line') as HTMLElement | null; + if (anchorRow && focusRow) { + const anchorLine = parseInt(anchorRow.dataset.line ?? '', 10); + const focusLine = parseInt(focusRow.dataset.line ?? '', 10); + if (!isNaN(anchorLine) && !isNaN(focusLine)) { + const low = Math.min(anchorLine, focusLine); + const high = Math.max(anchorLine, focusLine); + return low === high ? `line ${low}` : `lines ${low}–${high}`; + } + } + return ''; + } + + document.addEventListener('selectionchange', () => { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) { + if (lastSelectedText) { + lastSelectedText = ''; + options.updateContext?.(''); + hideHint(); + } + return; + } + + const text = selection.toString().trim(); + if (!text || text === lastSelectedText) { + return; + } + + const anchorInContent = wrapper.contains(selection.anchorNode); + const focusInContent = wrapper.contains(selection.focusNode); + if (!anchorInContent && !focusInContent) { + if (lastSelectedText) { + lastSelectedText = ''; + options.updateContext?.(''); + hideHint(); + } + return; + } + + lastSelectedText = text; + + const lineInfo = getLineInfo(selection); + const locationPart = lineInfo ? ` (${lineInfo})` : ''; + const context = `User selected text from file ${options.payload.filePath}${locationPart}:\n\`\`\`\n${text}\n\`\`\``; + + options.updateContext?.(context); + showHint(selection); + options.trackUiEvent?.('text_selected', { + file_type: options.payload.fileType, + file_extension: options.getFileExtensionForAnalytics(options.payload.filePath), + char_count: text.length, + }); + }, { signal: abortController.signal }); + + return abortController; +} diff --git a/src/ui/file-preview/src/markdown/conflict-dialog.ts b/src/ui/file-preview/src/markdown/conflict-dialog.ts new file mode 100644 index 00000000..b63e9389 --- /dev/null +++ b/src/ui/file-preview/src/markdown/conflict-dialog.ts @@ -0,0 +1,191 @@ +/** + * The "file changed on disk" conflict resolver. + * + * Shown when saveDocument detected that disk differs from what the editor + * thought it had. The editor has already re-synced its sourceContent to the + * fresh disk content with keepDraft: true — so the dialog's two actions map + * onto concrete state transitions: + * + * "Use disk version" — replace the draft with disk content + * (syncStateFromContent without keepDraft). + * Destroys unsaved edits. + * + * "Save my changes" — close the dialog and re-run saveDocument. + * computeEditBlocks will now diff against the fresh + * disk content, so non-overlapping edits merge in + * and overlapping edits win over disk for the + * lines the user actually touched. + * + * The dialog is modal (dimmed backdrop, keyboard-trapped, click-outside does + * not dismiss). Escape and the ✕ button both close it without taking either + * action — equivalent to "I'll deal with this later"; the save button stays + * dirty so the user can retry or keep editing. + */ + +export interface OpenConflictDialogOptions { + fileName: string; + onUseDiskVersion: () => void; + onSaveMyChanges: () => void; + onCancel?: () => void; +} + +export interface ConflictDialogController { + open: (options: OpenConflictDialogOptions) => void; + close: () => void; + isOpen: () => boolean; +} + +export function renderConflictDialogMarkup(): string { + return ` + + `; +} + +interface CreateConflictDialogOptions { + container: ParentNode; +} + +export function createConflictDialogController(options: CreateConflictDialogOptions): ConflictDialogController { + const { container } = options; + + const modal = container.querySelector('#md-conflict-modal') as HTMLElement | null; + const filenameEl = container.querySelector('#md-conflict-filename') as HTMLElement | null; + const useDiskBtn = container.querySelector('#md-conflict-use-disk') as HTMLButtonElement | null; + const saveMineBtn = container.querySelector('#md-conflict-save-mine') as HTMLButtonElement | null; + const closeBtn = container.querySelector('#md-conflict-close') as HTMLButtonElement | null; + + let currentOptions: OpenConflictDialogOptions | null = null; + let previousActiveElement: HTMLElement | null = null; + + const close = (): void => { + if (!modal || modal.hidden) { + return; + } + modal.hidden = true; + document.removeEventListener('keydown', handleKeyDown, true); + modal.removeEventListener('click', handleBackdropClick); + const cancel = currentOptions?.onCancel; + currentOptions = null; + // Restore focus to whatever the user was on before the dialog opened. + if (previousActiveElement && document.contains(previousActiveElement)) { + try { + previousActiveElement.focus(); + } catch { + /* focus can throw on removed nodes — ignore */ + } + } + previousActiveElement = null; + cancel?.(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (!modal || modal.hidden) { + return; + } + if (event.key === 'Escape') { + event.stopPropagation(); + event.preventDefault(); + close(); + return; + } + if (event.key === 'Tab') { + // Minimal focus trap between the three buttons. + const focusable = [useDiskBtn, saveMineBtn, closeBtn].filter( + (el): el is HTMLButtonElement => !!el + ); + if (focusable.length === 0) return; + const active = document.activeElement as HTMLElement | null; + const currentIndex = active ? focusable.indexOf(active as HTMLButtonElement) : -1; + const direction = event.shiftKey ? -1 : 1; + const nextIndex = currentIndex === -1 + ? (direction === 1 ? 0 : focusable.length - 1) + : (currentIndex + direction + focusable.length) % focusable.length; + event.preventDefault(); + focusable[nextIndex].focus(); + } + }; + + const handleBackdropClick = (event: MouseEvent): void => { + // Click on the dimmed backdrop (the modal element itself, not the card) + // is deliberately not a dismiss — the user must make a choice or hit ✕. + if (event.target === modal) { + event.stopPropagation(); + } + }; + + const handleUseDisk = (): void => { + const cb = currentOptions?.onUseDiskVersion; + // Clear currentOptions first so close() doesn't also fire onCancel. + currentOptions = null; + close(); + cb?.(); + }; + + const handleSaveMine = (): void => { + const cb = currentOptions?.onSaveMyChanges; + currentOptions = null; + close(); + cb?.(); + }; + + useDiskBtn?.addEventListener('click', handleUseDisk); + saveMineBtn?.addEventListener('click', handleSaveMine); + closeBtn?.addEventListener('click', close); + + return { + open: (options) => { + if (!modal) { + // No-op if the markup wasn't injected — fall back to cancel callback + // so the editor can still notify the user via the inline path. + options.onCancel?.(); + return; + } + currentOptions = options; + if (filenameEl) { + filenameEl.textContent = options.fileName; + } + previousActiveElement = (document.activeElement as HTMLElement | null) ?? null; + modal.hidden = false; + document.addEventListener('keydown', handleKeyDown, true); + modal.addEventListener('click', handleBackdropClick); + // Default focus goes to the safer action ("Save my changes" is the + // non-destructive intent — it doesn't discard the user's draft). + window.requestAnimationFrame(() => { + saveMineBtn?.focus(); + }); + }, + close, + isOpen: () => !!modal && !modal.hidden, + }; +} diff --git a/src/ui/file-preview/src/markdown/controller.ts b/src/ui/file-preview/src/markdown/controller.ts new file mode 100644 index 00000000..abafa0eb --- /dev/null +++ b/src/ui/file-preview/src/markdown/controller.ts @@ -0,0 +1,1065 @@ +import { attachDocumentOutline, renderDocumentOutline, type DocumentOutlineHandle } from '../document-outline.js'; +import { getDocumentFullscreenAvailability, parseReadRange, shouldAutoLoadDocumentOnEnterFullscreen, stripReadStatusLine } from '../document-workspace.js'; +import type { MarkdownWorkspaceState, RenderBodyResult, RenderPayload } from '../model.js'; +import { assertSuccessfulEditBlockResult, extractRenderPayload, extractToolText } from '../payload-utils.js'; +import { getAncestorDirectories, getParentDirectory, toPosixRelativePath } from '../path-utils.js'; +import { mountMarkdownEditor, renderMarkdownEditorShell, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './editor.js'; +import type { OpenConflictDialogOptions } from './conflict-dialog.js'; +import { resolveMarkdownLink } from './linking.js'; +import { extractMarkdownOutline } from './outline.js'; +import { getRenderedMarkdownCopyText } from './preview.js'; +import { slugifyMarkdownHeading } from './slugify.js'; +import { getFileExtensionForAnalytics } from '../payload-utils.js'; + +export interface MarkdownControllerDependencies { + callTool?: (name: string, args: Record) => Promise; + openExternalLink?: (url: string) => Promise; + requestDisplayMode?: (mode: 'inline' | 'fullscreen') => Promise; + getAvailableDisplayModes: () => string[]; + getCurrentDisplayMode: () => string | null; + getCurrentPayload: () => RenderPayload | undefined; + setExpanded: (expanded: boolean) => void; + syncPayload?: (payload?: RenderPayload) => void; + storePayloadOverride: (payload: RenderPayload) => void; + rerender: () => void; + updateSaveStatus: (label: string, statusClass: string) => void; + trackUiEvent?: (event: string, params?: Record) => void; + showConflictDialog?: (options: OpenConflictDialogOptions) => void; +} + +interface ToolErrorResult { + isError?: boolean; +} + +interface DiffHunk { + oldStart: number; + oldEnd: number; + newStart: number; + newEnd: number; +} + +function areOutlineItemsEqual( + left: MarkdownWorkspaceState['outline'], + right: MarkdownWorkspaceState['outline'] +): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((item, index) => { + const other = right[index]; + return item.id === other.id + && item.text === other.text + && item.level === other.level + && item.line === other.line; + }); +} + +function splitListingLines(text: string): string[] { + return text.split('\n').map((line) => line.trim()).filter(Boolean); +} + +function parseFileSearchResults(text: string): string[] { + return text.split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('📁 ')) + .map((line) => line.slice(3).trim()); +} + +function stripMarkdownExtension(filePath: string): string { + return filePath.replace(/\.md$/i, ''); +} + +function computeDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] { + const oldLength = oldLines.length; + const newLength = newLines.length; + + if (oldLength * newLength > 1_000_000) { + return [{ oldStart: 0, oldEnd: oldLength, newStart: 0, newEnd: newLength }]; + } + + const dp: number[][] = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0) as number[]); + for (let i = 1; i <= oldLength; i += 1) { + for (let j = 1; j <= newLength; j += 1) { + dp[i][j] = oldLines[i - 1] === newLines[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + const matches: Array<[number, number]> = []; + let oldIndex = oldLength; + let newIndex = newLength; + while (oldIndex > 0 && newIndex > 0) { + if (oldLines[oldIndex - 1] === newLines[newIndex - 1]) { + matches.unshift([oldIndex - 1, newIndex - 1]); + oldIndex -= 1; + newIndex -= 1; + } else if (dp[oldIndex - 1][newIndex] >= dp[oldIndex][newIndex - 1]) { + oldIndex -= 1; + } else { + newIndex -= 1; + } + } + + const hunks: DiffHunk[] = []; + let previousOld = 0; + let previousNew = 0; + for (const [matchOld, matchNew] of matches) { + if (matchOld > previousOld || matchNew > previousNew) { + hunks.push({ oldStart: previousOld, oldEnd: matchOld, newStart: previousNew, newEnd: matchNew }); + } + previousOld = matchOld + 1; + previousNew = matchNew + 1; + } + if (previousOld < oldLength || previousNew < newLength) { + hunks.push({ oldStart: previousOld, oldEnd: oldLength, newStart: previousNew, newEnd: newLength }); + } + + return hunks; +} + +function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { + if (hunks.length <= 1) { + return hunks; + } + + const merged: DiffHunk[] = [{ ...hunks[0] }]; + for (let index = 1; index < hunks.length; index += 1) { + const previous = merged[merged.length - 1]; + const current = hunks[index]; + if (current.oldStart - previous.oldEnd < minGap) { + previous.oldEnd = current.oldEnd; + previous.newEnd = current.newEnd; + continue; + } + merged.push({ ...current }); + } + return merged; +} + +function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { + if (oldText === newText) { + return []; + } + + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + const hunks = computeDiffHunks(oldLines, newLines); + if (hunks.length === 0) { + return []; + } + + const context = 3; + const merged = mergeCloseHunks(hunks, context * 2 + 1); + const totalChanged = merged.reduce((sum, hunk) => sum + (hunk.oldEnd - hunk.oldStart), 0); + if (totalChanged > oldLines.length * 0.7) { + return [{ old_string: oldText, new_string: newText }]; + } + + return merged.map((hunk) => { + const contextBefore = Math.max(0, hunk.oldStart - context); + const contextAfter = Math.min(oldLines.length, hunk.oldEnd + context); + + const oldBlock = oldLines.slice(contextBefore, contextAfter).join('\n'); + const newBlock = [ + ...oldLines.slice(contextBefore, hunk.oldStart), + ...newLines.slice(hunk.newStart, hunk.newEnd), + ...oldLines.slice(hunk.oldEnd, contextAfter), + ].join('\n'); + + return { old_string: oldBlock, new_string: newBlock }; + }).filter((block) => block.old_string !== block.new_string); +} + +function isToolErrorResult(value: unknown): value is ToolErrorResult { + return typeof value === 'object' && value !== null; +} + +function isMissingFileErrorResult(result: unknown): boolean { + if (!isToolErrorResult(result) || result.isError !== true) { + return false; + } + + const message = extractToolText(result)?.toLowerCase() ?? ''; + return message.includes('not found') + || message.includes('no such file') + || message.includes('enoent'); +} + +export function createMarkdownController(dependencies: MarkdownControllerDependencies) { + let workspaceState: MarkdownWorkspaceState | undefined; + let markdownEditorHandle: MarkdownEditorHandle | undefined; + let markdownTocHandle: DocumentOutlineHandle | undefined; + let autosaveTimer: ReturnType | null = null; + + const AUTOSAVE_DEBOUNCE_MS = 1000; + + function scheduleAutosave(): void { + if (autosaveTimer !== null) { + clearTimeout(autosaveTimer); + } + autosaveTimer = setTimeout(() => { + autosaveTimer = null; + void saveDocument(); + }, AUTOSAVE_DEBOUNCE_MS); + } + + function cancelAutosave(): void { + if (autosaveTimer !== null) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + } + } + + function disposeHandles(): void { + cancelAutosave(); + markdownEditorHandle?.destroy(); + markdownEditorHandle = undefined; + markdownTocHandle?.dispose(); + markdownTocHandle = undefined; + } + + function clear(): void { + workspaceState = undefined; + disposeHandles(); + } + + function readPayloadContent(payload: RenderPayload): string { + return stripReadStatusLine(payload.content); + } + + function syncStateFromContent( + state: MarkdownWorkspaceState, + content: string, + options: { keepDraft?: boolean } = {} + ): void { + const nextDraftContent = options.keepDraft ? state.draftContent : content; + state.sourceContent = content; + state.fullDocumentContent = content; + state.draftContent = nextDraftContent; + state.outline = extractMarkdownOutline(content); + state.dirty = nextDraftContent !== content; + state.fileDeleted = false; + if (!state.outline.some((item) => item.id === state.activeHeadingId)) { + state.activeHeadingId = state.outline[0]?.id ?? null; + } + } + + async function callReadFile(filePath: string, length?: number, offset?: number): Promise<{ rawResult: unknown; payload: RenderPayload | null }> { + const rawResult = await dependencies.callTool?.('read_file', { + path: filePath, + ...(typeof length === 'number' ? { offset: offset ?? 0, length } : {}), + }); + return { rawResult, payload: extractRenderPayload(rawResult) ?? null }; + } + + async function readPayload(filePath: string, length?: number, offset?: number): Promise { + return (await callReadFile(filePath, length, offset)).payload; + } + + async function ensureCompletePayload(payload: RenderPayload): Promise { + const range = parseReadRange(payload.content); + if (!range?.isPartial) { + return payload; + } + + return (await readPayload(payload.filePath, range.totalLines)) ?? payload; + } + + async function readCompletePayload(filePath: string): Promise { + const payload = await readPayload(filePath); + if (!payload) { + return null; + } + + return ensureCompletePayload(payload); + } + + function getState(payload: RenderPayload): MarkdownWorkspaceState { + const cleanedContent = stripReadStatusLine(payload.content); + + if (!workspaceState || workspaceState.filePath !== payload.filePath || workspaceState.sourceContent !== cleanedContent) { + const outline = extractMarkdownOutline(cleanedContent); + workspaceState = { + filePath: payload.filePath, + sourceContent: cleanedContent, + fullDocumentContent: cleanedContent, + draftContent: cleanedContent, + outline, + mode: 'edit', + dirty: false, + activeHeadingId: outline[0]?.id ?? null, + pendingAnchor: null, + notice: null, + error: null, + saving: false, + loadingDocument: false, + editorView: 'markdown', + editorScrollTop: 0, + saveIndicator: 'idle', + fileDeleted: false, + }; + } + + return workspaceState; + } + + function isUndoAvailable(state: MarkdownWorkspaceState): boolean { + return state.draftContent !== state.fullDocumentContent; + } + + function buildBody(payload: RenderPayload): RenderBodyResult { + const state = getState(payload); + const outline = state.outline; + const isFullscreen = dependencies.getCurrentDisplayMode() === 'fullscreen'; + const tocHtml = isFullscreen ? renderDocumentOutline(outline, state.activeHeadingId) : ''; + if (!state.activeHeadingId && outline.length > 0) { + state.activeHeadingId = outline[0].id; + } + + const notice = [state.error, state.notice] + .find((value): value is string => typeof value === 'string' && value.trim().length > 0); + + return { + notice, + html: ` +
+
+ ${tocHtml} +
+ ${renderMarkdownEditorShell({ view: state.editorView })} +
+
+
+ `, + }; + } + + async function resolveLinkSearchRoot(filePath: string): Promise { + const ancestors = getAncestorDirectories(filePath); + const markers = new Set(['[DIR] .git', '[DIR] .obsidian', '[FILE] package.json', '[FILE] pnpm-workspace.yaml', '[FILE] turbo.json']); + + for (const ancestor of ancestors) { + try { + const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1 }); + const text = extractToolText(result) ?? ''; + const entries = splitListingLines(text); + if (entries.some((entry) => markers.has(entry))) { + return ancestor; + } + } catch { + // Ignore and continue up the tree. + } + } + + return getParentDirectory(filePath); + } + + async function searchLinkTargets(filePath: string, query: string): Promise { + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) { + return []; + } + + const rootPath = await resolveLinkSearchRoot(filePath); + const result = await dependencies.callTool?.('start_search', { + path: rootPath, + pattern: trimmedQuery, + searchType: 'files', + filePattern: '*.md', + maxResults: 20, + earlyTermination: false, + literalSearch: true, + }); + const text = extractToolText(result) ?? ''; + const filePaths = parseFileSearchResults(text); + const currentDirectory = getParentDirectory(filePath); + + return filePaths.map((targetPath) => { + const normalized = targetPath.replace(/\\/g, '/'); + const fileName = normalized.split('/').pop() ?? normalized; + const title = stripMarkdownExtension(fileName); + const relativePath = toPosixRelativePath(currentDirectory, normalized); + const wikiPath = stripMarkdownExtension(relativePath.startsWith('./') ? relativePath.slice(2) : relativePath); + return { + path: normalized, + title, + wikiPath, + relativePath, + }; + }); + } + + async function loadLinkHeadings(currentPayloadPath: string, targetPath: string): Promise { + if (targetPath === currentPayloadPath && workspaceState) { + return workspaceState.outline.map((item) => ({ id: item.id, text: item.text })); + } + + const payload = await readCompletePayload(targetPath); + if (!payload) { + return []; + } + + return extractMarkdownOutline(readPayloadContent(payload)).map((item) => ({ id: item.id, text: item.text })); + } + + function findHeading(anchor: string): HTMLElement | null { + const trimmedAnchor = anchor.trim(); + if (!trimmedAnchor) { + return null; + } + + return document.getElementById(trimmedAnchor) ?? document.getElementById(slugifyMarkdownHeading(trimmedAnchor)); + } + + function scrollHeadingIntoView(anchor: string): boolean { + const heading = findHeading(anchor); + if (!heading) { + return false; + } + + const scrollParents: HTMLElement[] = []; + let current: HTMLElement | null = heading.parentElement; + while (current) { + const style = window.getComputedStyle(current); + const overflowY = style.overflowY; + const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') + && current.scrollHeight > current.clientHeight; + if (isScrollable) { + scrollParents.push(current); + } + current = current.parentElement; + } + + heading.scrollIntoView({ block: 'start', inline: 'nearest' }); + + for (const parent of scrollParents) { + const parentRect = parent.getBoundingClientRect(); + const headingRect = heading.getBoundingClientRect(); + const nextTop = Math.max(parent.scrollTop + (headingRect.top - parentRect.top) - 24, 0); + parent.scrollTop = nextTop; + } + + const rootScroller = document.scrollingElement as HTMLElement | null; + if (rootScroller) { + const rootRectTop = heading.getBoundingClientRect().top; + const nextRootTop = Math.max(rootScroller.scrollTop + rootRectTop - 24, 0); + rootScroller.scrollTop = nextRootTop; + } + + heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: true }); + if (workspaceState) { + workspaceState.activeHeadingId = heading.id || slugifyMarkdownHeading(anchor); + } + return true; + } + + function applyPendingAnchor(): void { + const pendingAnchor = workspaceState?.pendingAnchor; + if (!workspaceState || !pendingAnchor) { + return; + } + + workspaceState.pendingAnchor = null; + if (!scrollHeadingIntoView(pendingAnchor)) { + workspaceState.error = `Heading not found: ${pendingAnchor}`; + dependencies.rerender(); + } + } + + function flashSaveStatus( + label: string, + statusClass: string, + timeoutMs: number, + beforeClear?: () => boolean + ): void { + dependencies.updateSaveStatus(label, statusClass); + window.setTimeout(() => { + if (beforeClear && !beforeClear()) { + return; + } + dependencies.updateSaveStatus('', ''); + }, timeoutMs); + } + + async function refreshFromDisk(payload: RenderPayload): Promise { + try { + const range = parseReadRange(payload.content); + const { rawResult, payload: freshPayload } = range?.isPartial + ? await callReadFile(payload.filePath, range.toLine - range.fromLine + 1, range.readOffset) + : await callReadFile(payload.filePath); + if (!freshPayload) { + if (isMissingFileErrorResult(rawResult)) { + if (workspaceState) { + workspaceState.fileDeleted = true; + } + dependencies.updateSaveStatus('File deleted', 'saved'); + } + return; + } + + const freshContent = readPayloadContent(freshPayload); + const currentContent = readPayloadContent(payload); + if (freshContent === currentContent) { + return; + } + + // refreshFromDisk only runs at mount (no file watcher in this app), + // so disk-vs-payload mismatch means the host sent a stale cached + // payload — trust the disk read and reload silently. + dependencies.storePayloadOverride(freshPayload); + workspaceState = undefined; + dependencies.rerender(); + } catch { + // Silently fall back to host payload. + } + } + + async function loadFullDocument(payload: RenderPayload, options: { keepEditMode?: boolean } = {}): Promise { + const state = getState(payload); + const range = parseReadRange(payload.content); + if (!range?.isPartial) { + if (options.keepEditMode) { + state.mode = 'edit'; + state.editorView = 'markdown'; + state.notice = null; + state.error = null; + state.draftContent = state.sourceContent; + state.dirty = false; + dependencies.rerender(); + } + return; + } + + state.loadingDocument = true; + state.notice = 'Loading full document…'; + state.error = null; + dependencies.rerender(); + + try { + const nextPayload = await readPayload(payload.filePath, range.totalLines); + if (!nextPayload) { + state.error = 'Failed to load the full document.'; + state.notice = null; + state.loadingDocument = false; + dependencies.rerender(); + return; + } + + dependencies.syncPayload?.(nextPayload); + const nextState = getState(nextPayload); + nextState.loadingDocument = false; + nextState.notice = null; + nextState.error = null; + syncStateFromContent(nextState, nextState.sourceContent); + if (options.keepEditMode) { + nextState.mode = 'edit'; + nextState.editorView = 'markdown'; + dependencies.rerender(); + } + } catch { + state.loadingDocument = false; + state.notice = null; + state.error = 'Failed to load the full document.'; + dependencies.rerender(); + } + } + + async function navigateLink(payload: RenderPayload, href: string): Promise { + const state = getState(payload); + if (state.dirty) { + const shouldDiscard = window.confirm('Discard unsaved changes and follow this link?'); + if (!shouldDiscard) { + return; + } + } + + const resolvedLink = resolveMarkdownLink(payload.filePath, href); + state.notice = null; + state.error = null; + + if (resolvedLink.kind === 'external' && resolvedLink.url) { + const opened = await dependencies.openExternalLink?.(resolvedLink.url); + if (!opened) { + try { window.open(resolvedLink.url, '_blank', 'noopener'); } catch { /* sandbox may block */ } + } + return; + } + + if (resolvedLink.kind === 'anchor' && resolvedLink.anchor) { + if (!scrollHeadingIntoView(resolvedLink.anchor) && workspaceState) { + workspaceState.error = `Heading not found: ${resolvedLink.anchor}`; + dependencies.rerender(); + } + return; + } + + if (resolvedLink.kind === 'file' && resolvedLink.targetPath) { + const hostHandled = await dependencies.openExternalLink?.(resolvedLink.targetPath); + if (hostHandled) { + return; + } + + const nextPayload = await readPayload(resolvedLink.targetPath); + if (!nextPayload) { + if (workspaceState) { + workspaceState.error = `Unable to open ${resolvedLink.targetPath}.`; + dependencies.rerender(); + } + return; + } + + dependencies.syncPayload?.(nextPayload); + const nextState = getState(nextPayload); + nextState.pendingAnchor = resolvedLink.anchor ?? null; + nextState.error = null; + nextState.notice = null; + dependencies.rerender(); + } + } + + async function requestEditMode(payload: RenderPayload): Promise { + const state = getState(payload); + + state.error = null; + state.notice = null; + + if (shouldAutoLoadDocumentOnEnterFullscreen(payload.content)) { + await loadFullDocument(payload, { keepEditMode: true }); + return; + } + + state.mode = 'edit'; + state.draftContent = state.fullDocumentContent; + state.dirty = false; + state.editorView = 'markdown'; + dependencies.setExpanded(true); + dependencies.rerender(); + } + + async function requestFullscreen(): Promise { + const fullscreenAvailability = getDocumentFullscreenAvailability({ + availableDisplayModes: dependencies.getAvailableDisplayModes(), + }); + if (!fullscreenAvailability.canFullscreen) { + return false; + } + const nextMode = await dependencies.requestDisplayMode?.('fullscreen'); + return nextMode === 'fullscreen'; + } + + function revertEditing(): void { + if (!workspaceState) { + return; + } + const filePath = workspaceState.filePath; + workspaceState.draftContent = workspaceState.fullDocumentContent; + workspaceState.dirty = false; + workspaceState.error = null; + workspaceState.notice = null; + dependencies.rerender(); + flashSaveStatus('Reverted', 'saved', 1500); + dependencies.trackUiEvent?.('markdown_reverted', { + file_extension: getFileExtensionForAnalytics(filePath), + }); + } + + async function saveDocument(): Promise { + if (!workspaceState || workspaceState.saving || !workspaceState.dirty || workspaceState.fileDeleted) { + return; + } + cancelAutosave(); + const state = workspaceState; + state.saving = true; + state.saveIndicator = 'saving'; + state.error = null; + state.notice = null; + + try { + const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent); + if (blocks.length === 0) { + state.saving = false; + state.saveIndicator = 'idle'; + state.dirty = false; + return; + } + + // Try each hunk independently. Previously the loop threw on the + // first soft-failure, which left earlier hunks already written to + // disk while the UI claimed "Save failed" — making it look like the + // editor had silently overwritten external changes. Now we track + // per-hunk outcomes so we can give the user an honest accounting. + let appliedCount = 0; + const skippedHunks: typeof blocks = []; + let lastHardError: unknown = null; + for (const block of blocks) { + try { + const editResult = await dependencies.callTool?.('edit_block', { + file_path: state.filePath, + old_string: block.old_string, + new_string: block.new_string, + expected_replacements: 1, + }); + assertSuccessfulEditBlockResult(editResult); + appliedCount++; + } catch (hunkError) { + // A per-hunk failure is almost always "old_string not on + // disk" because the file changed there. Record it and keep + // going so other hunks still get their chance. + skippedHunks.push(block); + lastHardError = hunkError; + } + } + + if (skippedHunks.length > 0) { + // Partial (or total) failure. Let the catch branch take it — + // throw an error carrying counts so the catch can decide how + // to communicate and how to resync baseline vs. disk. + const err = new Error( + `${appliedCount} of ${blocks.length} edit${blocks.length === 1 ? '' : 's'} applied; ` + + `${skippedHunks.length} could not land because the text changed on disk.` + ) as Error & { appliedCount?: number; skippedCount?: number; totalCount?: number; underlyingError?: unknown }; + err.appliedCount = appliedCount; + err.skippedCount = skippedHunks.length; + err.totalCount = blocks.length; + err.underlyingError = lastHardError; + throw err; + } + + state.fullDocumentContent = state.draftContent; + state.sourceContent = state.draftContent; + state.outline = extractMarkdownOutline(state.sourceContent); + state.dirty = false; + state.saving = false; + state.saveIndicator = 'saved'; + if (!state.outline.some((item) => item.id === state.activeHeadingId)) { + state.activeHeadingId = state.outline[0]?.id ?? null; + } + + const savedContent = state.draftContent; + const currentPayload = dependencies.getCurrentPayload(); + if (currentPayload) { + const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/); + const statusLine = statusLineMatch?.[1] ?? ''; + dependencies.storePayloadOverride({ ...currentPayload, content: statusLine + savedContent }); + } + + const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; + if (revert) { + revert.disabled = !isUndoAvailable(state); + } + flashSaveStatus('Saved', 'saved', 1800, () => { + if (!state.dirty && !state.saving) { + state.saveIndicator = 'idle'; + return true; + } + return false; + }); + dependencies.trackUiEvent?.('markdown_saved', { + file_extension: getFileExtensionForAnalytics(state.filePath), + blocks: blocks.length, + }); + } catch (error) { + state.saving = false; + state.saveIndicator = 'idle'; + + // Pull per-hunk counts from the synthetic error thrown by the save + // loop, when this catch is reached via a partial/total hunk failure. + const errWithCounts = error as { appliedCount?: number; skippedCount?: number; totalCount?: number }; + const appliedCount = typeof errWithCounts.appliedCount === 'number' ? errWithCounts.appliedCount : 0; + const skippedCount = typeof errWithCounts.skippedCount === 'number' ? errWithCounts.skippedCount : 0; + const totalCount = typeof errWithCounts.totalCount === 'number' ? errWithCounts.totalCount : 0; + const isPartialSuccess = appliedCount > 0 && skippedCount > 0; + const isTotalFailure = appliedCount === 0 && skippedCount > 0; + + const freshPayload = await readCompletePayload(state.filePath).catch(() => null); + let reloadedFromDisk = false; + let freshContentForDialog: string | null = null; + if (freshPayload) { + const freshContent = readPayloadContent(freshPayload); + if (freshContent !== state.fullDocumentContent) { + syncStateFromContent(state, freshContent, { keepDraft: true }); + dependencies.storePayloadOverride(freshPayload); + reloadedFromDisk = true; + freshContentForDialog = freshContent; + } + } + + state.notice = null; + + if (isPartialSuccess) { + // Some hunks landed on disk, some didn't. The user would be + // misled by a "Save failed" message — and would be misled by + // a conflict dialog claiming nothing was saved. Tell them the + // truth: N saved, M skipped, with the skipped lines preserved + // in their draft so they can re-try or edit around the + // external change. + state.error = ( + `${appliedCount} of ${totalCount} edit${totalCount === 1 ? '' : 's'} saved. ` + + `${skippedCount} ${skippedCount === 1 ? 'edit' : 'edits'} did not apply because that text changed on disk — ` + + `your draft still has them; save again to merge.` + ); + dependencies.rerender(); + flashSaveStatus('Saved (partial)', 'saved', 3000); + dependencies.trackUiEvent?.('markdown_save_partial', { + file_extension: getFileExtensionForAnalytics(state.filePath), + applied: appliedCount, + skipped: skippedCount, + total: totalCount, + }); + } else if (isTotalFailure && reloadedFromDisk && dependencies.showConflictDialog && freshContentForDialog !== null) { + // No hunks landed and disk has changed. Genuine conflict — show + // the modal so the user can pick keep-mine / use-disk. + const savedFreshContent = freshContentForDialog; + const normalized = state.filePath.replace(/\\/g, '/'); + const displayName = normalized.split('/').pop() || state.filePath; + state.error = null; + dependencies.rerender(); + dependencies.trackUiEvent?.('markdown_save_conflict_shown', { + file_extension: getFileExtensionForAnalytics(state.filePath), + }); + dependencies.showConflictDialog({ + fileName: displayName, + onUseDiskVersion: () => { + if (workspaceState === state) { + syncStateFromContent(state, savedFreshContent); + dependencies.rerender(); + } + dependencies.trackUiEvent?.('markdown_save_conflict_resolved', { + file_extension: getFileExtensionForAnalytics(state.filePath), + action: 'use_disk', + }); + }, + onSaveMyChanges: () => { + dependencies.trackUiEvent?.('markdown_save_conflict_resolved', { + file_extension: getFileExtensionForAnalytics(state.filePath), + action: 'save_mine', + }); + // Re-run saveDocument. computeEditBlocks will diff against + // the fresh sourceContent that keepDraft: true left in place, + // so hunks the user actually modified win over disk on + // those specific lines and disk-only changes elsewhere are + // preserved. + void saveDocument(); + }, + onCancel: () => { + if (workspaceState === state) { + state.error = 'File changed on disk. Save again to merge your edits, or reopen to discard them.'; + dependencies.rerender(); + } + dependencies.trackUiEvent?.('markdown_save_conflict_resolved', { + file_extension: getFileExtensionForAnalytics(state.filePath), + action: 'dismissed', + }); + }, + }); + } else { + // Fallback: unexpected error that isn't a per-hunk soft-fail + // (e.g. read_file failure during resync, or an exception before + // the save loop started). Use a generic inline message. + state.error = reloadedFromDisk + ? 'File changed on disk. Save again to merge your edits, or reopen the file to discard them.' + : error instanceof Error ? error.message : 'Save failed.'; + dependencies.rerender(); + flashSaveStatus('Save failed', 'saving', 3000); + } + + dependencies.trackUiEvent?.('markdown_save_failed', { + file_extension: getFileExtensionForAnalytics(state.filePath), + reloaded_from_disk: reloadedFromDisk, + applied: appliedCount, + skipped: skippedCount, + total: totalCount, + }); + } + } + + function setEditorView(payload: RenderPayload, view: MarkdownEditorView): void { + const state = getState(payload); + const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + state.editorScrollTop = wrapper?.scrollTop ?? 0; + const previousView = state.editorView; + state.editorView = view; + state.notice = null; + state.error = null; + dependencies.rerender(); + if (previousView !== view) { + dependencies.trackUiEvent?.('markdown_view_toggled', { + file_extension: getFileExtensionForAnalytics(payload.filePath), + view, + }); + } + if (typeof state.editorScrollTop === 'number') { + window.requestAnimationFrame(() => { + const nextWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + if (nextWrapper) { + nextWrapper.scrollTop = state.editorScrollTop; + } + }); + } + } + + function attachHandlers(payload: RenderPayload): void { + const state = getState(payload); + const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + const outline = state.outline; + const fileExtension = getFileExtensionForAnalytics(payload.filePath); + let editStartedFired = false; + + { + const editorRoot = document.getElementById('markdown-editor-root'); + if (editorRoot) { + markdownEditorHandle = mountMarkdownEditor({ + target: editorRoot, + value: state.draftContent, + view: state.editorView, + initialScrollTop: state.editorScrollTop, + currentFilePath: payload.filePath, + searchLinks: (query) => searchLinkTargets(payload.filePath, query), + loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath), + onChange: (value) => { + state.draftContent = value; + state.dirty = value !== state.fullDocumentContent; + if (state.dirty && !editStartedFired) { + editStartedFired = true; + dependencies.trackUiEvent?.('markdown_edit_started', { + file_extension: fileExtension, + view: state.editorView, + }); + } + if (state.dirty) { + scheduleAutosave(); + } + const nextOutline = extractMarkdownOutline(value); + if (!areOutlineItemsEqual(state.outline, nextOutline)) { + state.outline = nextOutline; + if (!state.outline.some((item) => item.id === state.activeHeadingId)) { + state.activeHeadingId = state.outline[0]?.id ?? null; + } + markdownTocHandle?.refresh(state.outline, state.activeHeadingId); + } + if (state.dirty && state.saveIndicator === 'saved') { + state.saveIndicator = 'idle'; + } + const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; + if (revert) { + revert.disabled = !isUndoAvailable(state); + } + }, + onBlur: () => { + cancelAutosave(); + void saveDocument(); + }, + }); + markdownEditorHandle.focus(); + } + + const revertButton = document.getElementById('revert-markdown') as HTMLButtonElement | null; + revertButton?.addEventListener('click', () => { + revertEditing(); + }); + + const rawModeButton = document.getElementById('markdown-mode-raw') as HTMLButtonElement | null; + rawModeButton?.addEventListener('click', () => { + setEditorView(payload, 'raw'); + }); + + const previewModeButton = document.getElementById('markdown-mode-markdown') as HTMLButtonElement | null; + previewModeButton?.addEventListener('click', () => { + setEditorView(payload, 'markdown'); + }); + } + + const expandButton = document.getElementById('expand-fullscreen') as HTMLButtonElement | null; + expandButton?.addEventListener('click', () => { + void requestFullscreen(); + }); + + if (wrapper) { + wrapper.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + const link = target?.closest('a[href]'); + if (!link || !link.closest('.markdown-doc')) { + return; + } + const href = link.getAttribute('href'); + if (!href) { + return; + } + + event.preventDefault(); + void navigateLink(payload, href); + }); + } + + const tocShell = document.querySelector('.document-outline-shell') as HTMLElement | null; + if (tocShell && wrapper) { + markdownTocHandle = attachDocumentOutline({ + shell: tocShell, + outline, + scrollContainer: wrapper, + onSelect: (headingId) => { + const selectedHeading = state.outline.find((item) => item.id === headingId); + if (selectedHeading && typeof selectedHeading.line === 'number') { + markdownEditorHandle?.revealLine(selectedHeading.line, selectedHeading.id); + state.activeHeadingId = selectedHeading.id; + } + }, + }) ?? undefined; + } + + window.setTimeout(() => { + applyPendingAnchor(); + }, 0); + } + + function getCopyText(payload: RenderPayload): string | null { + const state = getState(payload); + const source = state.draftContent; + return state.editorView === 'raw' + ? source + : (getRenderedMarkdownCopyText(source) || source); + } + + async function handleInlineExitFromFullscreen(originalPayload?: RenderPayload): Promise { + const wasDirty = workspaceState?.saveIndicator === 'saved' || workspaceState?.dirty; + if (workspaceState) { + workspaceState.notice = null; + workspaceState.editorView = 'markdown'; + } + if (wasDirty && originalPayload) { + const range = parseReadRange(originalPayload.content); + if (range?.isPartial) { + const freshPayload = await readPayload(originalPayload.filePath, range.toLine - range.fromLine + 1, range.readOffset); + if (freshPayload) { + return freshPayload; + } + } + } + return undefined; + } + + return { + attachHandlers, + buildBody, + clear, + disposeHandles, + ensureCompletePayload, + getCopyText, + getState, + handleInlineExitFromFullscreen, + isUndoAvailable, + readCompletePayload, + readPayload, + readPayloadContent, + refreshFromDisk, + requestEditMode, + requestFullscreen, + saveDocument, + setEditorView, + }; +} + +export type MarkdownController = ReturnType; diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts new file mode 100644 index 00000000..0188c484 --- /dev/null +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -0,0 +1,758 @@ +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import Image from '@tiptap/extension-image'; +import { Markdown } from 'tiptap-markdown'; +import { restoreWikiLinks, rewriteWikiLinks } from './linking.js'; +import { createSlugTracker } from './slugify.js'; + +export type MarkdownEditorView = 'raw' | 'markdown'; + +export interface MarkdownLinkSearchItem { + path: string; + title: string; + wikiPath: string; + relativePath: string; +} + +export interface MarkdownLinkHeading { + id: string; + text: string; +} + +export interface MarkdownEditorHandle { + destroy: () => void; + focus: () => void; + getValue: () => string; + setValue: (value: string) => void; + revealLine: (lineNumber: number, headingId?: string) => void; + setScrollTop: (scrollTop: number) => void; +} + +function shouldIgnoreBlur(shell: Element | null | undefined, event: FocusEvent): boolean { + const nextTarget = event.relatedTarget as Node | null; + const widgetShell = shell?.closest('.tool-shell'); + return Boolean(nextTarget && (shell?.contains(nextTarget) || widgetShell?.contains(nextTarget))); +} + +function renderFormattingButtons(): string { + return ` + + + + + + + + + + + `; +} + +function renderModeToggleIcon(view: MarkdownEditorView): string { + if (view === 'raw') { + return ''; + } + + return ''; +} + +function renderHeadingOptionLabel(headings: MarkdownLinkHeading[], heading: MarkdownLinkHeading): string { + const duplicateCount = headings.filter((candidate) => candidate.text === heading.text).length; + if (duplicateCount <= 1) { + return heading.text; + } + + return `${heading.text} (#${heading.id})`; +} + +export function renderMarkdownCopyButton(): string { + return ``; +} + +export function renderMarkdownModeToggle(view: MarkdownEditorView): string { + return ` +
+ + + +
+ `; +} + +export function renderMarkdownEditorShell(options: { + view: MarkdownEditorView; +}): string { + const isMarkdownView = options.view === 'markdown'; + + return ` +
+
+ ${isMarkdownView ? `` : ''} +
+
+
+ `; +} + +function applyRawTab(textarea: HTMLTextAreaElement): void { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const nextValue = `${textarea.value.slice(0, start)}\t${textarea.value.slice(end)}`; + textarea.value = nextValue; + textarea.selectionStart = start + 1; + textarea.selectionEnd = start + 1; +} + +/** + * Walk the prose-mirror DOM and assign slug-based id attributes to headings + * so the outline's revealLine can scroll to them. Re-run after every update; + * no-op writes are skipped so identical ids don't dirty the style engine. + */ +function syncHeadingIds(root: HTMLElement): void { + const nextSlug = createSlugTracker(); + const headings = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6')); + for (const heading of headings) { + const text = heading.textContent?.trim() ?? ''; + if (!text) { + if (heading.hasAttribute('id')) { + heading.removeAttribute('id'); + } + if (heading.hasAttribute('data-heading-id')) { + heading.removeAttribute('data-heading-id'); + } + continue; + } + const headingId = nextSlug(text); + if (heading.id !== headingId) { + heading.id = headingId; + } + if (heading.getAttribute('data-heading-id') !== headingId) { + heading.setAttribute('data-heading-id', headingId); + } + } +} + +export function mountMarkdownEditor(options: { + target: HTMLElement; + value: string; + view: MarkdownEditorView; + initialScrollTop?: number; + currentFilePath: string; + searchLinks?: (query: string) => Promise; + loadHeadings?: (filePath: string) => Promise; + onChange: (value: string) => void; + onBlur?: () => void; +}): MarkdownEditorHandle { + const shell = options.target.closest('.markdown-editor-shell'); + const contextMenu = shell?.querySelector('#markdown-editor-context-menu') as HTMLElement | null; + const formatButtons = shell ? Array.from(shell.querySelectorAll('[data-format]')) : []; + const blockStyleSelect = shell?.querySelector('#markdown-block-style') as HTMLSelectElement | null; + const linkModal = shell?.querySelector('#markdown-link-modal') as HTMLElement | null; + const linkModeFile = shell?.querySelector('#markdown-link-mode-file') as HTMLButtonElement | null; + const linkModeUrl = shell?.querySelector('#markdown-link-mode-url') as HTMLButtonElement | null; + const linkFileFields = shell?.querySelector('#markdown-link-file-fields') as HTMLElement | null; + const linkUrlFields = shell?.querySelector('#markdown-link-url-fields') as HTMLElement | null; + const linkSearchInput = shell?.querySelector('#markdown-link-search') as HTMLInputElement | null; + const linkResults = shell?.querySelector('#markdown-link-results') as HTMLElement | null; + const linkHeadingSelect = shell?.querySelector('#markdown-link-heading') as HTMLSelectElement | null; + const linkAliasInput = shell?.querySelector('#markdown-link-alias') as HTMLInputElement | null; + const linkInput = shell?.querySelector('#markdown-link-input') as HTMLInputElement | null; + const linkLabelInput = shell?.querySelector('#markdown-link-label') as HTMLInputElement | null; + const linkApply = shell?.querySelector('#markdown-link-apply') as HTMLButtonElement | null; + const linkCancel = shell?.querySelector('#markdown-link-cancel') as HTMLButtonElement | null; + let linkMode: 'file' | 'url' = 'file'; + let linkSearchResults: MarkdownLinkSearchItem[] = []; + let selectedLinkItem: MarkdownLinkSearchItem | null = null; + let linkResultsMessage = 'Search for a file to link'; + let linkSearchRequestId = 0; + let linkHeadingRequestId = 0; + + if (options.view === 'markdown') { + options.target.replaceChildren(); + + const getTiptapMarkdown = (): string => { + const storage = tiptap.storage as { markdown?: { getMarkdown: () => string } }; + return restoreWikiLinks(storage.markdown?.getMarkdown() ?? ''); + }; + + const tiptap = new Editor({ + element: options.target, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3, 4, 5, 6] }, + codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, + link: { + openOnClick: false, + autolink: true, + HTMLAttributes: { 'data-markdown-link': 'true' }, + }, + }), + Image.configure({ allowBase64: true, inline: true }), + Markdown.configure({ + html: true, + tightLists: true, + bulletListMarker: '-', + linkify: true, + breaks: false, + transformPastedText: true, + transformCopiedText: false, + }), + ], + content: rewriteWikiLinks(options.value), + editorProps: { + attributes: { + class: 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc', + role: 'textbox', + 'aria-multiline': 'true', + }, + }, + onUpdate: ({ editor }) => { + syncHeadingIds(editor.view.dom as HTMLElement); + options.onChange(getTiptapMarkdown()); + }, + onSelectionUpdate: () => { + updateContextMenu(); + }, + onBlur: ({ event }) => { + if (shouldIgnoreBlur(shell, event as FocusEvent)) { + return; + } + if (contextMenu) { + contextMenu.hidden = true; + } + options.onBlur?.(); + }, + }); + + const editorDom = tiptap.view.dom as HTMLElement; + syncHeadingIds(editorDom); + + const updateContextMenu = (): void => { + if (!contextMenu) { + return; + } + const { from, to, empty } = tiptap.state.selection; + if (empty || !tiptap.isFocused) { + contextMenu.hidden = true; + return; + } + const start = tiptap.view.coordsAtPos(from); + const end = tiptap.view.coordsAtPos(to); + const shellEl = shell as HTMLElement | null; + if (!shellEl) { + return; + } + const shellRect = shellEl.getBoundingClientRect(); + const midX = (start.left + end.right) / 2; + contextMenu.hidden = false; + const left = Math.max(12, midX - shellRect.left - contextMenu.offsetWidth / 2); + const top = Math.max(12, start.top - shellRect.top - contextMenu.offsetHeight - 10); + contextMenu.style.left = `${left}px`; + contextMenu.style.top = `${top}px`; + }; + + const setLinkHeadingOptions = (headings: MarkdownLinkHeading[] = [], placeholder: string = 'None'): void => { + if (!linkHeadingSelect) { + return; + } + linkHeadingSelect.replaceChildren(); + const noneOption = document.createElement('option'); + noneOption.value = ''; + noneOption.textContent = placeholder; + linkHeadingSelect.appendChild(noneOption); + for (const heading of headings) { + const option = document.createElement('option'); + option.value = heading.id; + option.textContent = renderHeadingOptionLabel(headings, heading); + option.dataset.headingText = heading.text; + linkHeadingSelect.appendChild(option); + } + }; + + const loadHeadingsForItem = async (item: MarkdownLinkSearchItem): Promise => { + if (!linkHeadingSelect) { + return; + } + const requestId = ++linkHeadingRequestId; + setLinkHeadingOptions([], 'Loading…'); + try { + const headings = await options.loadHeadings?.(item.path) ?? []; + if (requestId !== linkHeadingRequestId || selectedLinkItem?.path !== item.path) { + return; + } + setLinkHeadingOptions(headings); + } catch { + if (requestId !== linkHeadingRequestId || selectedLinkItem?.path !== item.path) { + return; + } + setLinkHeadingOptions([], 'Failed to load headings'); + } + }; + + const renderLinkResults = (): void => { + if (!linkResults) { + return; + } + if (linkSearchResults.length === 0) { + const empty = document.createElement('div'); + empty.className = 'markdown-link-results-empty'; + empty.textContent = linkResultsMessage; + linkResults.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + for (const item of linkSearchResults) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `markdown-link-result${selectedLinkItem?.path === item.path ? ' is-active' : ''}`; + button.dataset.linkPath = item.path; + + const title = document.createElement('span'); + title.className = 'markdown-link-result-title'; + title.textContent = item.title; + + const path = document.createElement('span'); + path.className = 'markdown-link-result-path'; + path.textContent = item.relativePath; + + button.append(title, path); + button.addEventListener('click', () => { + selectedLinkItem = item; + renderLinkResults(); + void loadHeadingsForItem(item); + }); + fragment.appendChild(button); + } + linkResults.replaceChildren(fragment); + }; + + const updateLinkMode = (mode: 'file' | 'url'): void => { + linkMode = mode; + linkModeFile?.classList.toggle('is-active', mode === 'file'); + linkModeUrl?.classList.toggle('is-active', mode === 'url'); + if (linkFileFields) { + linkFileFields.hidden = mode !== 'file'; + } + if (linkUrlFields) { + linkUrlFields.hidden = mode !== 'url'; + } + }; + + const runLinkSearch = async (): Promise => { + if (!linkSearchInput || !options.searchLinks) { + return; + } + const query = linkSearchInput.value.trim(); + if (query.length === 0) { + linkSearchRequestId += 1; + linkSearchResults = []; + selectedLinkItem = null; + linkResultsMessage = 'Search for a file to link'; + setLinkHeadingOptions(); + renderLinkResults(); + return; + } + const requestId = ++linkSearchRequestId; + try { + const results = await options.searchLinks(query); + if (requestId !== linkSearchRequestId || query !== linkSearchInput.value.trim()) { + return; + } + linkSearchResults = results; + selectedLinkItem = results[0] ?? null; + linkResultsMessage = results.length === 0 ? 'No matching files found' : 'Search for a file to link'; + renderLinkResults(); + if (selectedLinkItem) { + void loadHeadingsForItem(selectedLinkItem); + } else { + setLinkHeadingOptions(); + } + } catch { + if (requestId !== linkSearchRequestId) { + return; + } + linkSearchResults = []; + selectedLinkItem = null; + linkResultsMessage = 'Search failed. Try again.'; + setLinkHeadingOptions(); + renderLinkResults(); + } + }; + + const closeLinkModal = (): void => { + linkModal?.setAttribute('hidden', ''); + if (linkInput) { linkInput.value = ''; } + if (linkLabelInput) { linkLabelInput.value = ''; } + if (linkAliasInput) { linkAliasInput.value = ''; } + if (linkSearchInput) { linkSearchInput.value = ''; } + setLinkHeadingOptions(); + linkSearchResults = []; + selectedLinkItem = null; + linkResultsMessage = 'Search for a file to link'; + renderLinkResults(); + }; + + const openLinkModalForSelection = (): void => { + if (!linkModal) { + return; + } + const selectedText = tiptap.state.doc.textBetween(tiptap.state.selection.from, tiptap.state.selection.to, ' ').trim(); + linkModal.removeAttribute('hidden'); + updateLinkMode('url'); + if (linkLabelInput) { + linkLabelInput.value = selectedText; + } + if (linkInput) { + linkInput.value = ''; + linkInput.focus(); + } + linkSearchResults = []; + selectedLinkItem = null; + linkResultsMessage = 'Search for a file to link'; + setLinkHeadingOptions(); + renderLinkResults(); + }; + + const handleLinkApply = (): void => { + if (linkMode === 'url') { + const href = linkInput?.value?.trim(); + if (!href) { + closeLinkModal(); + return; + } + const label = linkLabelInput?.value?.trim() || href; + const { from, to, empty } = tiptap.state.selection; + if (empty) { + tiptap.chain().focus().insertContent({ + type: 'text', + text: label, + marks: [{ type: 'link', attrs: { href } }], + }).run(); + } else { + tiptap.chain() + .focus() + .deleteRange({ from, to }) + .insertContent({ + type: 'text', + text: label, + marks: [{ type: 'link', attrs: { href } }], + }) + .run(); + } + } else if (selectedLinkItem) { + const selectedHeadingId = linkHeadingSelect?.value?.trim(); + const selectedHeadingText = linkHeadingSelect?.selectedOptions[0]?.dataset.headingText?.trim(); + const alias = linkAliasInput?.value?.trim(); + const pathPart = selectedLinkItem.path === options.currentFilePath ? '' : selectedLinkItem.wikiPath; + const wikiLink = `[[${pathPart}${selectedHeadingId ? `#${selectedHeadingId}` : ''}${alias ? `|${alias}` : ''}]]`; + const href = `${selectedLinkItem.relativePath}${selectedHeadingId ? `#${selectedHeadingId}` : ''}`; + const label = alias || selectedHeadingText || selectedLinkItem.title; + const { from, to, empty } = tiptap.state.selection; + const insertChain = tiptap.chain().focus(); + if (!empty) { + insertChain.deleteRange({ from, to }); + } + insertChain.insertContent({ + type: 'text', + text: label, + marks: [{ + type: 'link', + attrs: { + href, + title: `mcp-wiki:${encodeURIComponent(wikiLink)}`, + }, + }], + }).run(); + } + closeLinkModal(); + }; + + const handleFormatClick = (event: Event): void => { + const target = event.currentTarget as HTMLButtonElement; + const format = target.dataset.format; + if (!format) { + return; + } + switch (format) { + case 'bold': + tiptap.chain().focus().toggleBold().run(); + break; + case 'italic': + tiptap.chain().focus().toggleItalic().run(); + break; + case 'strike': + tiptap.chain().focus().toggleStrike().run(); + break; + case 'quote': + tiptap.chain().focus().toggleBlockquote().run(); + break; + case 'list': + tiptap.chain().focus().toggleBulletList().run(); + break; + case 'code': + tiptap.chain().focus().toggleCode().run(); + break; + case 'link': + openLinkModalForSelection(); + break; + } + }; + + const handleBlockStyleChange = (): void => { + const value = blockStyleSelect?.value; + if (!value) { + return; + } + if (value === 'p') { + tiptap.chain().focus().setParagraph().run(); + return; + } + const match = /^h([1-6])$/.exec(value); + if (match) { + const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6; + tiptap.chain().focus().toggleHeading({ level }).run(); + } + }; + + const linkPopover = document.createElement('div'); + linkPopover.className = 'markdown-link-popover'; + linkPopover.hidden = true; + editorDom.parentElement?.appendChild(linkPopover); + let popoverHideTimer: ReturnType | null = null; + + const showLinkPopover = (anchor: HTMLAnchorElement): void => { + if (popoverHideTimer) { + clearTimeout(popoverHideTimer); + popoverHideTimer = null; + } + const href = anchor.getAttribute('href') ?? ''; + linkPopover.innerHTML = ``; + linkPopover.hidden = false; + + linkPopover.querySelector('#link-popover-open')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + linkPopover.hidden = true; + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }, { once: true }); + + linkPopover.querySelector('#link-popover-edit')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + linkPopover.hidden = true; + if (!linkModal) { + return; + } + const pos = tiptap.view.posAtDOM(anchor, 0); + if (pos >= 0) { + const endPos = pos + (anchor.textContent?.length ?? 0); + tiptap.chain().focus().setTextSelection({ from: pos, to: endPos }).run(); + } + const label = anchor.textContent?.trim() ?? ''; + linkModal.removeAttribute('hidden'); + updateLinkMode('url'); + if (linkInput) { linkInput.value = href; } + if (linkLabelInput) { linkLabelInput.value = label; } + }, { once: true }); + + const rect = anchor.getBoundingClientRect(); + const parent = editorDom.parentElement; + if (!parent) { + return; + } + const parentRect = parent.getBoundingClientRect(); + linkPopover.style.left = `${Math.max(4, rect.left - parentRect.left)}px`; + linkPopover.style.top = `${rect.bottom - parentRect.top + 4}px`; + }; + + const hideLinkPopover = (): void => { + popoverHideTimer = setTimeout(() => { + linkPopover.hidden = true; + }, 200); + }; + + const handleMouseOver = (e: MouseEvent): void => { + const target = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null; + if (target && editorDom.contains(target)) { + showLinkPopover(target); + } + }; + const handleMouseOut = (e: MouseEvent): void => { + const target = (e.target as HTMLElement)?.closest?.('a[href]'); + if (target) { + hideLinkPopover(); + } + }; + const handlePopoverEnter = (): void => { + if (popoverHideTimer) { + clearTimeout(popoverHideTimer); + popoverHideTimer = null; + } + }; + const handlePopoverLeave = (): void => { + hideLinkPopover(); + }; + const handleLinkModeFileClick = (): void => updateLinkMode('file'); + const handleLinkModeUrlClick = (): void => { + updateLinkMode('url'); + linkInput?.focus(); + }; + const handleSearchInput = (): void => { void runLinkSearch(); }; + const handleModalBackdropClick = (e: MouseEvent): void => { + if (e.target === linkModal) { + closeLinkModal(); + } + }; + + editorDom.addEventListener('mouseover', handleMouseOver); + editorDom.addEventListener('mouseout', handleMouseOut); + linkPopover.addEventListener('mouseenter', handlePopoverEnter); + linkPopover.addEventListener('mouseleave', handlePopoverLeave); + formatButtons.forEach((button) => button.addEventListener('click', handleFormatClick)); + blockStyleSelect?.addEventListener('change', handleBlockStyleChange); + linkModeFile?.addEventListener('click', handleLinkModeFileClick); + linkModeUrl?.addEventListener('click', handleLinkModeUrlClick); + linkSearchInput?.addEventListener('input', handleSearchInput); + linkApply?.addEventListener('click', handleLinkApply); + linkCancel?.addEventListener('click', closeLinkModal); + linkModal?.addEventListener('click', handleModalBackdropClick); + + if (typeof options.initialScrollTop === 'number') { + editorDom.scrollTop = options.initialScrollTop; + } + renderLinkResults(); + + return { + destroy: () => { + editorDom.removeEventListener('mouseover', handleMouseOver); + editorDom.removeEventListener('mouseout', handleMouseOut); + linkPopover.removeEventListener('mouseenter', handlePopoverEnter); + linkPopover.removeEventListener('mouseleave', handlePopoverLeave); + formatButtons.forEach((button) => button.removeEventListener('click', handleFormatClick)); + blockStyleSelect?.removeEventListener('change', handleBlockStyleChange); + linkModeFile?.removeEventListener('click', handleLinkModeFileClick); + linkModeUrl?.removeEventListener('click', handleLinkModeUrlClick); + linkSearchInput?.removeEventListener('input', handleSearchInput); + linkApply?.removeEventListener('click', handleLinkApply); + linkCancel?.removeEventListener('click', closeLinkModal); + linkModal?.removeEventListener('click', handleModalBackdropClick); + linkPopover.remove(); + if (popoverHideTimer) { clearTimeout(popoverHideTimer); } + tiptap.destroy(); + options.target.replaceChildren(); + }, + focus: () => { + tiptap.commands.focus(); + }, + getValue: () => getTiptapMarkdown(), + setValue: (value: string) => { + tiptap.commands.setContent(rewriteWikiLinks(value), { emitUpdate: false }); + syncHeadingIds(editorDom); + }, + revealLine: (_lineNumber: number, headingId?: string) => { + if (headingId) { + const heading = editorDom.querySelector(`#${CSS.escape(headingId)}`); + if (heading) { + heading.scrollIntoView({ block: 'start', inline: 'nearest' }); + editorDom.scrollTop = Math.max(editorDom.scrollTop - 24, 0); + heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: true }); + return; + } + } + tiptap.commands.focus(); + }, + setScrollTop: (scrollTop: number) => { + editorDom.scrollTop = Math.max(0, scrollTop); + }, + }; + } + + const textarea = document.createElement('textarea'); + textarea.className = 'markdown-editor-textarea markdown-editor-textarea--raw'; + textarea.spellcheck = false; + textarea.setAttribute('autocomplete', 'off'); + textarea.setAttribute('autocorrect', 'off'); + textarea.setAttribute('autocapitalize', 'off'); + textarea.placeholder = 'Edit raw markdown...'; + textarea.value = options.value; + options.target.replaceChildren(textarea); + + const autosize = (): void => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.max(textarea.scrollHeight, 640)}px`; + }; + + const handleInput = (): void => { + autosize(); + options.onChange(textarea.value); + }; + + const handleFocusOut = (event: FocusEvent): void => { + if (shouldIgnoreBlur(shell, event)) { + return; + } + options.onBlur?.(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Tab') { + return; + } + + event.preventDefault(); + applyRawTab(textarea); + autosize(); + options.onChange(textarea.value); + }; + + textarea.addEventListener('input', handleInput); + textarea.addEventListener('keydown', handleKeyDown); + textarea.addEventListener('focusout', handleFocusOut); + autosize(); + if (typeof options.initialScrollTop === 'number') { + textarea.scrollTop = options.initialScrollTop; + } + + return { + destroy: () => { + textarea.removeEventListener('input', handleInput); + textarea.removeEventListener('keydown', handleKeyDown); + textarea.removeEventListener('focusout', handleFocusOut); + options.target.replaceChildren(); + }, + focus: () => { + textarea.focus(); + }, + getValue: () => textarea.value, + setValue: (value: string) => { + textarea.value = value; + autosize(); + }, + revealLine: (lineNumber: number) => { + const targetLine = Math.max(1, Math.floor(lineNumber)); + const lines = textarea.value.split('\n'); + let index = 0; + for (let currentLine = 1; currentLine < targetLine && currentLine <= lines.length; currentLine += 1) { + index += lines[currentLine - 1].length + 1; + } + + textarea.focus(); + textarea.setSelectionRange(index, index); + + const lineHeight = Number.parseFloat(window.getComputedStyle(textarea).lineHeight || '20') || 20; + textarea.scrollTop = Math.max(0, (targetLine - 1) * lineHeight - lineHeight * 2); + }, + setScrollTop: (scrollTop: number) => { + textarea.scrollTop = Math.max(0, scrollTop); + }, + }; +} diff --git a/src/ui/file-preview/src/markdown/linking.ts b/src/ui/file-preview/src/markdown/linking.ts new file mode 100644 index 00000000..dfe0baac --- /dev/null +++ b/src/ui/file-preview/src/markdown/linking.ts @@ -0,0 +1,287 @@ +import { slugifyMarkdownHeading } from './slugify.js'; +import { getParentDirectory, isWindowsAbsolutePath, normalizeFilePath, normalizePathSeparators } from '../path-utils.js'; + +export interface ResolvedMarkdownLink { + kind: 'external' | 'anchor' | 'file'; + href: string; + url?: string; + targetPath?: string; + anchor?: string; +} + +interface ParsedWikiLink { + path: string; + anchor?: string; + alias?: string; +} + +const WIKI_LINK_PATTERN = /\[\[([^\]|#]*)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g; +const FENCE_PATTERN = /^(`{3,}|~{3,})/; + +function encodeLinkPath(pathValue: string): string { + return encodeURI(normalizePathSeparators(pathValue)); +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseWikiLink(rawHref: string): ParsedWikiLink | null { + const match = rawHref.match(/^\[\[([^\]|#]*)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]$/); + if (!match) { + return null; + } + + return { + path: (match[1] ?? '').trim(), + anchor: match[2]?.trim(), + alias: match[3]?.trim(), + }; +} + +function buildWikiDisplayText(link: ParsedWikiLink): string { + if (link.alias && link.alias.length > 0) { + return link.alias; + } + + if (link.path && link.anchor) { + return `${link.path}#${link.anchor}`; + } + + if (link.path) { + return link.path; + } + + return link.anchor ?? ''; +} + +function appendMarkdownExtension(pathValue: string): string { + if (/\.[A-Za-z0-9_-]+$/.test(pathValue)) { + return pathValue; + } + + return `${pathValue}.md`; +} + +function buildWikiHref(link: ParsedWikiLink): string { + if (!link.path) { + if (!link.anchor) { + return '#'; + } + + return `#${slugifyMarkdownHeading(link.anchor)}`; + } + + const normalizedPath = appendMarkdownExtension(normalizePathSeparators(link.path)); + const prefixedPath = normalizedPath.startsWith('./') + || normalizedPath.startsWith('../') + || normalizedPath.startsWith('/') + || isWindowsAbsolutePath(normalizedPath) + ? normalizedPath + : `./${normalizedPath}`; + + const encodedPath = encodeLinkPath(prefixedPath); + if (!link.anchor) { + return encodedPath; + } + + return `${encodedPath}#${slugifyMarkdownHeading(link.anchor)}`; +} + +function rewriteWikiLinksInPlainText(segment: string): string { + return segment.replace(WIKI_LINK_PATTERN, (match) => { + const parsed = parseWikiLink(match); + if (!parsed) { + return match; + } + + const displayText = buildWikiDisplayText(parsed); + const href = buildWikiHref(parsed); + return `[${displayText}](${href} "mcp-wiki:${encodeURIComponent(match)}")`; + }); +} + +function replaceWikiLinksOutsideInlineCode(line: string): string { + let result = ''; + let cursor = 0; + + while (cursor < line.length) { + const codeStart = line.indexOf('`', cursor); + if (codeStart === -1) { + result += rewriteWikiLinksInPlainText(line.slice(cursor)); + break; + } + + result += rewriteWikiLinksInPlainText(line.slice(cursor, codeStart)); + + let delimiterEnd = codeStart; + while (delimiterEnd < line.length && line[delimiterEnd] === '`') { + delimiterEnd += 1; + } + + const delimiter = line.slice(codeStart, delimiterEnd); + const codeEnd = line.indexOf(delimiter, delimiterEnd); + if (codeEnd === -1) { + result += line.slice(codeStart); + break; + } + + result += line.slice(codeStart, codeEnd + delimiter.length); + cursor = codeEnd + delimiter.length; + } + + return result; +} + +function decodeAnchorFragment(fragment: string | undefined): string | undefined { + if (!fragment || fragment.length === 0) { + return undefined; + } + + return safeDecodeURIComponent(fragment); +} + +function splitHref(rawHref: string): { pathPart: string; anchorPart?: string } { + const hashIndex = rawHref.indexOf('#'); + if (hashIndex === -1) { + return { pathPart: rawHref }; + } + + return { + pathPart: rawHref.slice(0, hashIndex), + anchorPart: rawHref.slice(hashIndex + 1), + }; +} + +function toDirectoryFileUrl(directoryPath: string): URL { + const normalized = normalizeFilePath(directoryPath); + const withTrailingSlash = normalized.endsWith('/') ? normalized : `${normalized}/`; + + if (isWindowsAbsolutePath(withTrailingSlash)) { + return new URL(`file:///${encodeLinkPath(withTrailingSlash)}`); + } + + if (withTrailingSlash.startsWith('/')) { + return new URL(`file://${encodeLinkPath(withTrailingSlash)}`); + } + + return new URL(`file:///${encodeLinkPath(withTrailingSlash)}`); +} + +function fromFileUrl(url: URL): string { + const decodedPath = safeDecodeURIComponent(url.pathname); + if (/^\/[A-Za-z]:\//.test(decodedPath)) { + return decodedPath.slice(1); + } + + return decodedPath; +} + +function isExternalHref(rawHref: string): boolean { + return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawHref) && !isWindowsAbsolutePath(rawHref); +} + +function resolveFileTargetPath(currentPath: string, rawPath: string): string { + const normalizedRawPath = normalizePathSeparators(safeDecodeURIComponent(rawPath)); + if (normalizedRawPath.startsWith('/') || isWindowsAbsolutePath(normalizedRawPath)) { + return normalizeFilePath(normalizedRawPath); + } + + const baseDirectory = getParentDirectory(currentPath); + if (baseDirectory === '.' && !normalizeFilePath(currentPath).includes('/')) { + return normalizeFilePath(normalizedRawPath); + } + const resolvedUrl = new URL(encodeURI(normalizedRawPath), toDirectoryFileUrl(baseDirectory)); + return normalizeFilePath(fromFileUrl(resolvedUrl)); +} + +/** + * Invert `rewriteWikiLinks`: convert `[alias](href "mcp-wiki:ENCODED")` links + * back to their original `[[...]]` form. Used when serializing a WYSIWYG + * edit session back to markdown — the `mcp-wiki:` title prefix is the + * round-trip marker written by `rewriteWikiLinks`. + */ +export function restoreWikiLinks(markdown: string): string { + return markdown.replace(/\[([^\]]*)\]\(([^)\s]*)(?:\s+"mcp-wiki:([^"]+)")\)/g, (_, _alias, _href, encoded) => { + try { + return decodeURIComponent(encoded); + } catch { + return `[[${encoded}]]`; + } + }); +} + +export function rewriteWikiLinks(source: string): string { + const lines = source.split('\n'); + let activeFence: string | null = null; + + return lines.map((line) => { + const trimmedStart = line.trimStart(); + const fenceMatch = trimmedStart.match(FENCE_PATTERN); + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!activeFence) { + activeFence = marker; + } else if (marker[0] === activeFence[0] && marker.length >= activeFence.length) { + activeFence = null; + } + return line; + } + + if (activeFence) { + return line; + } + + return replaceWikiLinksOutsideInlineCode(line); + }).join('\n'); +} + +export function resolveMarkdownLink(currentPath: string, rawHref: string): ResolvedMarkdownLink { + const wikiLink = parseWikiLink(rawHref); + if (wikiLink) { + const href = buildWikiHref(wikiLink); + if (href.startsWith('#')) { + return { + kind: 'anchor', + href: rawHref, + anchor: decodeAnchorFragment(href.slice(1)), + }; + } + + const [pathPart, anchorPart] = href.split('#'); + return { + kind: 'file', + href: rawHref, + targetPath: resolveFileTargetPath(currentPath, pathPart), + ...(anchorPart ? { anchor: decodeAnchorFragment(anchorPart) } : {}), + }; + } + + if (isExternalHref(rawHref)) { + return { + kind: 'external', + href: rawHref, + url: rawHref, + }; + } + + if (rawHref.startsWith('#')) { + return { + kind: 'anchor', + href: rawHref, + anchor: decodeAnchorFragment(rawHref.slice(1)), + }; + } + + const { pathPart, anchorPart } = splitHref(rawHref); + return { + kind: 'file', + href: rawHref, + targetPath: resolveFileTargetPath(currentPath, pathPart), + ...(anchorPart ? { anchor: decodeAnchorFragment(anchorPart) } : {}), + }; +} diff --git a/src/ui/file-preview/src/markdown/outline.ts b/src/ui/file-preview/src/markdown/outline.ts new file mode 100644 index 00000000..36ec29a0 --- /dev/null +++ b/src/ui/file-preview/src/markdown/outline.ts @@ -0,0 +1,21 @@ +import type { DocumentOutlineItem } from '../document-outline.js'; +import { createMarkdownIt, prepareMarkdownSource, readHeadingProjection } from './parser.js'; +import { createSlugTracker } from './slugify.js'; +const outlineParser = createMarkdownIt(); + +export function extractMarkdownOutline(source: string): DocumentOutlineItem[] { + const tokens = outlineParser.parse(prepareMarkdownSource(source), {}); + const nextSlug = createSlugTracker(); + const outline: DocumentOutlineItem[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const heading = readHeadingProjection(tokens, index, nextSlug); + if (!heading) { + continue; + } + + outline.push(heading); + } + + return outline; +} diff --git a/src/ui/file-preview/src/markdown/parser.ts b/src/ui/file-preview/src/markdown/parser.ts new file mode 100644 index 00000000..83888b6a --- /dev/null +++ b/src/ui/file-preview/src/markdown/parser.ts @@ -0,0 +1,85 @@ +// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations. +import MarkdownIt from 'markdown-it'; +import type { MarkdownSlugTracker } from './slugify.js'; +import { rewriteWikiLinks } from './linking.js'; +import { extractInlineText } from './utils.js'; + +export interface MarkdownToken { + type?: string; + tag?: string; + map?: number[]; + children?: unknown; + content?: unknown; + attrSet?: (name: string, value: string) => void; + attrGet?: (name: string) => string | null; + attrs?: Array<[string, string]>; +} + +interface MarkdownItInstance { + render: (source: string, env?: Record) => string; + parse: (source: string, env?: Record) => MarkdownToken[]; + renderer: { + rules: Record string>; + }; +} + +type MarkdownItConstructor = new (options?: { + html?: boolean; + linkify?: boolean; + typographer?: boolean; + highlight?: (code: string, language: string) => string; +}) => MarkdownItInstance; + +export interface MarkdownHeadingProjection { + id: string; + text: string; + level: number; + line: number; +} + +const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; + +export function createMarkdownIt(options: { + highlight?: (code: string, language: string) => string; +} = {}): MarkdownItInstance { + return new MarkdownItCtor({ + html: false, + linkify: true, + typographer: false, + ...(options.highlight ? { highlight: options.highlight } : {}), + }); +} + +export function prepareMarkdownSource(source: string): string { + return rewriteWikiLinks(source); +} + +export function readHeadingProjection( + tokens: MarkdownToken[], + index: number, + nextSlug: MarkdownSlugTracker +): MarkdownHeadingProjection | null { + const token = tokens[index]; + if (token?.type !== 'heading_open' || typeof token.tag !== 'string') { + return null; + } + + const level = Number.parseInt(token.tag.replace(/^h/i, ''), 10); + if (!Number.isFinite(level)) { + return null; + } + + const inlineToken = tokens[index + 1] as Record | undefined; + const text = extractInlineText(inlineToken).trim(); + if (!text) { + return null; + } + + const lineMap = Array.isArray(token.map) ? token.map : undefined; + return { + id: nextSlug(text), + text, + level, + line: typeof lineMap?.[0] === 'number' ? lineMap[0] + 1 : index + 1, + }; +} diff --git a/src/ui/file-preview/src/markdown/preview.ts b/src/ui/file-preview/src/markdown/preview.ts new file mode 100644 index 00000000..a8364d63 --- /dev/null +++ b/src/ui/file-preview/src/markdown/preview.ts @@ -0,0 +1,23 @@ +import { renderMarkdown } from '../components/markdown-renderer.js'; + +export function getRenderedMarkdownCopyText(content: string): string { + const html = renderMarkdown(content); + const normalizedHtml = html + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/h[1-6]>/gi, '\n\n') + .replace(/<\/li>/gi, '\n') + .replace(/
  • /gi, '- ') + .replace(/<[^>]+>/g, ''); + + return normalizedHtml + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + diff --git a/src/ui/file-preview/src/markdown/slugify.ts b/src/ui/file-preview/src/markdown/slugify.ts new file mode 100644 index 00000000..4724ba00 --- /dev/null +++ b/src/ui/file-preview/src/markdown/slugify.ts @@ -0,0 +1,39 @@ +export type MarkdownSlugTracker = (text: string) => string; + +function sanitizeSlugPart(text: string): string { + const normalized = text + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, ' ') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized.length > 0 ? normalized : 'section'; +} + +export function slugifyMarkdownHeading(text: string): string { + return sanitizeSlugPart(text); +} + +export function createSlugTracker(): MarkdownSlugTracker { + const counts = new Map(); + const usedSlugs = new Set(); + + return (text: string): string => { + const baseSlug = slugifyMarkdownHeading(text); + let nextCount = counts.get(baseSlug) ?? 1; + let nextSlug = nextCount === 1 ? baseSlug : `${baseSlug}-${nextCount}`; + + while (usedSlugs.has(nextSlug)) { + nextCount += 1; + nextSlug = `${baseSlug}-${nextCount}`; + } + + counts.set(baseSlug, nextCount + 1); + usedSlugs.add(nextSlug); + return nextSlug; + }; +} diff --git a/src/ui/file-preview/src/markdown/utils.ts b/src/ui/file-preview/src/markdown/utils.ts new file mode 100644 index 00000000..a7a0e7ca --- /dev/null +++ b/src/ui/file-preview/src/markdown/utils.ts @@ -0,0 +1,17 @@ +export function extractInlineText(token: Record | undefined): string { + if (!token) { + return ''; + } + + const children = Array.isArray(token.children) ? token.children : []; + if (children.length === 0) { + return typeof token.content === 'string' ? token.content : ''; + } + + return children.map((child) => { + if (typeof child.content === 'string') { + return child.content; + } + return ''; + }).join(''); +} diff --git a/src/ui/file-preview/src/model.ts b/src/ui/file-preview/src/model.ts new file mode 100644 index 00000000..df5adc6e --- /dev/null +++ b/src/ui/file-preview/src/model.ts @@ -0,0 +1,36 @@ +import type { DocumentOutlineItem } from './document-outline.js'; +import type { FilePreviewStructuredContent } from '../../../types.js'; +import type { MarkdownEditorView } from './markdown/editor.js'; + +export type RenderPayload = FilePreviewStructuredContent & { content: string }; + +export interface MarkdownWorkspaceState { + filePath: string; + sourceContent: string; + fullDocumentContent: string; + draftContent: string; + outline: DocumentOutlineItem[]; + mode: 'edit'; + dirty: boolean; + activeHeadingId: string | null; + pendingAnchor: string | null; + notice: string | null; + error: string | null; + saving: boolean; + loadingDocument: boolean; + editorView: MarkdownEditorView; + editorScrollTop: number; + saveIndicator: 'idle' | 'saving' | 'saved'; + fileDeleted: boolean; +} + +export interface RenderBodyResult { + html: string; + notice?: string; +} + +export interface FileTypeCapabilities { + supportsPreview: boolean; + canCopy: boolean; + canOpenInFolder: boolean; +} diff --git a/src/ui/file-preview/src/panel-actions.ts b/src/ui/file-preview/src/panel-actions.ts new file mode 100644 index 00000000..47051145 --- /dev/null +++ b/src/ui/file-preview/src/panel-actions.ts @@ -0,0 +1,216 @@ +import { parseReadRange, stripReadStatusLine } from './document-workspace.js'; +import type { RenderPayload } from './model.js'; +import type { MarkdownController } from './markdown/controller.js'; +import { extractToolText } from './payload-utils.js'; +import type { HtmlPreviewMode } from './types.js'; + +export function attachPanelActions(options: { + container: HTMLElement; + payload: RenderPayload; + htmlMode: HtmlPreviewMode; + getIsExpanded: () => boolean; + callTool?: (name: string, args: Record) => Promise; + trackUiEvent?: (event: string, params?: Record) => void; + getFileExtensionForAnalytics: (filePath: string) => string; + buildOpenInFolderCommand: (filePath: string) => string | undefined; + buildOpenInEditorCommand: (filePath: string) => string | undefined; + render: (payload?: RenderPayload, htmlMode?: HtmlPreviewMode, expandedState?: boolean) => void; + updateSaveStatus: (label: string, statusClass: string) => void; + markdownController: MarkdownController; +}): void { + const queryById = (id: string): T | null => ( + options.container.querySelector(`#${id}`) + ); + + const fallbackCopy = (text: string): boolean => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + return success; + }; + + const setButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { + button.setAttribute('title', label); + button.setAttribute('aria-label', label); + button.textContent = label; + if (revertMs) { + setTimeout(() => { + button.textContent = fallbackLabel; + button.setAttribute('title', fallbackLabel); + button.setAttribute('aria-label', fallbackLabel); + }, revertMs); + } + }; + + const setIconButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { + button.setAttribute('title', label); + button.setAttribute('aria-label', label); + button.dataset.status = label; + if (revertMs) { + setTimeout(() => { + button.setAttribute('title', fallbackLabel); + button.setAttribute('aria-label', fallbackLabel); + delete button.dataset.status; + }, revertMs); + } + }; + + const copyTextData = async (text: string): Promise => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + return fallbackCopy(text); + } catch { + return fallbackCopy(text); + } + }; + + const fileExtension = options.getFileExtensionForAnalytics(options.payload.filePath); + const copyButton = queryById('copy-source'); + copyButton?.addEventListener('click', async () => { + options.trackUiEvent?.('copy_clicked', { + file_type: options.payload.fileType, + file_extension: fileExtension, + }); + + const copied = await copyTextData(stripReadStatusLine(options.payload.content)); + setButtonState(copyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); + }); + + const activeCopyButton = queryById('copy-active-markdown'); + activeCopyButton?.addEventListener('click', async () => { + const textToCopy = options.markdownController.getCopyText(options.payload); + if (!textToCopy) { + return; + } + const copied = await copyTextData(textToCopy); + if (copied) { + options.updateSaveStatus('Copied', 'saved'); + window.setTimeout(() => options.updateSaveStatus('', ''), 1500); + } + setIconButtonState(activeCopyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); + }); + + const toggleButton = queryById('toggle-html-mode'); + toggleButton?.addEventListener('click', () => { + const nextMode: HtmlPreviewMode = options.htmlMode === 'rendered' ? 'source' : 'rendered'; + options.trackUiEvent?.('html_view_toggled', { + file_type: options.payload.fileType, + file_extension: fileExtension, + }); + options.render(options.payload, nextMode, options.getIsExpanded()); + }); + + const openFolderButton = queryById('open-in-folder'); + if (openFolderButton) { + const command = options.buildOpenInFolderCommand(options.payload.filePath); + if (!command) { + openFolderButton.disabled = true; + } else { + openFolderButton.addEventListener('click', async () => { + options.trackUiEvent?.('open_in_folder', { + file_type: options.payload.fileType, + file_extension: fileExtension, + }); + try { + await options.callTool?.('start_process', { command, timeout_ms: 12000 }); + } catch { + // Keep UI stable if opening folder fails. + } + }); + } + } + + const openEditorButton = queryById('open-in-editor'); + if (openEditorButton) { + const command = options.buildOpenInEditorCommand(options.payload.filePath); + if (!command) { + openEditorButton.disabled = true; + } else { + openEditorButton.addEventListener('click', async () => { + options.trackUiEvent?.('open_in_editor', { + file_type: options.payload.fileType, + file_extension: fileExtension, + }); + try { + await options.callTool?.('start_process', { command, timeout_ms: 12000 }); + } catch { + // Keep UI stable if opening editor fails. + } + }); + } + } + + const beforeBtn = queryById('load-before'); + const afterBtn = queryById('load-after'); + if (!beforeBtn && !afterBtn) { + return; + } + + const range = parseReadRange(options.payload.content); + if (!range?.isPartial) { + return; + } + + const currentContent = stripReadStatusLine(options.payload.content); + const loadLines = async (button: HTMLButtonElement, direction: 'before' | 'after'): Promise => { + const originalText = button.textContent; + button.textContent = 'Loading…'; + button.disabled = true; + + options.trackUiEvent?.(direction === 'before' ? 'load_lines_before' : 'load_lines_after', { + file_type: options.payload.fileType, + file_extension: fileExtension, + }); + + try { + const readArgs = direction === 'before' + ? { path: options.payload.filePath, offset: 0, length: range.fromLine - 1 } + : { path: options.payload.filePath, offset: range.toLine }; + + const result = await options.callTool?.('read_file', readArgs); + const newText = extractToolText(result); + + if (newText && typeof newText === 'string') { + const cleanNew = stripReadStatusLine(newText); + const merged = direction === 'before' + ? `${cleanNew}${cleanNew.endsWith('\n') ? '' : '\n'}${currentContent}` + : `${currentContent}${currentContent.endsWith('\n') ? '' : '\n'}${cleanNew}`; + + const newFrom = direction === 'before' ? 1 : range.fromLine; + const newTo = direction === 'after' ? range.totalLines : range.toLine; + const lineCount = newTo - newFrom + 1; + const remaining = range.totalLines - newTo; + const isStillPartial = newFrom > 1 || newTo < range.totalLines; + const statusLine = isStillPartial + ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` + : ''; + + options.render({ + ...options.payload, + content: statusLine + merged, + }, options.htmlMode, options.getIsExpanded()); + return; + } + } catch { + // Fall through to button reset. + } + + button.textContent = 'Failed to load'; + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 2000); + }; + + beforeBtn?.addEventListener('click', () => void loadLines(beforeBtn, 'before')); + afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); +} diff --git a/src/ui/file-preview/src/path-utils.ts b/src/ui/file-preview/src/path-utils.ts new file mode 100644 index 00000000..1a697315 --- /dev/null +++ b/src/ui/file-preview/src/path-utils.ts @@ -0,0 +1,81 @@ +export function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value); +} + +export function normalizePathSeparators(value: string): string { + return value.replace(/\\/g, '/'); +} + +export function normalizeFilePath(value: string): string { + const normalized = normalizePathSeparators(value); + return normalized.replace(/\/+/g, '/'); +} + +function getPathRoot(value: string): string { + const normalized = normalizeFilePath(value); + if (isWindowsAbsolutePath(normalized)) { + return normalized.slice(0, 3); + } + + return normalized.startsWith('/') ? '/' : ''; +} + +function getPathSegments(value: string): string[] { + const normalized = normalizeFilePath(value); + const root = getPathRoot(normalized); + return normalized.slice(root.length).split('/').filter(Boolean); +} + +export function getParentDirectory(filePath: string): string { + const root = getPathRoot(filePath); + const segments = getPathSegments(filePath); + if (segments.length <= 1) { + return root || '.'; + } + + return `${root}${segments.slice(0, -1).join('/')}`; +} + +export function getAncestorDirectories(filePath: string): string[] { + const root = getPathRoot(filePath); + const segments = getPathSegments(filePath); + const ancestors: string[] = []; + + for (let index = segments.length - 1; index >= 1; index -= 1) { + const ancestor = `${root}${segments.slice(0, index).join('/')}`; + ancestors.push(ancestor || '.'); + } + + if (root && ancestors[ancestors.length - 1] !== root) { + ancestors.push(root); + } + + return ancestors; +} + +export function toPosixRelativePath(fromDirectory: string, targetPath: string): string { + const normalizedFrom = normalizeFilePath(fromDirectory); + const normalizedTarget = normalizeFilePath(targetPath); + const fromRoot = getPathRoot(normalizedFrom); + const targetRoot = getPathRoot(normalizedTarget); + + if (fromRoot !== targetRoot) { + return normalizedTarget; + } + + const fromParts = getPathSegments(normalizedFrom); + const targetParts = getPathSegments(normalizedTarget); + let shared = 0; + while ( + shared < fromParts.length + && shared < targetParts.length + && fromParts[shared] === targetParts[shared] + ) { + shared += 1; + } + + const up = new Array(Math.max(fromParts.length - shared, 0)).fill('..'); + const down = targetParts.slice(shared); + const joined = [...up, ...down].join('/'); + return joined.length > 0 ? joined : '.'; +} diff --git a/src/ui/file-preview/src/payload-utils.ts b/src/ui/file-preview/src/payload-utils.ts new file mode 100644 index 00000000..f2dc2f1b --- /dev/null +++ b/src/ui/file-preview/src/payload-utils.ts @@ -0,0 +1,112 @@ +import type { FilePreviewStructuredContent } from '../../../types.js'; +import { escapeHtml } from './components/highlighting.js'; +import { stripReadStatusLine } from './document-workspace.js'; +import type { RenderPayload } from './model.js'; + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function getFileExtensionForAnalytics(filePath: string): string { + const normalizedPath = filePath.trim().replace(/\\/g, '/'); + const fileName = normalizedPath.split('/').pop() ?? normalizedPath; + const dotIndex = fileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) { + return 'none'; + } + return fileName.slice(dotIndex + 1).toLowerCase(); +} + +export function isPreviewStructuredContent(value: unknown): value is FilePreviewStructuredContent { + if (!isObjectRecord(value)) { + return false; + } + + return ( + typeof value.fileName === 'string' && + typeof value.filePath === 'string' && + typeof value.fileType === 'string' + ); +} + +export function buildRenderPayload( + meta: FilePreviewStructuredContent, + text: string +): RenderPayload { + return { ...meta, content: text }; +} + +export function extractToolText(value: unknown): string | undefined { + if (!isObjectRecord(value)) { + return undefined; + } + const content = value.content; + if (!Array.isArray(content)) { + return undefined; + } + for (const item of content) { + if (!isObjectRecord(item)) { + continue; + } + if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { + return item.text; + } + } + return undefined; +} + +export function extractRenderPayload(value: unknown): RenderPayload | undefined { + if (!isObjectRecord(value)) { + return undefined; + } + const meta = isPreviewStructuredContent(value.structuredContent) + ? value.structuredContent + : isPreviewStructuredContent(value) + ? value + : null; + if (!meta) return undefined; + const text = extractToolText(value) ?? extractToolText(value.structuredContent) ?? ''; + return buildRenderPayload(meta, text); +} + +export function assertSuccessfulEditBlockResult(result: unknown): void { + if (!isObjectRecord(result)) { + throw new Error('edit_block did not return a valid result.'); + } + + if (result.isError === true) { + const message = extractToolText(result) ?? ''; + throw new Error(message || 'edit_block failed.'); + } + + // edit_block uses soft-failure returns (no isError flag) for cases the LLM + // is meant to recover from — "Search content not found", "Expected N + // occurrences but found M", fuzzy-match-too-close-to-ignore, etc. These + // look like success to a naive client. A real success always carries + // structuredContent (see src/tools/edit.ts — the write path attaches + // fileName/filePath/fileType); absence means the edit did not land. + // Throwing here routes soft failures through saveDocument's catch, which + // reloads disk, preserves the user's draft, and surfaces the server's + // message to the user. + if (!isObjectRecord(result.structuredContent)) { + const message = extractToolText(result) ?? ''; + throw new Error(message || 'edit_block did not confirm success.'); + } +} + +export function isLikelyUrl(filePath: string): boolean { + return /^https?:\/\//i.test(filePath); +} + +export function buildBreadcrumb(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const parts = normalized.split('/').filter(Boolean); + return parts.map((part) => escapeHtml(part)).join(' '); +} + +export function countContentLines(content: string): number { + const cleaned = stripReadStatusLine(content); + if (cleaned === '') return 0; + const lines = cleaned.split('\n'); + return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; +} diff --git a/src/ui/shared/tool-header.ts b/src/ui/shared/tool-header.ts deleted file mode 100644 index 3cb7d9e5..00000000 --- a/src/ui/shared/tool-header.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Reusable header renderer for MCP tool UIs. It provides a consistent title/description/status pattern so each app presents uniform top-of-page context. - */ -import { escapeHtml } from './escape-html.js'; - -export interface ToolHeaderConfig { - pillLabel: string; - pillClassName?: string; - title: string; - subtitle: string; - badges: string[]; - actionsHtml: string; -} - -export function renderToolHeader(config: ToolHeaderConfig): string { - return ` -
    -
    -
    - ${escapeHtml(config.pillLabel)} -
    - ${escapeHtml(config.title)} - ${escapeHtml(config.subtitle)} -
    -
    -
    - ${config.badges.map((badge) => `${escapeHtml(badge)}`).join('')} -
    -
    -
    - ${config.actionsHtml} -
    -
    - `; -} diff --git a/src/ui/shared/widget-state.ts b/src/ui/shared/widget-state.ts index 74d5b5b2..a8f46d7c 100644 --- a/src/ui/shared/widget-state.ts +++ b/src/ui/shared/widget-state.ts @@ -17,29 +17,129 @@ export interface WidgetStateStorage { write(state: T): void; } +const FALLBACK_WIDGET_STATE_KEY_PREFIX = 'desktop-commander:widget-state'; +const FALLBACK_WIDGET_INSTANCE_MARKER = '__dc_widget_id__:'; + +function createWidgetInstanceId(): string { + const cryptoObject = typeof globalThis.crypto === 'object' ? globalThis.crypto : undefined; + if (typeof cryptoObject?.randomUUID === 'function') { + return cryptoObject.randomUUID(); + } + + return `widget-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function readWidgetInstanceIdFromWindowName(windowName: string): string | undefined { + const markerIndex = windowName.indexOf(FALLBACK_WIDGET_INSTANCE_MARKER); + if (markerIndex === -1) { + return undefined; + } + + const encodedValue = windowName + .slice(markerIndex + FALLBACK_WIDGET_INSTANCE_MARKER.length) + .split('|', 1)[0]; + if (!encodedValue) { + return undefined; + } + + try { + return decodeURIComponent(encodedValue); + } catch { + return encodedValue; + } +} + +function getFallbackWidgetInstanceId(): string { + if (typeof window === 'undefined') { + return 'unknown-instance'; + } + + const currentWindowName = typeof window.name === 'string' ? window.name : ''; + const existingInstanceId = readWidgetInstanceIdFromWindowName(currentWindowName); + if (existingInstanceId) { + return existingInstanceId; + } + + const instanceId = createWidgetInstanceId(); + const marker = `${FALLBACK_WIDGET_INSTANCE_MARKER}${encodeURIComponent(instanceId)}`; + try { + window.name = currentWindowName ? `${currentWindowName}|${marker}` : marker; + } catch { + // Ignore window.name write failures and fall back to the in-memory id. + } + return instanceId; +} + +function getFallbackWidgetStateKey(): string { + if (typeof window === 'undefined') { + return `${FALLBACK_WIDGET_STATE_KEY_PREFIX}:unknown`; + } + + const appPath = window.location.pathname || 'unknown'; + const instanceId = getFallbackWidgetInstanceId(); + return `${FALLBACK_WIDGET_STATE_KEY_PREFIX}:${appPath}:${encodeURIComponent(instanceId)}`; +} + +function getSessionStorage(): Storage | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return window.sessionStorage; + } catch { + return undefined; + } +} + /** * Check if we're running in ChatGPT (has special widget state API) */ export function isChatGPT(): boolean { - return typeof window !== 'undefined' && + return typeof window !== 'undefined' && typeof (window as any).openai?.setWidgetState === 'function'; } /** * Create a widget state storage adapter. - * + * * On ChatGPT: Uses window.openai.widgetState for persistence - * On other hosts: Returns no-op adapter (state comes from ui/notifications/tool-result) + * On other hosts: Uses sessionStorage as a fallback so the preview can survive + * transient interruptions (page refresh on hosts that don't re-send tool_result, + * visibility/focus loss, etc.). + * The fallback cache key is scoped by app pathname and a per-frame widget id + * persisted in window.name, so different widgets in the same origin/session + * do not overwrite one another's cached state. */ export function createWidgetStateStorage( validator?: (state: unknown) => boolean ): WidgetStateStorage { - if (!isChatGPT()) { - // Other hosts don't have widget state persistence - return no-op + const storage = getSessionStorage(); + const storageKey = getFallbackWidgetStateKey(); return { - read: () => undefined, - write: () => {} + read(): T | undefined { + if (!storage) return undefined; + try { + const raw = storage.getItem(storageKey); + if (!raw) return undefined; + const parsed = JSON.parse(raw); + const payload = parsed?.payload; + if (payload === undefined) return undefined; + if (validator && !validator(payload)) return undefined; + return payload as T; + } catch { + return undefined; + } + }, + write(state: T): void { + if (!storage) return; + try { + storage.setItem(storageKey, JSON.stringify({ payload: state })); + } catch { + // Ignore storage failures + } + } }; } diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 42802871..afdfe7d8 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -12,7 +12,7 @@ --hljs-attr: var(--color-text-info, light-dark(#2563eb, #60a5fa)); --hljs-built-in: light-dark(#6366f1, #818cf8); --hljs-tag: var(--color-text-info, light-dark(#0ea5a8, #67e8f9)); - --content-height: min(82vh, 920px); + --content-height: 920px; --markdown-text: var(--text); --markdown-muted: var(--text-secondary); --inline-code-bg: var(--panel-subtle); @@ -21,6 +21,7 @@ --notice-bg: var(--panel-subtle); --notice-border: var(--border); --notice-text: var(--text-secondary); + --toc-rail-width: 320px; } /* ── Panel (Claude-style card) ── */ @@ -40,6 +41,45 @@ /* When the host hides the summary row (hideSummaryRow: true), it means the host provides its own outer frame/card. Strip inner chrome so only content remains. */ +html:has(.fullscreen) { + height: 100%; +} + +html:has(.fullscreen) body { + height: 100%; + display: flex; + flex-direction: column; +} + +html:has(.fullscreen) #app { + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen { + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen .panel { + margin-top: 0; + border: none; + border-radius: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen { + --content-height: 89vh; +} + +.tool-shell.fullscreen .panel-content-wrapper { + flex: 1; +} + .tool-shell.host-framed .compact-row { display: none; } .tool-shell.host-framed .panel { margin-top: 0; @@ -47,8 +87,30 @@ border-radius: 0; background: transparent; } -.tool-shell.host-framed .panel-topbar { display: none; } +.tool-shell.host-framed .panel-breadcrumb { display: none; } .tool-shell.host-framed .panel-footer { display: none; } +.tool-shell.host-framed .panel { position: relative; } +.tool-shell.host-framed .panel-topbar { + position: absolute; + top: 0; + right: 0; + z-index: 10; + width: auto; + opacity: 0; + transition: opacity 0.2s ease 1.5s; + pointer-events: none; + padding: 4px 8px; + gap: 6px; + background: var(--panel); + border-radius: 0 0 0 var(--border-radius-md, 6px); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} +.tool-shell.host-framed .panel:hover .panel-topbar, +.tool-shell.host-framed .panel:focus-within .panel-topbar { + opacity: 1; + pointer-events: auto; + transition: opacity 0.2s ease; +} .tool-shell.host-framed .panel-content-wrapper { max-height: none; overflow: hidden; } .tool-shell.host-framed .image-content { background: transparent; } @@ -61,16 +123,19 @@ gap: 12px; padding: 10px 16px; border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + overflow: visible; } .panel-breadcrumb { font-size: 13px; color: var(--text-secondary); + direction: rtl; + text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 1 1 0; - width: 0; + flex: 0 1 auto; + max-width: 50%; min-width: 0; } @@ -111,12 +176,49 @@ background: var(--panel-subtle); } +.panel-action--primary { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.panel-action--primary:hover { + background: color-mix(in srgb, var(--panel-subtle) 58%, var(--border) 42%); +} + .panel-action:disabled { opacity: 0.35; cursor: not-allowed; } .panel-action svg { flex-shrink: 0; opacity: 0.7; } + +.panel-action[title] { + position: relative; +} + +.panel-action[title]::after { + content: attr(title); + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--panel); + color: var(--text); + font-size: 11px; + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 10; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); +} + +.panel-action[title]:hover::after { + opacity: 1; +} .panel-action:hover svg { opacity: 1; } /* ── Footer ── */ @@ -174,6 +276,579 @@ .html-content .html-rendered-frame { min-height: 300px; height: var(--content-height); } .markdown-content { overflow: auto; padding: 0 4px 0 0; } +.markdown-content--workspace { + padding: 0; + overflow: visible; +} + +.markdown-workspace { + display: flex; + background: transparent; +} + +.markdown-workspace--edit { + min-height: min(70vh, 880px); +} + +.markdown-workspace--with-toc { + align-items: stretch; +} + +.markdown-workspace-main { + flex: 1 1 auto; + min-width: 0; + padding-left: 18px; +} + +.markdown-workspace-main--editor { + display: flex; + min-height: min(70vh, 880px); +} + +.markdown-editor-shell { + display: grid; + gap: 14px; + width: 100%; + padding: 20px; + position: relative; +} + +.markdown-editor-mode-toggle { + position: relative; + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + gap: 4px; + padding: 3px 4.5px 3px 3px; + border-radius: 999px; + background: color-mix(in srgb, var(--panel-subtle) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); +} + +.markdown-editor-mode-toggle-indicator { + position: absolute; + top: 3px; + bottom: 3px; + left: 3px; + width: calc(50% - 3.5px); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 220ms ease, background-color 220ms ease; + pointer-events: none; +} + +.markdown-editor-mode-toggle-indicator--markdown { + transform: translateX(calc(100% + 2.5px)); +} + +.markdown-editor-mode-option { + position: relative; + z-index: 1; + border: none; + background: transparent; + color: var(--text-secondary); + min-width: 0; + height: 32px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font: inherit; + font-size: 12px; + cursor: pointer; + transition: color 180ms ease, opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.markdown-editor-mode-option.is-active { + color: var(--text); + transform: translateY(-0.5px); +} + +.markdown-editor-mode-option span { + white-space: nowrap; + transition: opacity 180ms ease; +} + +.markdown-editor-mode-option:not(.is-active) { + opacity: 0.82; +} + +.markdown-editor-mode-option.is-active svg, +.markdown-editor-mode-option.is-active span { + opacity: 1; +} + +.markdown-editor-pane { + display: flex; + flex-direction: column; + min-height: min(70vh, 840px); + overflow: visible; + background: transparent; +} + +.markdown-editor-pane--raw { + max-width: none; +} + +.markdown-editor-pane--markdown { + max-width: none; +} + +.markdown-editor-context-menu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px; + background: #171717; + border: 1px solid color-mix(in srgb, var(--border) 34%, transparent); + border-radius: 10px; + position: absolute; + z-index: 20; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); +} + +.markdown-editor-context-menu[hidden] { + display: none; +} + +.markdown-format-button { + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + border-radius: 6px; + width: 32px; + height: 32px; + padding: 0; + font: inherit; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.markdown-format-button:hover { + background: rgba(255, 255, 255, 0.14); +} + +.markdown-format-button--swatch { + padding: 0; +} + +.markdown-format-size { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); +} + +.markdown-format-size select { + height: 32px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + padding: 0 8px; + font: inherit; + font-size: 12px; + outline: none; +} + +.markdown-format-sep { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.14); +} + +.markdown-link-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + z-index: 30; +} + +.markdown-link-modal[hidden] { + display: none; +} + +.markdown-link-modal-card { + width: min(420px, calc(100% - 32px)); + background: var(--panel); + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + border-radius: 14px; + padding: 14px; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.22); +} + +.markdown-link-mode-tabs { + display: inline-flex; + gap: 6px; + margin-bottom: 12px; +} + +.markdown-link-mode-tab { + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: transparent; + color: var(--text-secondary); + border-radius: 999px; + padding: 7px 12px; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.markdown-link-mode-tab.is-active { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-link-modal-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.markdown-link-modal-input { + width: 100%; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + font-size: 13px; + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 68%, transparent); +} + +.markdown-link-modal-select { + appearance: none; +} + +.markdown-link-results { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 160px; + overflow: auto; + margin: 10px 0 12px; +} + +.markdown-link-results-empty { + padding: 10px 12px; + font-size: 12px; + color: var(--text-secondary); + border: 1px dashed color-mix(in srgb, var(--border) 45%, transparent); + border-radius: 10px; +} + +.markdown-link-result { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + background: transparent; + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + text-align: left; +} + +.markdown-link-result.is-active { + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-link-result-title { + font-size: 13px; + color: var(--text); +} + +.markdown-link-result-path { + font-size: 11px; + color: var(--text-secondary); +} + +.markdown-link-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.markdown-link-modal-button { + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + background: transparent; + color: var(--text-secondary); + border-radius: 10px; + padding: 8px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.markdown-link-modal-button--primary { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-editor-copy-button { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: none; + border-radius: 8px; + padding: 5px 12px; + font-size: 13px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + white-space: nowrap; + transition: color 150ms ease, background 150ms ease; + line-height: 1.4; + font-family: inherit; +} + +.markdown-editor-copy-button span { + white-space: nowrap; +} + +.markdown-editor-copy-button:hover { + color: var(--text); +} + +.markdown-editor-copy-button[data-status="Copied!"] { + color: var(--text); +} + +.markdown-editor-copy-button[data-status="Copy failed"] { + color: #b91c1c; +} + +.panel-save-status { + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 400; + color: var(--muted); + background: none; + white-space: nowrap; + pointer-events: none; + line-height: 1; +} + +.panel-save-status--saving { + color: var(--text); +} + +.panel-save-status--saved { + color: var(--muted); + opacity: 0.7; +} + +.panel-save-status--pending { + color: var(--muted); +} + +.panel-topbar .markdown-editor-mode-toggle { + margin-left: 2px; +} + +.markdown-toc-shell { + flex: 0 0 var(--toc-rail-width); + width: var(--toc-rail-width); + border-right: 1px solid color-mix(in srgb, var(--border) 18%, transparent); + background: color-mix(in srgb, var(--panel-subtle) 90%, transparent); + padding: 18px 14px 18px 16px; + display: flex; + flex-direction: column; + align-self: flex-start; + position: sticky; + top: 12px; + max-height: calc(100vh - 120px); + overflow: visible; + transition: flex-basis 0.2s ease, width 0.2s ease, padding 0.2s ease; +} + +.markdown-toc-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + min-height: 20px; +} + +.markdown-toc-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.document-outline-toggle { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + width: 24px; + height: 24px; + padding: 0; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.5; + transition: opacity 0.15s, background 0.15s; +} +.document-outline-toggle:hover { + opacity: 1; + background: color-mix(in srgb, var(--border) 20%, transparent); +} + +.markdown-toc-collapsed { + flex: 0 0 44px !important; + width: 44px !important; + padding: 12px 6px !important; + overflow: hidden; + background: transparent !important; + border-right: none !important; +} +.markdown-toc-collapsed .markdown-toc-header { + justify-content: center; +} +.markdown-toc-collapsed .markdown-toc-title, +.markdown-toc-collapsed .markdown-toc-nav { + display: none; +} +.markdown-toc-collapsed .document-outline-toggle { + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 8px; + opacity: 0.7; +} + +.markdown-toc-nav { + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + min-height: 0; +} + +.markdown-toc-link { + border: none; + background: transparent; + color: var(--text-secondary); + text-align: left; + font: inherit; + font-size: 13px; + line-height: 1.35; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; +} + +.markdown-toc-link[data-level="2"] { padding-left: 16px; } +.markdown-toc-link[data-level="3"] { padding-left: 24px; } +.markdown-toc-link[data-level="4"] { padding-left: 32px; } +.markdown-toc-link[data-level="5"] { padding-left: 40px; } +.markdown-toc-link[data-level="6"] { padding-left: 48px; } + +.markdown-toc-link:hover { + color: var(--text); + background: color-mix(in srgb, var(--panel) 72%, transparent); +} + +.markdown-toc-link.is-active { + color: var(--text); + background: color-mix(in srgb, var(--panel) 82%, var(--border) 18%); +} + +.markdown-link-popover { + position: absolute; + z-index: 10; + display: flex; + align-items: center; + gap: 2px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} +.markdown-link-popover[hidden] { display: none; } +.markdown-link-popover-btn { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} +.markdown-link-popover-btn:hover { + color: var(--text); + background: color-mix(in srgb, var(--border) 30%, transparent); +} + +.markdown-editor-root { + flex: 1 1 auto; + min-height: 0; + padding: 0; + position: relative; +} + +.markdown-editor-surface { + flex: 1 1 auto; + min-height: min(70vh, 760px); + outline: none; +} + +.markdown-editor-surface--markdown { + max-width: none; + margin: 0; + padding: 22px 24px 28px; +} + +.markdown-editor-textarea { + width: 100%; + min-height: min(70vh, 760px); + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 14px; + line-height: 1.65; + padding: 18px 20px 24px; + resize: none; + overflow: hidden; + outline: none; +} + +.markdown-editor-textarea:focus { + box-shadow: none; +} + .image-content { display: flex; align-items: center; @@ -428,6 +1103,17 @@ border-radius: 10px; } +.markdown-doc a { + color: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--text-secondary) 45%, transparent); + text-underline-offset: 0.18em; +} + +.markdown-doc a:hover { + text-decoration-color: currentcolor; +} + /* ── HTML frame ── */ .html-rendered-frame { @@ -442,6 +1128,52 @@ /* ── Responsive ── */ @media (max-width: 720px) { + .markdown-workspace { + flex-direction: column; + } + .markdown-workspace--edit { + min-height: auto; + } + .markdown-workspace--with-toc { + background: transparent; + } + .markdown-editor-shell { + padding: 12px; + } + .markdown-toc-shell { + position: static; + top: auto; + max-height: none; + overflow: visible; + align-self: stretch; + width: 100% !important; + flex-basis: auto !important; + border-right: none; + border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + } + .markdown-toc-collapsed .markdown-toc-title, + .markdown-toc-collapsed .markdown-toc-nav { + display: flex; + } + .document-outline-toggle { + display: none; + } + .markdown-workspace-main { + padding-left: 0; + } + .markdown-toc-nav { + max-height: 40vh; + } + .markdown-editor-root { + padding: 0; + } + .markdown-editor-textarea { + min-height: 58vh; + padding: 14px; + } + .markdown-editor-surface--markdown { + padding: 16px; + } .markdown-doc { padding: 16px; } .markdown-doc h1 { font-size: 27px; } .markdown-doc h2 { font-size: 22px; } @@ -600,3 +1332,157 @@ .dir-row-folder:hover .dir-open-btn:hover { opacity: 1; } + +/* === File-changed-on-disk conflict modal === */ + +.md-conflict-modal[hidden] { + display: none; +} + +.md-conflict-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: grid; + place-items: center; + z-index: 50; + padding: 16px; + box-sizing: border-box; +} + +.md-conflict-card { + width: min(480px, 100%); + max-height: min(80vh, 560px); + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.md-conflict-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.md-conflict-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.md-conflict-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.md-conflict-close:hover { + background: var(--panel-subtle); + color: var(--text); +} + +.md-conflict-close svg { + width: 16px; + height: 16px; +} + +.md-conflict-body { + padding: 14px 16px; + overflow-y: auto; + font-size: 13px; + line-height: 1.5; + color: var(--text); +} + +.md-conflict-body p { + margin: 0 0 10px; +} + +.md-conflict-body p:last-of-type { + margin-bottom: 8px; +} + +.md-conflict-body ul { + margin: 0 0 4px; + padding-left: 20px; + color: var(--text-secondary); +} + +.md-conflict-body li { + margin-bottom: 4px; +} + +.md-conflict-filename { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-weight: 600; + word-break: break-all; +} + +.md-conflict-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px 14px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + background: color-mix(in srgb, var(--panel-subtle) 50%, transparent); +} + +.md-conflict-btn { + appearance: none; + font: inherit; + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + border-radius: 6px; + cursor: pointer; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.md-conflict-btn:hover { + background: var(--panel-subtle); +} + +.md-conflict-btn:focus-visible { + outline: 2px solid var(--accent, #4a8cff); + outline-offset: 2px; +} + +.md-conflict-btn--secondary { + color: var(--text-secondary); +} + +.md-conflict-btn--secondary:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--border) 80%, var(--text) 20%); +} + +.md-conflict-btn--primary { + background: var(--accent, #4a8cff); + color: #fff; + border-color: color-mix(in srgb, var(--accent, #4a8cff) 85%, black 15%); +} + +.md-conflict-btn--primary:hover { + background: color-mix(in srgb, var(--accent, #4a8cff) 88%, black 12%); +} diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index 1b7277ac..0806103d 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -50,7 +50,8 @@ body { body.dc-ready { max-height: none; - overflow: visible; + overflow: auto; + overflow: clip; } #app { diff --git a/src/ui/styles/components/tool-header.css b/src/ui/styles/components/tool-header.css deleted file mode 100644 index 3062270f..00000000 --- a/src/ui/styles/components/tool-header.css +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Reusable styles for the shared tool header component. It provides consistent spacing, alignment, and action presentation across apps. - */ -.toolbar { - display: flex; - gap: 10px; - align-items: center; - justify-content: space-between; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--panel); - margin-bottom: 0; - box-shadow: var(--shadow-sm); -} - -.meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - width: 100%; - flex: 1 1 auto; -} - -.meta-main { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.file-pill { - width: 52px; - height: 20px; - border-radius: 6px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.2px; - background: transparent; - color: var(--text); - border: 1px solid var(--border); - flex-shrink: 0; - text-align: center; -} - -.file-pill--md { - background: transparent; - color: var(--text); - border-color: var(--border); -} - -.file-pill--html { - background: transparent; - color: var(--text); - border-color: var(--border); -} - -.file-pill--json { - background: transparent; - color: var(--text); - border-color: var(--border); -} - -.file-pill--text { - background: transparent; - color: var(--muted); - border-color: var(--border); -} - -.meta-text { - display: flex; - flex-direction: row; - align-items: baseline; - min-width: 0; - gap: 8px; -} - -.filename { - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 460px; - font-size: 12px; -} - -.filepath { - color: var(--muted); - font-size: 10px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 360px; -} - -.meta-badges { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.badge { - border-radius: 999px; - border: 1px solid var(--border); - color: var(--muted); - padding: 2px 7px; - font-size: 10px; - background: var(--panel-subtle); -} - -.actions { - display: flex; - align-items: center; - gap: 6px; - padding-left: 8px; - border-left: 1px solid var(--border); - flex: 0 0 auto; -} - -.icon-button { - width: 30px; - height: 30px; - border: 1px solid var(--border); - background: var(--panel); - color: var(--muted); - border-radius: 9px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease; -} - -.icon-button svg { - width: 14px; - height: 14px; - fill: currentColor; -} - -.icon-button:hover { - background: var(--panel-muted); - border-color: var(--border); - box-shadow: var(--shadow-sm); -} - -.icon-button:active { - transform: translateY(1px); -} - -.icon-button:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.icon-button:focus-visible { - outline: 2px solid var(--border); - outline-offset: 2px; -} - -.icon-button--secondary { - border-color: var(--border); - background: var(--panel-subtle); - color: var(--text-secondary); -} - -@media (max-width: 720px) { - .shell { - padding: 12px; - } - - .toolbar { - gap: 8px; - } - - .meta { - gap: 8px; - } - - .actions { - padding-left: 6px; - } - - .meta-badges { - display: none; - } - - .meta-text { - flex-direction: column; - align-items: flex-start; - gap: 0; - } - - .filename, - .filepath { - max-width: 100%; - } -} diff --git a/test/test-edit-block-line-endings.js b/test/test-edit-block-line-endings.js index 55b1b92d..df1fc844 100644 --- a/test/test-edit-block-line-endings.js +++ b/test/test-edit-block-line-endings.js @@ -27,6 +27,30 @@ const CRLF_FILE = path.join(TEST_DIR, 'file_with_crlf.txt'); const CR_FILE = path.join(TEST_DIR, 'file_with_cr.txt'); const MIXED_FILE = path.join(TEST_DIR, 'file_with_mixed.txt'); +/** + * Assert that an edit_block result indicates success. + * + * Since the file-preview refactor (commit 8fd8f94), handleEditBlock's + * exact-match path no longer returns a "Successfully applied N edit(s)" + * text message — it returns a file preview (status line + snippet of + * the edited region) plus structuredContent carrying fileName/filePath/ + * fileType for the preview UI. + * + * We assert the new contract: text response + structuredContent present + * + preview status line shape, which together mean the edit was written + * and a preview was produced. Callers additionally verify the edit + * landed by reading the file back. + */ +function assertEditBlockSuccess(result, message) { + assert.strictEqual(result.content[0].type, 'text', `${message} (should return text content)`); + assert.ok(result.structuredContent, `${message} (should return structuredContent)`); + assert.ok(result.structuredContent.filePath, `${message} (structuredContent.filePath should be set)`); + assert.ok( + /\[Reading \d+ lines? from/.test(result.content[0].text), + `${message} (text should contain file-preview status line)` + ); +} + /** * Setup function to prepare the test environment */ @@ -102,11 +126,7 @@ async function testLFLineEndings() { }); // Check that the operation succeeded - assert.strictEqual(result.content[0].type, 'text', 'Result should be text'); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should report success with the LF edit' - ); + assertEditBlockSuccess(result, 'Should report success with the LF edit'); // Verify file still has LF line endings const rawContent = await readRawFile(LF_FILE); @@ -137,11 +157,7 @@ async function testCRLFLineEndings() { }); // Check that the operation succeeded - assert.strictEqual(result.content[0].type, 'text', 'Result should be text'); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should report success with the CRLF edit' - ); + assertEditBlockSuccess(result, 'Should report success with the CRLF edit'); // Verify file still has CRLF line endings const rawContent = await readRawFile(CRLF_FILE); @@ -156,11 +172,7 @@ async function testCRLFLineEndings() { }); // Check that the operation succeeded - assert.strictEqual(result.content[0].type, 'text', 'Result should be text'); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should report success with the multi-line CRLF edit' - ); + assertEditBlockSuccess(result, 'Should report success with the multi-line CRLF edit'); console.log('✓ CRLF line endings test passed'); } catch (error) { @@ -185,11 +197,7 @@ async function testCRLineEndings() { }); // Check that the operation succeeded - assert.strictEqual(result.content[0].type, 'text', 'Result should be text'); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should report success with the CR edit' - ); + assertEditBlockSuccess(result, 'Should report success with the CR edit'); // Verify file still has CR line endings const rawContent = await readRawFile(CR_FILE); @@ -220,11 +228,7 @@ async function testMixedLineEndings() { }); // Check that the operation succeeded - assert.strictEqual(result.content[0].type, 'text', 'Result should be text'); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should report success with the mixed line ending edit' - ); + assertEditBlockSuccess(result, 'Should report success with the mixed line ending edit'); // Verify file preserves mixed line endings const rawContent = await readRawFile(MIXED_FILE); @@ -258,10 +262,7 @@ async function testContextAwareReplacement() { expected_replacements: 1 }); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle multi-line replacement in CRLF file' - ); + assertEditBlockSuccess(result, 'Should handle multi-line replacement in CRLF file'); // Re-create LF file (it was modified in previous tests) const lfContent = `First line with LF @@ -279,10 +280,7 @@ Fifth line with LF`; expected_replacements: 1 }); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle multi-line replacement in LF file' - ); + assertEditBlockSuccess(result, 'Should handle multi-line replacement in LF file'); console.log('✓ Context-aware replacement test passed'); } catch (error) { @@ -323,10 +321,7 @@ async function testLargeFilePerformance() { }); const timeLF = Date.now() - startLF; - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle large LF file' - ); + assertEditBlockSuccess(result, 'Should handle large LF file'); // Test CRLF file const startCRLF = Date.now(); @@ -338,10 +333,7 @@ async function testLargeFilePerformance() { }); const timeCRLF = Date.now() - startCRLF; - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle large CRLF file' - ); + assertEditBlockSuccess(result, 'Should handle large CRLF file'); console.log(`✓ Performance test passed (LF: ${timeLF}ms, CRLF: ${timeCRLF}ms)`); } catch (error) { @@ -387,10 +379,7 @@ async function testEdgeCases() { expected_replacements: 1 }); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle single line file' - ); + assertEditBlockSuccess(result, 'Should handle single line file'); // Test file without trailing line ending result = await handleEditBlock({ @@ -400,10 +389,7 @@ async function testEdgeCases() { expected_replacements: 1 }); - assert.ok( - result.content[0].text.includes('Successfully applied 1 edit'), - 'Should handle file without trailing line ending' - ); + assertEditBlockSuccess(result, 'Should handle file without trailing line ending'); console.log('✓ Edge cases test passed'); } catch (error) { diff --git a/test/test-file-handlers.js b/test/test-file-handlers.js index 14cb429a..b1458dee 100644 --- a/test/test-file-handlers.js +++ b/test/test-file-handlers.js @@ -19,6 +19,7 @@ import assert from 'assert'; import { readFile, writeFile, getFileInfo } from '../dist/tools/filesystem.js'; import { getFileHandler } from '../dist/utils/files/factory.js'; import { handleReadFile } from '../dist/handlers/filesystem-handlers.js'; +import { handleEditBlock } from '../dist/handlers/edit-search-handlers.js'; // Get directory name const __filename = fileURLToPath(import.meta.url); @@ -345,6 +346,43 @@ async function testReadFilePreviewMetadata() { console.log('✓ read_file preview structured content contract works'); } +/** + * Test 10: Markdown exact-match save flow works through edit_block + */ +async function testMarkdownExactMatchSave() { + console.log('\n--- Test 10: markdown exact-match save flow ---'); + + const originalContent = '# Title\n\nOriginal paragraph.\n'; + const updatedContent = '# Title\n\nUpdated paragraph.\n'; + + await fs.writeFile(MD_FILE, originalContent); + + const result = await handleEditBlock({ + file_path: MD_FILE, + old_string: originalContent, + new_string: updatedContent, + expected_replacements: 1, + }); + + assert.ok(Array.isArray(result.content), 'edit_block result should include content array'); + // After the file-preview refactor (commit 8fd8f94), edit_block's exact-match + // path returns a file preview + structuredContent instead of a + // "Successfully applied N edit(s)" message. Verify the new contract here. + assert.strictEqual(result.content[0].type, 'text', 'edit_block result[0] should be text'); + assert.ok(result.structuredContent, 'edit_block should return structuredContent'); + assert.ok(result.structuredContent.filePath, 'edit_block structuredContent should include filePath'); + assert.match( + result.content[0].text, + /\[Reading \d+ lines? from/, + 'edit_block should return a file-preview status line' + ); + + const readBack = await fs.readFile(MD_FILE, 'utf8'); + assert.strictEqual(readBack, updatedContent, 'Markdown file should be rewritten with the updated content'); + + console.log('✓ markdown exact-match save flow works'); +} + /** * Run all tests */ @@ -360,6 +398,7 @@ async function runAllTests() { await testFileInfo(); await testWriteModes(); await testReadFilePreviewMetadata(); + await testMarkdownExactMatchSave(); console.log('\n✅ All file handler tests passed!'); } diff --git a/test/test-file-preview-directory-runtime.js b/test/test-file-preview-directory-runtime.js new file mode 100644 index 00000000..2d247688 --- /dev/null +++ b/test/test-file-preview-directory-runtime.js @@ -0,0 +1,50 @@ +import assert from 'assert'; +import { pathToFileURL } from 'url'; + +import { renderDirectoryBody } from '../dist/ui/file-preview/src/directory-controller.js'; + +async function testDirectoryBodyRendering() { + console.log('\n--- Test: directory preview rendering ---'); + + const listing = [ + 'Directory listing for /tmp/project', + '[DIR] docs', + '[FILE] docs/readme.md', + '[WARNING] docs: 8 items hidden (showing first 2 of 10 total)', + '[DENIED] secrets', + ].join('\n'); + + const result = renderDirectoryBody(listing, '/tmp/project'); + assert.strictEqual(result.notice, 'Directory listing for /tmp/project'); + assert.ok(result.html.includes('dir-tree'), 'Directory preview should render the tree shell'); + assert.ok(result.html.includes('dir-row-folder'), 'Directory preview should render folder rows'); + assert.ok(result.html.includes('dir-row-file'), 'Directory preview should render file rows'); + assert.ok(result.html.includes('dir-load-more'), 'Directory preview should render load-more warnings'); + assert.ok(result.html.includes('dir-name-denied'), 'Directory preview should render denied entries'); + + console.log('✓ directory preview renders folder, file, warning, and denied states'); +} + +export default async function runTests() { + try { + await testDirectoryBodyRendering(); + console.log('\n✅ File preview directory runtime tests passed!'); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('❌ Test failed:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + return false; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runTests().then((success) => { + process.exit(success ? 0 : 1); + }).catch((error) => { + console.error('❌ Unhandled error:', error); + process.exit(1); + }); +} diff --git a/test/test-markdown-preview.js b/test/test-markdown-preview.js new file mode 100644 index 00000000..5f48ebb0 --- /dev/null +++ b/test/test-markdown-preview.js @@ -0,0 +1,554 @@ +import assert from 'assert'; +import { pathToFileURL } from 'url'; + +import { renderMarkdown } from '../dist/ui/file-preview/src/components/markdown-renderer.js'; +import { resolveMarkdownLink, rewriteWikiLinks } from '../dist/ui/file-preview/src/markdown/linking.js'; +import { extractMarkdownOutline } from '../dist/ui/file-preview/src/markdown/outline.js'; +import { getRenderedMarkdownCopyText } from '../dist/ui/file-preview/src/markdown/preview.js'; +import { renderMarkdownEditorShell } from '../dist/ui/file-preview/src/markdown/editor.js'; +import { createMarkdownController } from '../dist/ui/file-preview/src/markdown/controller.js'; +import { createSlugTracker, slugifyMarkdownHeading } from '../dist/ui/file-preview/src/markdown/slugify.js'; +import { getDocumentFullscreenAvailability, shouldAutoLoadDocumentOnEnterFullscreen } from '../dist/ui/file-preview/src/document-workspace.js'; + +async function testSlugGeneration() { + console.log('\n--- Test 1: heading slug generation ---'); + + assert.strictEqual(slugifyMarkdownHeading(' Hello, World! '), 'hello-world'); + + const nextSlug = createSlugTracker(); + assert.strictEqual(nextSlug('Overview'), 'overview'); + assert.strictEqual(nextSlug('Overview'), 'overview-2'); + assert.strictEqual(nextSlug('Overview'), 'overview-3'); + + const collisionTracker = createSlugTracker(); + assert.strictEqual(collisionTracker('Foo'), 'foo'); + assert.strictEqual(collisionTracker('Foo-2'), 'foo-2'); + assert.strictEqual(collisionTracker('Foo'), 'foo-3'); + + console.log('✓ heading slugs are stable and unique'); +} + +async function testOutlineExtraction() { + console.log('\n--- Test 2: markdown outline extraction ---'); + + const source = [ + '# Title', + '', + '## Details', + '', + '```md', + '# Not a heading', + '```', + '', + '## Details', + '', + '### Linked [Section](#details)', + ].join('\n'); + + const outline = extractMarkdownOutline(source); + assert.deepStrictEqual( + outline.map((item) => ({ id: item.id, text: item.text, level: item.level })), + [ + { id: 'title', text: 'Title', level: 1 }, + { id: 'details', text: 'Details', level: 2 }, + { id: 'details-2', text: 'Details', level: 2 }, + { id: 'linked-section', text: 'Linked Section', level: 3 }, + ], + ); + + console.log('✓ outline extraction ignores fenced code and de-duplicates headings'); +} + +async function testLinkResolution() { + console.log('\n--- Test 3: markdown link resolution ---'); + + const currentPath = '/Users/tester/docs/start.md'; + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '#details'), { + kind: 'anchor', + href: '#details', + anchor: 'details', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, './guide.md#Install%20Now'), { + kind: 'file', + href: './guide.md#Install%20Now', + targetPath: '/Users/tester/docs/guide.md', + anchor: 'Install Now', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, './guide.md#100%'), { + kind: 'file', + href: './guide.md#100%', + targetPath: '/Users/tester/docs/guide.md', + anchor: '100%', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '/tmp/reference.md#Intro'), { + kind: 'file', + href: '/tmp/reference.md#Intro', + targetPath: '/tmp/reference.md', + anchor: 'Intro', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, 'https://example.com/docs'), { + kind: 'external', + href: 'https://example.com/docs', + url: 'https://example.com/docs', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '[[Meeting Notes#Action Items|Actions]]'), { + kind: 'file', + href: '[[Meeting Notes#Action Items|Actions]]', + targetPath: '/Users/tester/docs/Meeting Notes.md', + anchor: 'action-items', + }); + + assert.deepStrictEqual(resolveMarkdownLink('README.md', 'other.md'), { + kind: 'file', + href: 'other.md', + targetPath: 'other.md', + }); + + assert.deepStrictEqual(resolveMarkdownLink('/start.md', 'guide.md'), { + kind: 'file', + href: 'guide.md', + targetPath: '/guide.md', + }); + + assert.deepStrictEqual(resolveMarkdownLink('C:/start.md', 'guide.md'), { + kind: 'file', + href: 'guide.md', + targetPath: 'C:/guide.md', + }); + + console.log('✓ anchors, file links, absolute paths, external URLs, and wiki links resolve correctly'); +} + +async function testWikiRewriteAndRendering() { + console.log('\n--- Test 4: wiki link rewrite and rendering ---'); + + const rewritten = rewriteWikiLinks('See [[Meeting Notes#Action Items|Actions]] and `[[Code]]`.'); + assert.ok(rewritten.includes('[Actions](./Meeting%20Notes.md#action-items "mcp-wiki:'), 'Wiki links should rewrite to markdown links with round-trip metadata'); + assert.ok(rewritten.includes('`[[Code]]`'), 'Inline code should remain untouched'); + const multiTickRewrite = rewriteWikiLinks('Use ``[[Code]]`` and `code [[still-not-link]]` samples.'); + assert.ok(multiTickRewrite.includes('``[[Code]]``'), 'Multi-backtick inline code should remain untouched'); + assert.ok(multiTickRewrite.includes('`code [[still-not-link]]`'), 'Wiki links inside inline code should stay literal'); + + const fencedRewrite = rewriteWikiLinks([ + '````md', + '```', + '[[Inside Code]]', + '````', + '[[Outside Code]]', + ].join('\n')); + assert.ok(fencedRewrite.includes('[[Inside Code]]'), 'Long code fences should remain open until a matching-length close fence appears'); + assert.ok(fencedRewrite.includes('[Outside Code](./Outside%20Code.md "mcp-wiki:'), 'Wiki links outside closed fences should still rewrite'); + + const html = renderMarkdown([ + '# Title', + '## Details', + '## Details', + '', + 'Go to [[Meeting Notes#Action Items|Actions]].', + ].join('\n')); + + assert.ok(html.includes('id="title"'), 'Rendered markdown should include slugged heading ids'); + assert.ok(html.includes('id="details-2"'), 'Duplicate headings should receive unique ids'); + assert.ok(html.includes('href="./Meeting%20Notes.md#action-items"'), 'Rendered markdown should keep rewritten wiki links'); + assert.ok(html.includes('data-wiki-link="[[Meeting Notes#Action Items|Actions]]"'), 'Rendered markdown should preserve original wiki-link syntax for editing'); + + console.log('✓ markdown rendering uses preview heading ids and rewritten wiki links'); +} + +async function testFailedSaveResyncsEditBaseline() { + console.log('\n--- Test 11: failed saves resync the edit baseline from disk ---'); + + const payload = { + fileName: 'notes.md', + filePath: '/Users/tester/docs/notes.md', + fileType: 'markdown', + content: [ + 'alpha', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + 'line 7', + 'line 8', + 'beta', + 'omega', + '', + ].join('\n'), + }; + + let diskContent = payload.content; + let editCallCount = 0; + const storedPayloads = []; + const previousWindow = globalThis.window; + globalThis.window = { setTimeout: globalThis.setTimeout }; + + try { + const controller = createMarkdownController({ + callTool: async (name, args) => { + if (name === 'edit_block') { + editCallCount += 1; + if (editCallCount === 2) { + throw new Error('Simulated second edit_block failure'); + } + const { old_string: oldString, new_string: newString } = args; + if (typeof oldString !== 'string' || typeof newString !== 'string') { + throw new Error('Unexpected edit_block arguments'); + } + const nextContent = diskContent.replace(oldString, newString); + assert.notStrictEqual(nextContent, diskContent, 'Each edit block should match the current disk content'); + diskContent = nextContent; + // See Test 12 for why structuredContent is required in the mock. + return { + content: [{ type: 'text', text: 'Successfully applied 1 edit to notes.md' }], + structuredContent: { + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + }, + }; + } + + if (name === 'read_file') { + assert.deepStrictEqual(args, { path: payload.filePath }); + return { + structuredContent: { + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + }, + content: [{ type: 'text', text: diskContent }], + }; + } + + throw new Error(`Unexpected tool call: ${name}`); + }, + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'fullscreen', + getCurrentPayload: () => payload, + setExpanded: () => {}, + storePayloadOverride: (nextPayload) => { + storedPayloads.push(nextPayload); + }, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + + const state = controller.getState(payload); + state.draftContent = [ + 'alpha updated', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + 'line 7', + 'line 8', + 'beta updated', + 'omega', + '', + ].join('\n'); + state.dirty = true; + + await controller.saveDocument(); + + assert.strictEqual(diskContent, [ + 'alpha updated', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + 'line 7', + 'line 8', + 'beta', + 'omega', + '', + ].join('\n'), 'The simulated disk should keep the partial save'); + assert.strictEqual(state.sourceContent, diskContent, 'Source content should match the latest disk contents'); + assert.strictEqual(state.fullDocumentContent, diskContent, 'The full document baseline should match the latest disk contents'); + assert.strictEqual(state.draftContent, [ + 'alpha updated', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + 'line 7', + 'line 8', + 'beta updated', + 'omega', + '', + ].join('\n'), 'Local unsaved edits should stay in the editor'); + assert.strictEqual(state.dirty, true, 'The editor should stay dirty against the new disk baseline'); + assert.ok(state.error?.includes('changed on disk'), 'The error should explain that the file changed on disk'); + assert.deepStrictEqual(storedPayloads, [{ + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + content: diskContent, + }], 'The refreshed payload should be persisted for future renders'); + } finally { + globalThis.window = previousWindow; + } + + console.log('✓ failed saves resync the edit baseline without discarding local edits'); +} + +async function testSuccessfulSaveResetsUndoBaseline() { + console.log('\n--- Test 12: successful saves reset the undo baseline ---'); + + const payload = { + fileName: 'notes.md', + filePath: '/Users/tester/docs/notes.md', + fileType: 'markdown', + content: 'alpha\n', + }; + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { setTimeout: globalThis.setTimeout }; + globalThis.document = { + getElementById: () => null, + querySelector: () => null, + }; + + try { + const controller = createMarkdownController({ + callTool: async (name) => { + if (name !== 'edit_block') { + throw new Error(`Unexpected tool call: ${name}`); + } + + // Successful edit_block returns carry structuredContent with + // fileName/filePath/fileType (per commit 8fd8f94). The client's + // assertSuccessfulEditBlockResult now uses its presence as the + // success signal, so the mock has to match that contract. + return { + content: [{ type: 'text', text: 'Successfully applied 1 edit(s) to notes.md' }], + structuredContent: { + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + }, + }; + }, + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'inline', + getCurrentPayload: () => payload, + setExpanded: () => {}, + storePayloadOverride: () => {}, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + + const state = controller.getState(payload); + state.draftContent = 'beta\n'; + state.dirty = true; + + await controller.saveDocument(); + + assert.strictEqual(state.fullDocumentContent, 'beta\n', 'The saved document should become the new baseline'); + assert.strictEqual(state.draftContent, 'beta\n', 'Draft content should stay at the saved value'); + assert.strictEqual(state.dirty, false, 'The workspace should be clean after a successful save'); + assert.strictEqual(controller.isUndoAvailable(state), false, 'Undo should be disabled immediately after saving'); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + console.log('✓ successful saves clear undo state against the latest saved content'); +} + +async function testFullscreenWorkspaceHelpers() { + console.log('\n--- Test 6: fullscreen document helpers ---'); + + assert.deepStrictEqual( + getDocumentFullscreenAvailability({ + availableDisplayModes: ['inline', 'fullscreen'], + }), + { canFullscreen: true }, + ); + + assert.deepStrictEqual( + getDocumentFullscreenAvailability({ + availableDisplayModes: ['inline'], + }), + { canFullscreen: false, reason: 'Fullscreen editing is unavailable in this host.' }, + ); + + assert.strictEqual( + shouldAutoLoadDocumentOnEnterFullscreen('[Reading 10 lines from start (total: 20 lines, 10 remaining)]\n# Partial'), + true, + ); + assert.strictEqual(shouldAutoLoadDocumentOnEnterFullscreen('# Full'), false); + + console.log('✓ fullscreen entry support and partial-read auto-load are detected correctly'); +} + +async function testCopyFormatsAndEditorShell() { + console.log('\n--- Test 8: copy formats and editor shell ---'); + + const renderedCopy = getRenderedMarkdownCopyText('# Title\n\n- First\n- Second\n\n**Bold** text'); + assert.ok(renderedCopy.includes('Title'), 'Rendered copy should preserve heading text'); + assert.ok(renderedCopy.includes('- First'), 'Rendered copy should preserve list text'); + assert.ok(renderedCopy.includes('Bold text'), 'Rendered copy should flatten formatted inline text'); + + const markdownShell = renderMarkdownEditorShell({ + view: 'markdown', + }); + assert.ok(!markdownShell.includes('markdown-editor-mode-toggle'), 'Editor shell should not duplicate top-bar mode toggle'); + assert.ok(!markdownShell.includes('agents.md'), 'Editor shell should not duplicate file title header'); + assert.ok(!markdownShell.includes('copy-active-markdown'), 'Editor shell should not duplicate top-bar copy action'); + assert.ok(markdownShell.includes('markdown-editor-context-menu'), 'Markdown mode should include formatting context controls'); + assert.ok(markdownShell.includes('data-format="strike"'), 'Context menu should include strikethrough'); + assert.ok(markdownShell.includes('markdown-block-style'), 'Context menu should include semantic block-style dropdown'); + assert.ok(!markdownShell.includes('data-format="color-blue"'), 'Context menu should not include non-native color styling'); + assert.ok(!markdownShell.includes('data-format="highlight"'), 'Context menu should not include non-native highlight styling'); + assert.ok(markdownShell.includes('markdown-link-modal'), 'Markdown mode should include a link-entry modal'); + + const rawShell = renderMarkdownEditorShell({ + view: 'raw', + }); + assert.ok(!rawShell.includes('agents.md'), 'Raw mode should not duplicate the file title'); + assert.ok(!rawShell.includes('markdown-editor-context-menu'), 'Raw mode should not include markdown formatting context controls'); + assert.ok(!rawShell.includes('data-format="bold"'), 'Raw mode should not include formatting buttons'); + + console.log('✓ raw/rendered copy support and mode-specific editor shell are wired'); +} + +async function testPartialDocumentBecomesNewEditBaseline() { + console.log('\n--- Test 9: partial documents reset baseline after full load ---'); + + const partialPayload = { + fileName: 'notes.md', + filePath: '/Users/tester/docs/notes.md', + fileType: 'markdown', + content: '[Reading 1 lines from start (total: 3 lines, 2 remaining)]\n# Intro', + }; + const fullContent = '# Intro\n\n## Details\n\nBody'; + let currentPayload = partialPayload; + + const controller = createMarkdownController({ + callTool: async (name, args) => { + assert.strictEqual(name, 'read_file'); + assert.deepStrictEqual(args, { path: partialPayload.filePath, offset: 0, length: 3 }); + return { + structuredContent: { + fileName: partialPayload.fileName, + filePath: partialPayload.filePath, + fileType: partialPayload.fileType, + }, + content: [{ type: 'text', text: fullContent }], + }; + }, + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'fullscreen', + getCurrentPayload: () => currentPayload, + setExpanded: () => {}, + syncPayload: (payload) => { + currentPayload = payload ?? currentPayload; + }, + storePayloadOverride: () => {}, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + + controller.getState(partialPayload); + await controller.requestEditMode(partialPayload); + + const nextState = controller.getState(currentPayload); + assert.strictEqual(nextState.mode, 'edit'); + assert.strictEqual(nextState.fullDocumentContent, fullContent, 'The full document should replace the truncated edit baseline'); + assert.strictEqual(nextState.draftContent, fullContent, 'Draft content should start from the full document'); + assert.strictEqual(controller.isUndoAvailable(nextState), false, 'Undo should stay disabled until the user edits the full document'); + + console.log('✓ fullscreen edit mode replaces the partial baseline with the full document'); +} + +async function testRefreshDoesNotMisclassifyMarkdownContentAsDeletion() { + console.log('\n--- Test 10: refresh does not treat note text as a missing-file error ---'); + + const markdownText = '# Debug log\n\nError: file not found\nENOENT happened here'; + const payload = { + fileName: 'debug.md', + filePath: '/Users/tester/docs/debug.md', + fileType: 'markdown', + content: markdownText, + }; + + const previousDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + + try { + const controller = createMarkdownController({ + callTool: async () => ({ + structuredContent: { + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + }, + content: [{ type: 'text', text: markdownText }], + }), + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'inline', + getCurrentPayload: () => payload, + setExpanded: () => {}, + storePayloadOverride: () => {}, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + + const state = controller.getState(payload); + await controller.refreshFromDisk(payload); + + assert.strictEqual(state.fileDeleted, false, 'Normal markdown contents should not mark the file as deleted'); + } finally { + globalThis.document = previousDocument; + } + + console.log('✓ refresh only treats actual tool errors as missing files'); +} + +export default async function runTests() { + try { + await testSlugGeneration(); + await testOutlineExtraction(); + await testLinkResolution(); + await testWikiRewriteAndRendering(); + await testFullscreenWorkspaceHelpers(); + await testCopyFormatsAndEditorShell(); + await testPartialDocumentBecomesNewEditBaseline(); + await testRefreshDoesNotMisclassifyMarkdownContentAsDeletion(); + await testFailedSaveResyncsEditBaseline(); + await testSuccessfulSaveResetsUndoBaseline(); + console.log('\n✅ Markdown preview tests passed!'); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('❌ Test failed:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + return false; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runTests().then((success) => { + process.exit(success ? 0 : 1); + }).catch((error) => { + console.error('❌ Unhandled error:', error); + process.exit(1); + }); +} diff --git a/test/test-widget-state-runtime.js b/test/test-widget-state-runtime.js new file mode 100644 index 00000000..44ec4351 --- /dev/null +++ b/test/test-widget-state-runtime.js @@ -0,0 +1,93 @@ +import assert from 'assert'; +import { pathToFileURL } from 'url'; + +import { createWidgetStateStorage } from '../dist/ui/shared/widget-state.js'; + +function createMockSessionStorage() { + const data = new Map(); + return { + data, + getItem(key) { + return data.has(key) ? data.get(key) : null; + }, + setItem(key, value) { + data.set(key, value); + }, + removeItem(key) { + data.delete(key); + }, + }; +} + +function createMockWindow(pathname, sessionStorage, name = '') { + return { + location: { pathname }, + sessionStorage, + name, + }; +} + +async function testWidgetStateUsesPerFrameKeys() { + console.log('\n--- Test: widget state keeps same-origin iframes isolated ---'); + + const originalWindow = globalThis.window; + const sessionStorage = createMockSessionStorage(); + + try { + const firstWindow = createMockWindow('/ui/file-preview/index.html', sessionStorage); + globalThis.window = firstWindow; + const firstStorage = createWidgetStateStorage((value) => typeof value === 'string'); + firstStorage.write('first payload'); + + assert.ok(firstWindow.name.includes('__dc_widget_id__:'), 'The first frame should persist its widget id in window.name'); + assert.strictEqual(firstStorage.read(), 'first payload', 'The first frame should read back its own cached payload'); + + const secondWindow = createMockWindow('/ui/file-preview/index.html', sessionStorage); + globalThis.window = secondWindow; + const secondStorage = createWidgetStateStorage((value) => typeof value === 'string'); + secondStorage.write('second payload'); + + assert.ok(secondWindow.name.includes('__dc_widget_id__:'), 'The second frame should persist its widget id in window.name'); + assert.notStrictEqual(secondWindow.name, firstWindow.name, 'Visible frames should get distinct widget ids'); + assert.strictEqual(secondStorage.read(), 'second payload', 'The second frame should read back its own cached payload'); + assert.strictEqual(sessionStorage.data.size, 2, 'Two same-origin frames should write to separate cache keys'); + + const refreshedFirstWindow = createMockWindow('/ui/file-preview/index.html', sessionStorage, firstWindow.name); + globalThis.window = refreshedFirstWindow; + const refreshedFirstStorage = createWidgetStateStorage((value) => typeof value === 'string'); + assert.strictEqual(refreshedFirstStorage.read(), 'first payload', 'Reloading the first frame should preserve its cache slot'); + + const refreshedSecondWindow = createMockWindow('/ui/file-preview/index.html', sessionStorage, secondWindow.name); + globalThis.window = refreshedSecondWindow; + const refreshedSecondStorage = createWidgetStateStorage((value) => typeof value === 'string'); + assert.strictEqual(refreshedSecondStorage.read(), 'second payload', 'Reloading the second frame should preserve its own cache slot'); + } finally { + globalThis.window = originalWindow; + } + + console.log('✓ widget state keeps same-origin iframes isolated across refresh'); +} + +export default async function runTests() { + try { + await testWidgetStateUsesPerFrameKeys(); + console.log('\n✅ Widget state runtime tests passed!'); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('❌ Test failed:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + return false; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runTests().then((success) => { + process.exit(success ? 0 : 1); + }).catch((error) => { + console.error('❌ Unhandled error:', error); + process.exit(1); + }); +}