diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c6c895..67fb487 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Package Electron App (Windows Only) +name: Build and Package Electron App (Windows & Linux) on: push: @@ -67,3 +67,59 @@ jobs: dist/*.zip retention-days: 3 if-no-files-found: error + + build-linux: + name: Build for Linux + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Run Tests + run: npm test + + - name: Build Frontend Assets + run: npm run build --if-present + + # Always build without publishing — release step handles upload separately + - name: Package Electron App + run: npm run package -- --linux --publish never + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # On version tag → create a GitHub Release and attach the built installers + # Tags with -beta / -alpha / -rc are automatically marked as pre-release + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/*.AppImage + dist/*.deb + draft: false + prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-rc') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # On branch / PR builds → upload as a temporary artifact (expires in 3 days) + - name: Upload Artifacts (non-release builds) + if: "!startsWith(github.ref, 'refs/tags/')" + uses: actions/upload-artifact@v4 + with: + name: electron-linux-build + path: | + dist/*.AppImage + dist/*.deb + retention-days: 3 + if-no-files-found: error diff --git a/AI_SETUP.md b/AI_SETUP.md new file mode 100644 index 0000000..b5b583c --- /dev/null +++ b/AI_SETUP.md @@ -0,0 +1,61 @@ +# AI Configuration & Model Setup Guide + +MoilStack .md uses the standard OpenAI Chat Completions API format for its AI capabilities. This guide provides detailed instructions on configuring both cloud infrastructure and local offline models[cite: 1]. + +--- + +## Cloud API Providers + +To connect a cloud provider, navigate to **Settings** (⚙ gear icon) → **AI Models** → **Add Model**, choose a provider type, and input your credentials[cite: 1]. + +| Provider | Base URL | Free Tier Details | +|---|---|---| +| [Groq](https://console.groq.com) | `https://api.groq.com/openai/v1` | ✅ No credit card required[cite: 1] | +| [Google Gemini](https://aistudio.google.com/app/apikey) | `https://generativelanguage.googleapis.com/v1beta/openai/` | ✅ Free tier available[cite: 1] | +| [OpenRouter](https://openrouter.ai/keys) | `https://openrouter.ai/api/v1` | ✅ Free models available (append `:free`)[cite: 1] | +| [Mistral](https://console.mistral.ai) | `https://api.mistral.ai/v1` | ✅ Free tier available[cite: 1] | +| [Together AI](https://api.together.ai) | `https://api.together.xyz/v1` | ✅ $1 credit on signup[cite: 1] | +| [OpenAI](https://platform.openai.com/api-keys) | `https://api.openai.com/v1` | Paid tier only[cite: 1] | + +--- + +## Local Ollama Setup (Fully Private) + +For maximum data privacy, you can run large language models completely local to your machine with zero data leakage[cite: 1]. + +1. Download and install the core framework from [ollama.com/download](https://ollama.com/download)[cite: 1]. +2. Open your local terminal window and pull your preferred model[cite: 1]. We highly recommend running `ollama pull qwen2.5:7b` or `ollama pull llama3.2`[cite: 1]. +3. Inside MoilStack .md, open the Settings menu, add a new model with the type set to **Ollama**, and click **Detect** to auto-discover your active local engines[cite: 1]. + +> *Note: Running local models under 7B parameters may yield less reliable results when processing strict inline document edits[cite: 1].* + +--- + +## Recommended Models + +| Model Name | Integration Provider | Editing Accuracy | Speed Metrics | +|---|---|---|---| +| `llama-3.3-70b-versatile` | Groq (Free) | ⭐⭐⭐⭐⭐ | Ultra Fast[cite: 1] | +| `gemini-2.0-flash` | Google (Free) | ⭐⭐⭐⭐⭐ | Exceptionally Fast[cite: 1] | +| `gpt-4o-mini` | OpenAI (Paid) | ⭐⭐⭐⭐⭐ | Fast[cite: 1] | +| `qwen2.5:7b` | Ollama (Local) | ⭐⭐⭐⭐ | Medium[cite: 1] | +| `llama3.2` | Ollama (Local) | ⭐⭐⭐ | Fast[cite: 1] | + +--- + +## Core Provider Compatibility + +| Provider Integration | Support Status | Architecture Notes | +|---|---|---| +| Groq | ✅ Fully Supported | Standard OpenAI format integration with free options[cite: 1]. | +| OpenAI | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| Together AI | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| Mistral AI | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| OpenRouter | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| Google Gemini | ✅ Fully Supported | Accessible via standard OpenAI-compatible endpoints[cite: 1]. | +| Cerebras | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| Perplexity | ✅ Fully Supported | Native standard compatibility[cite: 1]. | +| Azure OpenAI | ⚠️ Partial Support | Utilizes a unique deployment URL layout rather than global endpoints[cite: 1]. | +| Anthropic Claude | ❌ Not Supported | Relies on a non-standard API structure that is not yet supported[cite: 1]. | +| AWS Bedrock | ❌ Not Supported | Demands AWS SigV4 request signatures which are not yet supported[cite: 1]. | +| Ollama | ✅ Fully Supported | Integrated via dedicated Ollama NDJSON streaming endpoints[cite: 1]. | \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6ca45..3bc3a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable new features and critical fixes for MoilStack .md. --- +## [1.1.0] - 2026-07-18 + +### Added +- **Copy / Paste in the editor context menu** — added as a new group at the top, above the formatting options, with `Ctrl C` / `Ctrl V` shown as hints +- **Ctrl+Shift+N — New File (Explorer)** — creates a file on disk in the active Explorer folder (prompting to open a folder first if none is active), distinct from Ctrl+N's in-memory untitled buffer; also added to the hamburger menu +- **File tagging** — right-click → "Add Tags…" opens a small modal to add/edit a file's tags, stored in its YAML frontmatter (`tags: [work, project]`); pre-fills with existing tags and preserves cursor position in the document body when saved + - Tags are shown as `#tag` pills next to filenames in Root folder only Explorer mode + - Search by tag with `#tag` or `tag:name` in the header search box — works across Multi-level, Root-only, and Custom (Recents) explorer modes, matching only real frontmatter tags rather than plain text + - Settings → File Explorer now explains the frontmatter format and search syntax directly in the Explorer Mode description +- **Quick jump to Explorer settings** — clicking the "Explorer" label in the sidebar header (now shown with a small gear icon) opens Settings, switches to the Explorer tab, and scrolls to/highlights the Explorer Mode row +- **Remove recent folders** — a hover-revealed × button now lets you remove a folder from the recents list without switching to it first, in both the Welcome screen's Recents list and the header folder-path dropdown + +### Changed +- **Editor context menu decluttered** — removed Bullet List and Numbered List (they only ever applied to the first level and behaved inconsistently on nested lists) and Divider/horizontal-rule; users can still type Markdown list/`---` syntax directly +- **File tags are frontmatter-only** — dropped the inline `#hashtag`-in-body fallback for tag detection (it produced false positives from URLs, anchors, and code); a file's tags now only come from an explicit `tags:` field in YAML frontmatter +- **Image toolbar/context-menu action** — selected text now becomes the image's alt text (`![alt](url)`), matching how the Link action already worked, instead of incorrectly becoming the image path + +### Fixed +- **Ctrl+Z not undoing context-menu formatting actions** — Bold, Italic, Strikethrough, headings, lists, blockquote, code block, and table insert now push an undo snapshot before editing, so Ctrl+Z correctly reverts them (previously these used a text-insertion method that the browser's native undo history didn't track) +- **Long tag lists squeezing the filename out of view** — in Root folder only Explorer mode, a file with several tags could shrink its filename down to nothing; the filename now keeps a minimum width and the tag list clips instead +- **"Untitled (unsaved)" row disappearing after Save As** — the Recent Files sidebar now always shows an "Untitled (unsaved)" row, so there's a one-click way back to a blank document after saving instead of having to press Ctrl+N again + +--- + ## [1.0.1] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 188d0b6..49a5eb5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ -# MoilStack .md +# MoilStack .md (markdown) — Privacy-First Markdown AI Editor & Viewer [![Version](https://img.shields.io/github/v/release/moilstack/moilstack-md?label=version&include_prereleases)](https://github.com/moilstack/moilstack-md/releases) -![Platform](https://img.shields.io/badge/platform-Windows-blue) +![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS%20(soon)-blue) ![License](https://img.shields.io/badge/license-MIT-green) -A desktop Markdown editor with an integrated AI assistant, built with Electron. -Write and edit Markdown files with syntax highlighting, preview, and AI-powered editing — all running locally on your machine. +An open-source desktop **Markdown AI editor** and standalone markdown viewer built with Electron. Write, edit, and view Markdown files with syntax highlighting, live split-pane preview, and an integrated local AI assistant — all running privately on your machine. + ---- ![MoilStack .md](assets/screenshot-editor.png) @@ -18,25 +17,26 @@ Pre-built installers are available on the [Releases page](https://github.com/moi | Platform | Format | |---|---| | Windows | NSIS installer, portable ZIP | - ---- +| Linux | AppImage, DEB | +| macOS | DMG, ZIP (coming soon) | ## Features - **Dual-pane editor** — syntax-highlighted editor with Markdown preview (`Ctrl+\`` to toggle) -- **File explorer** — browse, create, rename, and open `.md` files from a folder +- **File explorer** — browse, create, rename, and open `.md` files from a folder, with Multi-level, Root-only, and Custom (no-folder) sidebar modes +- **Global search** — find filenames and in-file content across the open folder (`Ctrl+Shift+F`), with tag search via `#tag` or `tag:name` - **AI Assistant** — ask the AI to edit your document, answer questions, or improve your writing - **Smart AI editing** — document edits are applied silently and instantly; informational answers stream as chat - **Undo AI edits** — every AI document change is reversible with the Undo button or `Ctrl+Z` - **Visual table builder** — insert Markdown tables with a point-and-click grid editor -- **File labels** — colour-tag files in the explorer for quick navigation -- **File backups** — automatic `.markflow/backups/` snapshots before every AI edit +- **File labels & tags** — colour-tag files in the explorer for quick navigation, or add searchable tags stored in YAML frontmatter +- **File backups** — automatic snapshots to the app's user data folder before every AI edit +- **File trash** — delete files to the OS Recycle Bin from the context menu - **Multi-model support** — connect any OpenAI-compatible API (Groq, OpenAI, Mistral, Together AI) or run Ollama locally - **Export to PDF** — one-click export via native save dialog - **Dark / light theme** — persisted across sessions - **Configurable editor** — font size and font family settings ---- ## Screens ![AI assistant editing](assets/screenshot-preview.png) ![AI assistant editing](assets/screenshot-ai-edit.png) @@ -73,83 +73,32 @@ Output goes to the `dist/` folder. Targets: NSIS/ZIP (Windows), DMG/ZIP (macOS), ## AI Assistant Setup -Open **Settings** (⚙ gear icon) → **AI Models** → **Add Model** and choose a provider type. - -### Cloud API - -Any OpenAI-compatible provider works. Enter the Base URL, your API key, and a model name, then save. - -| Provider | Base URL | Free? | -|---|---|---| -| [Groq](https://console.groq.com) | `https://api.groq.com/openai/v1` | ✅ No credit card | -| [Google Gemini](https://aistudio.google.com/app/apikey) | `https://generativelanguage.googleapis.com/v1beta/openai/` | ✅ Free tier | -| [OpenRouter](https://openrouter.ai/keys) | `https://openrouter.ai/api/v1` | ✅ Free models (append `:free`) | -| [Mistral](https://console.mistral.ai) | `https://api.mistral.ai/v1` | ✅ Free tier | -| [Together AI](https://api.together.ai) | `https://api.together.xyz/v1` | ✅ $1 credit on signup | -| [OpenAI](https://platform.openai.com/api-keys) | `https://api.openai.com/v1` | Paid | - -### Ollama (local, fully private) - -1. Install from [ollama.com/download](https://ollama.com/download) -2. Pull a model: `ollama pull qwen2.5:7b` (recommended) or `ollama pull llama3.2` -3. In Settings, add a model with type **Ollama** and click **Detect** to find running models +MoilStack .md relies on the standard OpenAI Chat Completions API architectural design, allowing you to instantly deploy powerful cloud models or execute complex workflows completely offline. -> Models below 7B parameters may not follow the document editing format reliably. +### Flexible Deployment Options +* **Fully Offline & Private:** Protect sensitive information by running local text operations straight on your machine through **Ollama**. +* **High-Speed Cloud API Infrastructure:** Connect native accounts from **Google Gemini**, **Groq**, **OpenAI**, **Mistral**, or **Together AI**. -### Recommended models +For definitive step-by-step setup guides, free tier account endpoint links, local model terminal scripts, and detailed performance matrices, see our dedicated [AI Configuration & Model Setup Guide](AI_SETUP.md). -| Model | Provider | Doc Editing | Speed | -|---|---|---|---| -| `llama-3.3-70b-versatile` | Groq (free) | ⭐⭐⭐⭐⭐ | Fast | -| `gemini-2.0-flash` | Google (free) | ⭐⭐⭐⭐⭐ | Very fast | -| `gpt-4o-mini` | OpenAI (paid) | ⭐⭐⭐⭐⭐ | Fast | -| `qwen2.5:7b` | Ollama | ⭐⭐⭐⭐ | Medium | -| `llama3.2` | Ollama | ⭐⭐⭐ | Fast | - ---- ## Using the AI Assistant -### Chat basics - -- Type a prompt in the chat panel on the right and press **Enter** (or click the send button) -- Use **Alt+Enter** to insert a newline in your prompt +MoilStack .md acts as an interactive markdown AI editor, allowing you to seamlessly communicate text changes directly to your local workspace. -### Document editing +### Document Editing & Refinement +Simply ask the AI assistant to modify your active file text: +* "Fix the grammar and layout flow in this document" +* "Add a clean summary section right at the top" +* "Convert this raw text paragraph into a clear bulleted list" -Ask the AI to modify your document: +When the AI assistant processes an edit, changes are applied silently and instantly into the editor pane. A structural summary of the modifications appears inside the chat window. -``` -Fix the grammar in this document -Add a summary section at the top -Convert this to use bullet points -Make the introduction more concise -``` +### Safety & Version Controls +* **Instant Undo:** Every single document modification made by the AI can be instantly reversed using the UI Undo button (↺) or by pressing `Ctrl+Z`. +* **Automatic Snapshots:** For absolute safety, MoilStack .md saves automatic file backups to the app's user data directory (not your workspace folder) before any AI processing occurs. +* **Scoped Selections:** Highlight specific sentences or code lines inside the editor pane before typing a prompt to limit the AI assistant's scope exclusively to that text selection. -When the AI makes a document edit: -- Changes are **applied silently and instantly** — no streaming wall of text -- A summary of what changed appears in the chat bubble -- Use the **Undo** button (↺) on the bubble to revert, or press `Ctrl+Z` -- Use the **Re-apply** button (↻) to reapply the last undone edit -- A backup is saved to `.markflow/backups/` before every AI edit - -### Informational questions - -Ask anything about writing, Markdown, or your document: - -``` -What's the difference between a blockquote and a code block? -How do I create a table in Markdown? -Summarise what this document is about -``` - -Answers stream directly into the chat. - -### Line selection - -Select lines in the editor before sending a prompt to scope the AI's edit to just those lines. - ---- ## Keyboard Shortcuts @@ -159,86 +108,27 @@ Select lines in the editor before sending a prompt to scope the AI's edit to jus | `Ctrl+Z` | Undo (AI edits first, then native undo) | | `Ctrl+\`` | Toggle Edit / Preview mode | | `Ctrl+O` | Open folder picker | -| `Ctrl+N` | New file in current folder | +| `Ctrl+N` | New untitled file (in-memory) | +| `Ctrl+Shift+N` | New file on disk in Explorer's active folder | | `Ctrl+F` | Find & replace | +| `Ctrl+Shift+F` | Global search (filenames & content) | | `Enter` | Send chat message | | `Alt+Enter` | New line in chat input | | `Escape` | Close any open modal or dropdown | ---- - ## File Backups -Every time the AI edits your document, MoilStack .md saves a backup to: +Every time the AI edits your document, MoilStack .md saves a backup to the app's user data directory, keyed to your file's parent folder — not inside your workspace: ``` -/.markflow/backups/ +/backups/-/ ``` -Files are named `_.md` and the last **10 backups per file** are kept automatically. Use these to recover from any unwanted AI changes. - ---- - -## Provider Compatibility - -MoilStack .md uses the **OpenAI Chat Completions API format** for all API-type models. Any provider that offers an OpenAI-compatible endpoint works out of the box. - -| Provider | Compatible? | Notes | -|---|---|---| -| Groq | ✅ | Fully compatible, free tier | -| OpenAI | ✅ | Fully compatible | -| Together AI | ✅ | Fully compatible | -| Mistral AI | ✅ | Fully compatible | -| OpenRouter | ✅ | Fully compatible | -| Google Gemini | ✅ | Via OpenAI-compatible endpoint | -| Cerebras | ✅ | Fully compatible | -| Perplexity | ✅ | Fully compatible | -| Azure OpenAI | ⚠️ | Different URL structure — use the deployment-specific endpoint | -| Anthropic Claude | ❌ | Different API format — not yet supported | -| AWS Bedrock | ❌ | Requires AWS SigV4 signing — not yet supported | -| Ollama | ✅ | Via built-in Ollama type (NDJSON streaming) | - ---- +On Windows this is typically under `%APPDATA%`, on macOS under `~/Library/Application Support`, and on Linux under `~/.config`. -## Project Structure +Files are named `_.md` and the last **10 backups per file** are kept automatically. Use these to recover from any unwanted AI changes. -``` -moilstack-md/ -├── src/ -│ ├── main/ -│ │ ├── index.js # Electron main process, window management -│ │ └── ipc.js # IPC handlers (file I/O, AI requests, config) -│ ├── preload/ -│ │ └── index.js # Context bridge (safe API exposure to renderer) -│ ├── assets/ -│ │ ├── icon.png # App icon -│ │ └── file-icon-md.svg # .md file association icon -│ └── renderer/ -│ ├── index.html # App shell -│ ├── index.js # Main renderer entry point -│ ├── styles.css # All styles -│ ├── aiConfig.js # AI model settings UI -│ ├── aiService.js # AI request dispatcher -│ ├── chatPanel.js # AI chat panel -│ ├── editorCore.js # Editor logic, undo, ruler -│ ├── fileTreeManager.js # File explorer tree rendering -│ ├── fileOperations.js # Create, rename, delete files/folders -│ ├── markdownRenderer.js # Markdown → HTML preview -│ ├── saveManager.js # Save / dirty-state tracking -│ ├── storageManager.js # localStorage / IPC persistence -│ ├── tableBuilder.js # Visual table builder modal -│ ├── exportService.js # PDF export -│ └── themeManager.js # Dark / light theme -├── build/ -│ ├── installer.nsh # NSIS installer customisation -│ └── make-file-icon.js # Icon generation script -├── assets/ # Screenshots and marketing assets (add here) -├── CHANGELOG.md -├── package.json -└── README.md -``` ---- ## Contributing diff --git a/package-lock.json b/package-lock.json index 24e0352..ad8f67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moilstack-md", - "version": "1.0.0-beta.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moilstack-md", - "version": "1.0.0-beta.1", + "version": "1.1.0", "license": "MIT", "dependencies": { "katex": "^0.17.0" @@ -17,6 +17,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "nodemon": "^3.1.14", + "patch-package": "^8.0.1", "png-to-ico": "^2.1.8", "sharp": "^0.33.5" } @@ -1894,6 +1895,13 @@ "node": ">=14.6" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -2683,6 +2691,25 @@ "source-map": "^0.6.0" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2697,6 +2724,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3235,6 +3279,24 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3890,6 +3952,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4185,6 +4257,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4476,6 +4561,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4549,6 +4650,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -5387,6 +5501,33 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5423,6 +5564,16 @@ "node": ">= 10.0.0" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/katex": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", @@ -5448,6 +5599,16 @@ "node": ">= 12" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5945,6 +6106,16 @@ "dev": true, "license": "MIT" }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5971,6 +6142,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6065,6 +6253,46 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6632,6 +6860,24 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -7538,6 +7784,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index f6ebb32..fc5cef1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moilstack-md", - "version": "1.0.1", + "version": "1.1.0", "description": "Markdown editor with AI assistance by MoilStack", "main": "src/main/index.js", "scripts": { @@ -8,7 +8,8 @@ "test": "jest", "make-app-icon": "node build/make-app-icon.js", "make-file-icon": "node build/make-file-icon.js", - "package": "electron-builder" + "package": "electron-builder", + "postinstall": "patch-package" }, "build": { "appId": "com.moilstack.markdown", @@ -23,11 +24,7 @@ }, "fileAssociations": [ { - "ext": [ - "md", - "markdown", - "txt" - ], + "ext": "md", "name": "Markdown File", "description": "Markdown Document", "icon": "src/assets/file-icon-md", @@ -40,7 +37,8 @@ "nsis", "zip" ], - "icon": "src/assets/icon.ico" + "icon": "src/assets/icon.ico", + "artifactName": "MoilStack.md.${ext}" }, "nsis": { "oneClick": false, @@ -70,7 +68,8 @@ "target": [ "AppImage", "deb" - ] + ], + "maintainer": "MoilStack " } }, "keywords": [ @@ -94,6 +93,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "nodemon": "^3.1.14", + "patch-package": "^8.0.1", "png-to-ico": "^2.1.8", "sharp": "^0.33.5" }, diff --git a/patches/app-builder-lib+24.13.3.patch b/patches/app-builder-lib+24.13.3.patch new file mode 100644 index 0000000..58ba704 --- /dev/null +++ b/patches/app-builder-lib+24.13.3.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/app-builder-lib/out/targets/AppxTarget.js b/node_modules/app-builder-lib/out/targets/AppxTarget.js +index 1bb0b5f..66f36b2 100644 +--- a/node_modules/app-builder-lib/out/targets/AppxTarget.js ++++ b/node_modules/app-builder-lib/out/targets/AppxTarget.js +@@ -233,7 +233,7 @@ class AppXTarget extends core_1.Target { + for (const protocol of uriSchemes) { + for (const scheme of (0, builder_util_1.asArray)(protocol.schemes)) { + extensions += ` +- ++ + + ${protocol.name} + +@@ -243,7 +243,7 @@ class AppXTarget extends core_1.Target { + for (const fileAssociation of fileAssociations) { + for (const ext of (0, builder_util_1.asArray)(fileAssociation.ext)) { + extensions += ` +- ++ + + + .${ext} diff --git a/src/main/index.js b/src/main/index.js index cbb2cd1..723e14c 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,6 +2,7 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, protocol, net } = require('el const path = require('path') const fs = require('fs') const { registerIpcHandlers, updateJumpList } = require('./ipc') +const { startReleaseCheck } = require('./releaseCheck') // ── Window state persistence ─────────────────────────────────────────────── // Stored in /window-state.json so it survives app restarts. @@ -229,6 +230,8 @@ if (!gotLock) { // First window — pass any file/folder from the CLI args createWindow(getArgsFromArgv(process.argv)) + startReleaseCheck() + app.on('activate', () => { // macOS: re-create a window when dock icon is clicked and no windows exist if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/ipc.js b/src/main/ipc.js index 2c9bd97..2e1aced 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -139,12 +139,22 @@ function fetchJson(urlStr) { }) } +/** + * Strip a leading UTF-8 BOM (U+FEFF), if present. + * fs.readFile(..., 'utf8') does not strip it, and several common Windows + * editors (Notepad, some Git configs) write one by default — without this, + * `content.startsWith('---')` silently fails for otherwise-valid frontmatter. + */ +function _stripBOM(content) { + return content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content +} + /** * Extract a plain-text first line from file content. * Skips YAML frontmatter and strips basic Markdown syntax. */ function _extractFirstLine(content) { - let text = content + let text = _stripBOM(content) if (text.startsWith('---')) { const fmEnd = text.indexOf('\n---', 3) if (fmEnd !== -1) text = text.slice(fmEnd + 4) @@ -163,40 +173,39 @@ function _extractFirstLine(content) { } /** - * Extract up to 5 tags from file content. - * Checks YAML frontmatter `tags:` first, then inline `#hashtag` patterns. + * Extract up to 5 tags from a file's YAML frontmatter `tags:` field. + * Recognized shapes only — inline array `tags: [a, b]` or a YAML list: + * tags: + * - a + * - b + * No inline #hashtag fallback: scanning file prose for stray `#word` text + * produced false positives (URLs, anchors, code) with no way to tell a real + * tag from a coincidental match, so tags are only ever what the user + * explicitly declared in frontmatter. */ function _extractTags(content) { + content = _stripBOM(content) + if (!content.startsWith('---')) return [] + const fmEnd = content.indexOf('\n---', 3) + if (fmEnd === -1) return [] + const tags = new Set() - const sample = content.slice(0, 2000) - - if (sample.startsWith('---')) { - const fmEnd = sample.indexOf('\n---', 3) - if (fmEnd !== -1) { - const fm = sample.slice(3, fmEnd) - const inlineMatch = fm.match(/^tags:\s*\[([^\]]+)\]/m) - if (inlineMatch) { - for (const t of inlineMatch[1].split(',')) { - const tag = t.trim().replace(/^['"]|['"]$/g, '') - if (tag) tags.add(tag) - } - } - if (tags.size === 0) { - const listMatch = fm.match(/^tags:\s*\n((?:[ \t]*-[ \t]+.+\n?)+)/m) - if (listMatch) { - for (const line of listMatch[1].split('\n')) { - const tag = line.replace(/^[ \t]*-[ \t]+/, '').replace(/^['"]|['"]$/g, '').trim() - if (tag) tags.add(tag) - } - } - } + const fm = content.slice(3, fmEnd) + const inlineMatch = fm.match(/^tags:\s*\[([^\]]+)\]/m) + if (inlineMatch) { + for (const t of inlineMatch[1].split(',')) { + const tag = t.trim().replace(/^['"]|['"]$/g, '') + if (tag) tags.add(tag) } } - if (tags.size === 0) { - const re = /(?<=\s)#([a-zA-Z][a-zA-Z0-9_-]{1,20})/g - let m - while ((m = re.exec(sample)) !== null && tags.size < 5) tags.add(m[1]) + const listMatch = fm.match(/^tags:\s*\n((?:[ \t]*-[ \t]+.+\n?)+)/m) + if (listMatch) { + for (const line of listMatch[1].split('\n')) { + const tag = line.replace(/^[ \t]*-[ \t]+/, '').replace(/^['"]|['"]$/g, '').trim() + if (tag) tags.add(tag) + } + } } return [...tags].slice(0, 5) @@ -596,11 +605,26 @@ function registerIpcHandlers() { * Matches a single file against a lowercased query, returning a * SearchResult ({ filePath, fileName, snippet, matchType }) or null. * Shared by search:files (folder walk) and search:recent-files (explicit list). + * + * Query syntax: a leading `#` or `tag:` (e.g. `#project`, `tag:project`) + * searches only the file's frontmatter tags (see _extractTags) instead of + * filename/content — lets a user look up files by tag specifically rather + * than relying on the tag word happening to also appear in the text. */ async function _matchFile(fullPath, fileName, q) { - const nameMatch = fileName.toLowerCase().includes(q) let content = '' try { content = await fs.readFile(fullPath, 'utf8') } catch { return null } + + const tagQuery = q.match(/^(?:#|tag:)\s*(.+)$/) + if (tagQuery) { + const wanted = tagQuery[1].trim() + if (!wanted) return null + const tags = _extractTags(content) + if (!tags.some(t => t.toLowerCase().includes(wanted))) return null + return { filePath: fullPath, fileName, snippet: `Tags: ${tags.join(', ')}`, matchType: 'tag' } + } + + const nameMatch = fileName.toLowerCase().includes(q) const contentMatch = content.toLowerCase().includes(q) if (!nameMatch && !contentMatch) return null @@ -620,10 +644,12 @@ function registerIpcHandlers() { /** * search:files — full-text + filename search across a folder tree. + * A query starting with `#` or `tag:` (e.g. `#project`) matches only + * against frontmatter tags instead — see _matchFile. * * Args: { folderPath: string, query: string } * Returns: { results: SearchResult[] } - * SearchResult: { filePath, fileName, snippet, matchType: 'name'|'content' } + * SearchResult: { filePath, fileName, snippet, matchType: 'name'|'content'|'tag' } * Caps at 30 results. Skips hidden files and non-text files. */ ipcMain.handle('search:files', async (_event, { folderPath, query }) => { @@ -658,6 +684,7 @@ function registerIpcHandlers() { * search:recent-files — full-text + filename search over an explicit list * of file paths (used for Custom/no-folder Explorer mode, where there is * no folder tree to walk — only the Recent Files list). + * Same `#tag` / `tag:` query syntax as search:files — see _matchFile. * * Args: { filePaths: string[], query: string } * Returns: { results: SearchResult[] } diff --git a/src/main/releaseCheck.js b/src/main/releaseCheck.js new file mode 100644 index 0000000..3edfb80 --- /dev/null +++ b/src/main/releaseCheck.js @@ -0,0 +1,65 @@ +const https = require('https') +const { app, BrowserWindow } = require('electron') + +// "owner/repo" — used to query the GitHub Releases API and to build the +// release page URL shown in the notification. +const REPO = 'moilstack/moilstack-md' +const CHECK_DELAY_MS = 5000 + +/** Fetch the latest published (non-draft, non-prerelease) GitHub release. */ +function fetchLatestRelease() { + return new Promise((resolve, reject) => { + const req = https.get({ + hostname: 'api.github.com', + path: `/repos/${REPO}/releases/latest`, + headers: { 'User-Agent': 'moilstack-md', Accept: 'application/vnd.github+json' }, + }, (res) => { + if (res.statusCode !== 200) { + res.resume() + reject(new Error(`GitHub API responded ${res.statusCode}`)) + return + } + let body = '' + res.on('data', (chunk) => { body += chunk }) + res.on('end', () => { + try { resolve(JSON.parse(body)) } catch (err) { reject(err) } + }) + }) + req.on('error', reject) + req.setTimeout(10_000, () => req.destroy(new Error('timeout'))) + }) +} + +/** True if version `a` (e.g. "1.2.0") is newer than `b`. */ +function isNewer(a, b) { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0 + const nb = pb[i] || 0 + if (na !== nb) return na > nb + } + return false +} + +async function checkForNewRelease() { + try { + const release = await fetchLatestRelease() + const latestVersion = String(release.tag_name || '').replace(/^v/, '') + if (!latestVersion || !isNewer(latestVersion, app.getVersion())) return + + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('release:available', { version: latestVersion, url: release.html_url }) + } + } catch (err) { + // Offline, rate-limited, or no releases published yet — never bother the user about it. + console.error('[releaseCheck]', err) + } +} + +/** Check once, a few seconds after launch (gives the window time to load first). */ +function startReleaseCheck() { + setTimeout(checkForNewRelease, CHECK_DELAY_MS) +} + +module.exports = { startReleaseCheck } diff --git a/src/preload/index.js b/src/preload/index.js index 5c74886..bb553a6 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -93,6 +93,9 @@ contextBridge.exposeInMainWorld('electronAPI', { listModels: (host) => ipcRenderer.invoke('ollama:list-models', { host }), }, + // Fired once, a few seconds after launch, if a newer GitHub release exists. + onReleaseAvailable: (cb) => ipcRenderer.on('release:available', (_e, info) => cb(info)), + // ── AI Streaming ───────────────────────────────────────────────────── // Fire the AI request (Ollama or API backend) askAI: (payload) => ipcRenderer.invoke('ai:ask', payload), diff --git a/src/renderer/chatPanel.js b/src/renderer/chatPanel.js index aca6e02..76ccfe3 100644 --- a/src/renderer/chatPanel.js +++ b/src/renderer/chatPanel.js @@ -696,7 +696,7 @@ const ChatPanel = (() => { if (!row) return; const newContent = row.dataset.aiText; if (!newContent) return; - _deps.setEditorContentUndoable(newContent); + _deps.setEditorContentNative(newContent); _deps.saveFile(); } @@ -711,7 +711,7 @@ const ChatPanel = (() => { if (!row) return; const before = row.dataset.beforeText; if (before === undefined || before === null) return; - _deps.setEditorContentUndoable(before); + _deps.setEditorContentNative(before); _deps.saveFile(); const origHTML = btn.innerHTML; btn.textContent = '✓ Restored'; @@ -787,10 +787,19 @@ const ChatPanel = (() => { if (sendBtn) sendBtn.disabled = true; if (chatInput) chatInput.disabled = true; - /** Re-enable input — called in all exit paths (success, error, no-model). */ - function _reenableInput() { + /** + * Re-enable input — called in all exit paths (success, error, no-model). + * @param {boolean} [focusChatInput=true] Pass false after a document/block + * edit was just applied — the edit already focused the editor so that + * Ctrl+Z operates on it, and stealing focus back to the chat box here + * would make Ctrl+Z target the chat textarea's own undo history instead. + */ + function _reenableInput(focusChatInput = true) { if (sendBtn) sendBtn.disabled = false; - if (chatInput) { chatInput.disabled = false; chatInput.focus(); } + if (chatInput) { + chatInput.disabled = false; + if (focusChatInput) chatInput.focus(); + } } // 5. Build messages array — rules first, then file context appended at the end @@ -884,6 +893,11 @@ const ChatPanel = (() => { // Priority: BLOCK_EDIT (selection active) → DOC_EDIT → conversational const blockContent = capturedSelection ? extractBlockEdit(fullResponse) : null; const docContent = blockContent === null ? extractDocEdit(fullResponse) : null; + // Any of the three edit paths below focuses the editor via + // replaceRangeNative()/setEditorContentNative() — keep focus there + // afterward (see _reenableInput) so Ctrl+Z targets the editor. + const editorWasEdited = blockContent !== null || docContent !== null + || (!isAskMode && capturedSelection); if (blockContent !== null) { // ── BLOCK EDIT path ────────────────────────────────────────── @@ -897,7 +911,7 @@ const ChatPanel = (() => { if (!origEndsNL && replacement.endsWith('\n')) replacement = replacement.slice(0, -1); if (editor) { - editor.setRangeText(replacement, blockStart, blockEnd, 'end'); + _deps.replaceRangeNative(blockStart, blockEnd, replacement); _deps.updateStats(); _deps.updateHighlight(); _deps.triggerUpdate(); @@ -937,10 +951,12 @@ const ChatPanel = (() => { streaming.row.dataset.beforeText = editorContent; } - // ③ Apply the AI edit — through the native undo stack so Ctrl+Z works + // ③ Apply the AI edit via the browser's native undo stack (execCommand + // 'insertText') rather than a direct .value assign, so Ctrl+Z chains + // back through the user's own prior typing instead of stopping here. const editor = _deps.getEditor(); if (editor) { - _deps.setEditorContentUndoable(docContent); + _deps.setEditorContentNative(docContent); _deps.saveFile(); } @@ -978,7 +994,7 @@ const ChatPanel = (() => { if (origEndsNL && !replacement.endsWith('\n')) replacement += '\n'; if (!origEndsNL && replacement.endsWith('\n')) replacement = replacement.slice(0, -1); - editor.setRangeText(replacement, blockStart, blockEnd, 'end'); + _deps.replaceRangeNative(blockStart, blockEnd, replacement); _deps.updateStats(); _deps.updateHighlight(); _deps.triggerUpdate(); @@ -1028,7 +1044,7 @@ const ChatPanel = (() => { updateChatCount(); updateTokenEstimate(); scrollChatToBottom(); - _reenableInput(); + _reenableInput(!editorWasEdited); }) .catch((err) => { console.error('[sendMessage] AI error:', err); diff --git a/src/renderer/editorCore.js b/src/renderer/editorCore.js index ac9b2b8..a2a8fcd 100644 --- a/src/renderer/editorCore.js +++ b/src/renderer/editorCore.js @@ -19,14 +19,6 @@ const EditorCore = (() => { Private state ═══════════════════════════════════════════════════════════════════ */ - /** - * Stack of full-document snapshots taken before each AI edit. - * Drained by the Ctrl+Z interceptor in the editor keydown handler. - * @type {string[]} - */ - const aiUndoStack = []; - const AI_UNDO_LIMIT = 20; // keep at most 20 AI-edit snapshots - /** Heading regex — used by buildHighlight to colour heading lines. */ const HEADING_RE = /^(#{1,6})(\s)/; @@ -359,7 +351,7 @@ const EditorCore = (() => { ); if (found && newMd !== editor.value) { - setEditorContentUndoable(newMd); + setEditorContentNative(newMd); _deps.markDirty(); // Manually update just the preview — skip triggerUpdate() debounce so the // checkbox toggle feels instant. @@ -416,25 +408,84 @@ const EditorCore = (() => { } /* ═══════════════════════════════════════════════════════════════════ - AI undo stack + Native undo-recorded content replacement ═══════════════════════════════════════════════════════════════════ */ /** - * Replace the entire editor content and push a snapshot to the AI undo stack. - * Goes through direct assignment so it targets the correct element every time. + * Replace a range of the editor content via document.execCommand('insertText', ...) + * instead of direct `.value =`/`setRangeText()` mutation, so the change lands in + * the textarea's native undo/redo history and chains with the user's own + * keystroke history. jsdom (used by the Jest test environment) has no + * execCommand implementation, so this falls back to direct slicing there. * - * @param {string} content New content to place in the editor. + * @param {number} start Range start offset. + * @param {number} end Range end offset. + * @param {string} replacement Text to place in [start, end). */ - function setEditorContentUndoable(content) { + function replaceRangeNative(start, end, replacement) { const editor = _deps.getEditor ? _deps.getEditor() : null; if (!editor) return; - // Push current content onto the AI undo stack before overwriting - aiUndoStack.push(editor.value); - if (aiUndoStack.length > AI_UNDO_LIMIT) aiUndoStack.shift(); // cap size - - editor.value = content; editor.focus(); + + // True no-op (nothing selected, nothing to insert) — skip execCommand + // entirely. Calling it anyway would still push an empty edit onto the + // native undo stack, forcing an extra Ctrl+Z press to skip past it + // before reaching any real prior edit. + if (start === end && replacement === '') return; + + editor.setSelectionRange(start, end); + let applied = false; + try { applied = document.execCommand('insertText', false, replacement); } + catch (_) { applied = false; } + if (!applied) { + editor.value = editor.value.slice(0, start) + replacement + editor.value.slice(end); + const caret = start + replacement.length; + editor.setSelectionRange(caret, caret); + editor.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + /** + * Find the smallest [start, end) range in `oldStr` that differs from `newStr`, + * by trimming the longest common prefix and suffix. Selecting-and-replacing + * the *entire* buffer via execCommand doesn't reliably chain into the native + * undo history the way a small, localized edit does — so full-document + * replacements are turned into the minimal underlying patch before applying. + * + * @param {string} oldStr + * @param {string} newStr + * @returns {{start: number, end: number, replacement: string}} + */ + function _minimalDiff(oldStr, newStr) { + const maxCommon = Math.min(oldStr.length, newStr.length); + let start = 0; + while (start < maxCommon && oldStr[start] === newStr[start]) start++; + + let oldEnd = oldStr.length; + let newEnd = newStr.length; + while (oldEnd > start && newEnd > start && oldStr[oldEnd - 1] === newStr[newEnd - 1]) { + oldEnd--; + newEnd--; + } + + return { start, end: oldEnd, replacement: newStr.slice(start, newEnd) }; + } + + /** + * Replace the editor content with `content`, patching only the minimal + * changed range (via replaceRangeNative) so full-document edits (AI edits, + * checkbox toggles, tag edits, find/replace-all) chain into the browser's + * native undo/redo history instead of resetting it. + * + * @param {string} content New content to place in the editor. + */ + function setEditorContentNative(content) { + const editor = _deps.getEditor ? _deps.getEditor() : null; + if (!editor) return; + + const { start, end, replacement } = _minimalDiff(editor.value, content); + replaceRangeNative(start, end, replacement); updateStats(); updateHighlight(); triggerUpdate(); @@ -452,10 +503,7 @@ const EditorCore = (() => { if (!ta) return; const start = ta.selectionStart, end = ta.selectionEnd; const sel = ta.value.substring(start, end); - ta.focus(); - ta.setSelectionRange(start, end); - ta.setRangeText(before + sel + after, start, end, 'end'); - ta.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(start, end, before + sel + after); ta.setSelectionRange(start + before.length, start + before.length + sel.length); updateStats(); } @@ -468,10 +516,7 @@ const EditorCore = (() => { if (!ta) return; const start = ta.selectionStart; const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; - ta.focus(); - ta.setSelectionRange(lineStart, lineStart); - ta.setRangeText(prefix, lineStart, lineStart, 'end'); - ta.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(lineStart, lineStart, prefix); ta.setSelectionRange(start + prefix.length, start + prefix.length); updateStats(); } @@ -486,10 +531,7 @@ const EditorCore = (() => { const end = editor.selectionEnd; const selected = editor.value.slice(start, end) || placeholder; const insertion = before + selected + after; - editor.focus(); - editor.setSelectionRange(start, end); - editor.setRangeText(insertion, start, end, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(start, end, insertion); editor.setSelectionRange(start + before.length, start + before.length + selected.length); triggerUpdate(); } @@ -517,10 +559,7 @@ const EditorCore = (() => { : lines.map(l => prefix + l).join('\n'); const realEnd = lineEnd === -1 ? val.length : lineEnd; - editor.focus(); - editor.setSelectionRange(lineStart, realEnd); - editor.setRangeText(newBlock, lineStart, realEnd, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(lineStart, realEnd, newBlock); editor.setSelectionRange(lineStart, lineStart + newBlock.length); triggerUpdate(); } @@ -528,12 +567,30 @@ const EditorCore = (() => { /* ── Toolbar actions map ────────────────────────────────────────── */ const TOOLBAR_ACTIONS = { + // ── Clipboard ────────────────────────────────────────────────── + copy: () => { + const editor = _deps.getEditor ? _deps.getEditor() : null; + if (!editor) return; + editor.focus(); + document.execCommand('copy'); + }, + paste: () => { + const editor = _deps.getEditor ? _deps.getEditor() : null; + if (!editor) return; + editor.focus(); + document.execCommand('paste'); + }, + // ── Toolbar-left buttons (insertMd / insertLine) ────────────────── bold: () => insertMd('**', '**'), italic: () => insertMd('*', '*'), inlinecode: () => insertMd('`', '`'), link: () => insertMd('[', '](url)'), - image: () => insertMd('![alt](', ')'), + // Empty URL slot (not a literal "url" placeholder) — the live preview + // renders this straight into , and a non-empty bogus + // relative path gets eagerly fetched under file://, throwing + // net::ERR_FILE_NOT_FOUND. An explicit src="" fires no request at all. + image: () => insertMd('![', ']()'), h1: () => insertLine('# '), h2: () => insertLine('## '), list: () => insertLine('- '), @@ -560,10 +617,7 @@ const EditorCore = (() => { ? lines.map((l, i) => l.slice(`${i + 1}. `.length)).join('\n') : lines.map((l, i) => `${i + 1}. ${l}`).join('\n'); const realEnd = lineEnd === -1 ? val.length : lineEnd; - editor.focus(); - editor.setSelectionRange(lineStart, realEnd); - editor.setRangeText(newBlock, lineStart, realEnd, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(lineStart, realEnd, newBlock); editor.setSelectionRange(lineStart, lineStart + newBlock.length); triggerUpdate(); }, @@ -591,10 +645,7 @@ const EditorCore = (() => { const end = editor.selectionEnd; const selected = editor.value.slice(start, end) || 'code here'; const insertion = '```\n' + selected + '\n```'; - editor.focus(); - editor.setSelectionRange(start, end); - editor.setRangeText(insertion, start, end, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(start, end, insertion); editor.setSelectionRange(start + 4, start + 4 + selected.length); triggerUpdate(); }, @@ -605,12 +656,12 @@ const EditorCore = (() => { const val = editor.value; const before = val[pos - 1] === '\n' || pos === 0 ? '' : '\n'; const after = val[pos] === '\n' ? '' : '\n'; - editor.focus(); - editor.setSelectionRange(pos, pos); - editor.setRangeText(`${before}---${after}`, pos, pos, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); + replaceRangeNative(pos, pos, `${before}---${after}`); triggerUpdate(); }, + tags: () => { + if (typeof TagModal !== 'undefined') TagModal.show(); + }, }; /* ── Ruler state getters (used by ChatPanel via deps injection) ─── */ @@ -684,10 +735,6 @@ const EditorCore = (() => { const value = editor.value; const INDENT = ' '; - // Snapshot for Ctrl+Z undo - if (aiUndoStack.length >= AI_UNDO_LIMIT) aiUndoStack.shift(); - aiUndoStack.push(value); - if (selEnd > selStart) { // Indent every line touched by the selection const firstLineStart = value.lastIndexOf('\n', selStart - 1) + 1; @@ -698,19 +745,20 @@ const EditorCore = (() => { const lines = value.slice(firstLineStart, regionEnd).split('\n'); const indented = lines.map(l => INDENT + l).join('\n'); - editor.value = value.slice(0, firstLineStart) + indented + value.slice(regionEnd); + replaceRangeNative(firstLineStart, regionEnd, indented); editor.selectionStart = selStart + INDENT.length; editor.selectionEnd = selEnd + lines.length * INDENT.length; } else { // No selection: insert two spaces at the cursor - editor.setRangeText(INDENT, selStart, selEnd, 'end'); + replaceRangeNative(selStart, selEnd, INDENT); } - - editor.dispatchEvent(new Event('input', { bubbles: true })); } }); - // Keyboard shortcuts: Ctrl+B, Ctrl+I, Ctrl+Z (AI undo) + // Keyboard shortcuts: Ctrl+B, Ctrl+I + // (Ctrl+Z/Ctrl+Shift+Z are left to the textarea's native undo/redo — + // every programmatic edit now goes through replaceRangeNative()/ + // setEditorContentNative(), which records into that same history.) editor.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); @@ -720,23 +768,6 @@ const EditorCore = (() => { e.preventDefault(); TOOLBAR_ACTIONS.italic(); } - - // Ctrl+Z — AI undo takes priority over textarea's native undo. - // Once the AI stack is empty the event falls through to the browser - // so normal keystroke undo continues to work. - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { - if (aiUndoStack.length > 0) { - e.preventDefault(); - const prev = aiUndoStack.pop(); - editor.value = prev; - updateStats(); - updateHighlight(); - triggerUpdate(); - const filePath = deps.getCurrentFilePath ? deps.getCurrentFilePath() : null; - if (filePath) deps.saveFile(); - } - // else: let native undo handle regular typing - } }); } @@ -776,9 +807,6 @@ const EditorCore = (() => { /* ── Public API ─────────────────────────────────────────────────── */ - /** Clear the AI undo stack — must be called whenever a new file is loaded. */ - function clearAiUndoStack() { aiUndoStack.length = 0; } - return { init, updateHighlight, @@ -786,8 +814,8 @@ const EditorCore = (() => { updateStats, triggerUpdate, renderMarkdown, - setEditorContentUndoable, - clearAiUndoStack, + setEditorContentNative, + replaceRangeNative, syncRuler, visualRowsForLine, computeMatches, diff --git a/src/renderer/fileOperations.js b/src/renderer/fileOperations.js index 93e896f..db79186 100644 --- a/src/renderer/fileOperations.js +++ b/src/renderer/fileOperations.js @@ -212,7 +212,7 @@ const FileOperations = (() => { }); } - document.getElementById('btn-new-file')?.addEventListener('click', async () => { + async function triggerExplorerNewFile() { const explorerMode = localStorage.getItem('explorerMode') || 'multi-level'; if (explorerMode === 'custom') { await window.newUntitledFile?.(); @@ -229,7 +229,9 @@ const FileOperations = (() => { showNewFileInput(); } } - }); + } + + document.getElementById('btn-new-file')?.addEventListener('click', triggerExplorerNewFile); return { startInlineRename, @@ -237,6 +239,7 @@ const FileOperations = (() => { showNewFileInput, hideNewFileInput, confirmNewFile, + triggerExplorerNewFile, }; })(); diff --git a/src/renderer/fileTreeManager.js b/src/renderer/fileTreeManager.js index 0ec12f0..6a71271 100644 --- a/src/renderer/fileTreeManager.js +++ b/src/renderer/fileTreeManager.js @@ -61,6 +61,11 @@ const FileTreeManager = (() => { stroke-linejoin="round" stroke-linecap="round"/> `; + const REMOVE_ICON_SVG = ``; + /* ── HTML builders ────────────────────────────────────────────────── */ function _escHtml(str) { @@ -132,6 +137,7 @@ const FileTreeManager = (() => { const tags = file.tags || []; const preview = file.firstLine || ''; const tagsHTML = tags.map(t => `${_escHtml('#' + t)}`).join(''); + const tagsTitle = tags.length ? ` title="${_escHtml(tags.map(t => '#' + t).join(', '))}"` : ''; const labelDot = label ? `` : ''; @@ -143,7 +149,7 @@ const FileTreeManager = (() => { ${_fileIconSVG(file.name)} ${file.name} ${labelDot} - ${tagsHTML ? `
${tagsHTML}
` : ''} + ${tagsHTML ? `
${tagsHTML}
` : ''} `; - row.addEventListener('click', async () => { + const _open = async () => { _closeDropdown(); await setActiveFolder(item.path); + }; + + row.addEventListener('click', (e) => { + if (e.target.closest('.folder-recent-remove-btn')) return; + _open(); + }); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _open(); } + }); + + row.querySelector('.folder-recent-remove-btn').addEventListener('click', (e) => { + e.stopPropagation(); + StorageManager.removeRecentItem(item.path); + _populateDropdown(); // refresh in place, keep the dropdown open }); dropdown.appendChild(row); diff --git a/src/renderer/findReplaceWidget.js b/src/renderer/findReplaceWidget.js index 05c5493..4ab7c3c 100644 --- a/src/renderer/findReplaceWidget.js +++ b/src/renderer/findReplaceWidget.js @@ -181,8 +181,7 @@ const FindReplaceWidget = (() => { const { start, end } = _matches[_activeIndex]; SaveManager.markDirty(); - editor.focus(); - editor.setRangeText(replaceWith, start, end, 'end'); + EditorCore.replaceRangeNative(start, end, replaceWith); EditorCore.updateStats(); runSearch(); @@ -200,7 +199,7 @@ const FindReplaceWidget = (() => { result = result.slice(0, start) + replaceWith + result.slice(end); } - EditorCore.setEditorContentUndoable(result); + EditorCore.setEditorContentNative(result); SaveManager.markDirty(); if (currentFile.path) SaveManager.saveFile(); diff --git a/src/renderer/hamburgerMenu.js b/src/renderer/hamburgerMenu.js index 3ce090c..2453509 100644 --- a/src/renderer/hamburgerMenu.js +++ b/src/renderer/hamburgerMenu.js @@ -43,6 +43,11 @@ const HamburgerMenu = (() => { await newUntitledFile(); }); + document.getElementById('hmenu-new-explorer-file')?.addEventListener('click', async () => { + closeHamburgerMenu(); + await FileOperations.triggerExplorerNewFile(); + }); + document.getElementById('hmenu-open-file')?.addEventListener('click', async () => { closeHamburgerMenu(); const result = await window.electronAPI?.openFile(); @@ -100,6 +105,24 @@ const HamburgerMenu = (() => { document.getElementById('btnSettings')?.click(); }); + /* ── "Explorer" label (sidebar header) — jumps to Explorer settings ── */ + function _openExplorerSettings() { + document.getElementById('btnSettings')?.click(); + document.querySelector('.settings-nav-item[data-settings-panel="explorer"]')?.click(); + requestAnimationFrame(() => { + const row = document.getElementById('settingsRow-explorerMode'); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('settings-row--flash'); + setTimeout(() => row.classList.remove('settings-row--flash'), 1200); + }); + } + const _sidebarExplorerLabel = document.getElementById('sidebarExplorerLabel'); + _sidebarExplorerLabel?.addEventListener('click', _openExplorerSettings); + _sidebarExplorerLabel?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _openExplorerSettings(); } + }); + /* ── Collapse All button (sidebar header) ─────────────────────────── */ // Disabled in Root folder only mode, since that view has no sub-folders to // collapse (see FileTreeManager.updateFolderToolbarButtons). diff --git a/src/renderer/index.html b/src/renderer/index.html index 671e81d..f965841 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -39,6 +39,10 @@ New Ctrl+N +
+ - @@ -561,6 +572,13 @@

Recent

Markdown + | + + +
+ +
+ @@ -816,6 +834,17 @@

AI Assistant

@@ -1041,10 +1065,10 @@

File Explorer

-
+
Explorer Mode - Root only shows date groups, content previews, and tags. Custom hides folders entirely — Recent Files is the only way to open something. + Root only shows date groups, content previews, and tags — start the file with a frontmatter block (--- ... tags: [work, project] ... ---) to tag it; search with #tag or tag:name. Custom hides folders entirely — Recent Files is the only way to open something.
+
+ +
+
+
+
+