diff --git a/.gitignore b/.gitignore index c73ea86..fb29262 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,15 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# ─── Certificates & signing keys (never commit) ────────────────────────────── +*.pfx +*.p12 +*.cer +*.key + +# ─── Microsoft Store build config (contains publisher identity) ────────────── +build/store-appx.yml + # ─── Environment / secrets ─────────────────────────────────────────────────── .env .env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 342bb0f..cc6ca45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable new features and critical fixes for MoilStack .md. --- +## [1.0.1] - 2026-07-08 + +### Added +- **Notepad-style untitled files** — Ctrl+N now creates an in-memory untitled document that works regardless of whether a folder is open or the Explorer sidebar is visible +- **Save As uses the native OS dialog** — saving an untitled file now opens the system Save dialog directly, pre-pointed at the active folder with a filename suggested from the document's first line of text, instead of a custom in-app modal +- **"On Launch" setting** — Settings → Editor lets you choose whether the app opens to the Recents screen or straight into a new untitled file +- **"Custom (no folder)" Explorer mode** — Settings → File Explorer adds a third mode that hides the folder tree entirely; Recent Files becomes the sidebar's only content and the sole way to reopen something +- **Recent Files section** — a new section at the bottom of the Explorer sidebar tracks the current untitled draft plus files opened via Ctrl+N, Windows Explorer, or the "Open in New Window" context-menu action; entries persist until removed with the row's × button (hover to reveal), independent of which folder is currently active + - Capped at 5 visible rows with a scrollbar beyond that, plus a count badge + - Multi-level / Root folder only modes show it as a collapsible accordion with single-line icon+filename rows + - Custom mode shows it full-height with a second-line content preview per row + +### Fixed +- **Ctrl+N silently discarding unsaved content** — pressing Ctrl+N with unsaved text in the current untitled buffer now prompts Save / Discard / Cancel instead of clearing it +- **Unsaved untitled files lost on close** — closing the window (or switching to another file) without saving now persists the untitled buffer as a recoverable draft, restored automatically (with a toast) the next time the app opens +- **Save As changing the Explorer's active folder** — picking a different folder in the Save dialog no longer navigates the sidebar away from the folder you were browsing +- **False "file was deleted or moved outside the app" notification** — this could incorrectly fire right after saving or opening a file that lives outside the currently active folder (e.g. Save As to another location); the check now only applies to files that are actually supposed to be inside the active folder's tree +- **Sidebar hiding on single-file opens** — opening a file via Ctrl+O, the "Open File" button, Windows Explorer, or "Open in New Window" from the context menu no longer hides or resets the Explorer sidebar; it now stays visible until explicitly toggled off from the header icon +- **Editor toolbar buttons** — bold, italic, links, headings, lists, quotes, code blocks, and horizontal rules now insert text using a more modern, reliable method, so the syntax highlighting and "unsaved changes" indicator always stay accurate +- **Table Builder** — inserting or updating a table now uses the same more reliable text-insertion method +- **Copy AI response** — the "Copy" button on AI chat replies now relies solely on the system clipboard API for a more consistent copy experience +- **Save / export error messages** — failures now show as a small toast notification instead of a disruptive popup dialog +- Renamed a leftover temporary file that still used the app's old name ("MarkFlow") to use the correct "MoilStack" name +- **Dark theme on first launch** — the app now opens in dark mode by default when no theme preference has been set yet, instead of light mode +- **Flash of light theme on launch** — fixed a brief flash of the light theme before the dark theme appeared when starting the app +- **Startup flicker** — the app window no longer briefly shows an unstyled, oversized version of the toolbar icons while it finishes loading; the window now stays hidden until everything is fully rendered + +--- + ## [1.0.0-beta.2] - 2026-07-05 ### Changed diff --git a/assets/screenshot-edit-options.png b/assets/screenshot-edit-options.png new file mode 100644 index 0000000..cd32f73 Binary files /dev/null and b/assets/screenshot-edit-options.png differ diff --git a/assets/screenshot-settings.png b/assets/screenshot-settings.png new file mode 100644 index 0000000..07bbe56 Binary files /dev/null and b/assets/screenshot-settings.png differ diff --git a/build/appx/Square150x150Logo.png b/build/appx/Square150x150Logo.png new file mode 100644 index 0000000..20791df Binary files /dev/null and b/build/appx/Square150x150Logo.png differ diff --git a/build/appx/Square310x310Logo.png b/build/appx/Square310x310Logo.png new file mode 100644 index 0000000..c236d41 Binary files /dev/null and b/build/appx/Square310x310Logo.png differ diff --git a/build/appx/Square44x44Logo.png b/build/appx/Square44x44Logo.png new file mode 100644 index 0000000..16aa8cc Binary files /dev/null and b/build/appx/Square44x44Logo.png differ diff --git a/build/appx/StoreLogo.png b/build/appx/StoreLogo.png new file mode 100644 index 0000000..90c0d12 Binary files /dev/null and b/build/appx/StoreLogo.png differ diff --git a/build/appx/Wide310x150Logo.png b/build/appx/Wide310x150Logo.png new file mode 100644 index 0000000..98d18b5 Binary files /dev/null and b/build/appx/Wide310x150Logo.png differ diff --git a/build/make-app-icon.js b/build/make-app-icon.js new file mode 100644 index 0000000..7382d82 --- /dev/null +++ b/build/make-app-icon.js @@ -0,0 +1,60 @@ +const sharp = require('sharp'); +const pngToIco = require('png-to-ico'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PNG_SRC = path.join(__dirname, '../src/assets/icon.png'); +const ICO_DEST = path.join(__dirname, '../src/assets/icon.ico'); +const APPX_DIR = path.join(__dirname, 'appx'); +const SIZES = [16, 32, 44, 48, 150, 256, 310]; + +const APPX_ASSETS = [ + { name: 'Square44x44Logo.png', size: 44 }, + { name: 'Square150x150Logo.png', size: 150 }, + { name: 'Square310x310Logo.png', size: 310 }, + { name: 'Wide310x150Logo.png', w: 310, h: 150 }, + { name: 'StoreLogo.png', size: 50 }, +]; + +async function run() { + const pngBuffer = fs.readFileSync(PNG_SRC); + const tmpDir = os.tmpdir(); + const tmpFiles = []; + + console.log('Generating PNG sizes for ICO…'); + for (const size of SIZES) { + const outPath = path.join(tmpDir, `icon-${size}.png`); + await sharp(pngBuffer) + .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toFile(outPath); + tmpFiles.push(outPath); + console.log(` ${size}x${size} ✓`); + } + + console.log('Packaging into ICO…'); + const icoBuffer = await pngToIco(tmpFiles); + fs.writeFileSync(ICO_DEST, icoBuffer); + tmpFiles.forEach(f => fs.unlinkSync(f)); + console.log(`Done → ${ICO_DEST}`); + + console.log('Generating AppX visual assets…'); + if (!fs.existsSync(APPX_DIR)) fs.mkdirSync(APPX_DIR); + for (const asset of APPX_ASSETS) { + const w = asset.w || asset.size; + const h = asset.h || asset.size; + const outPath = path.join(APPX_DIR, asset.name); + await sharp(pngBuffer) + .resize(w, h, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toFile(outPath); + console.log(` ${asset.name} (${w}x${h}) ✓`); + } + console.log(`Done → ${APPX_DIR}`); +} + +run().catch(err => { + console.error('Failed:', err.message); + process.exit(1); +}); diff --git a/package.json b/package.json index abb9fbe..f6ebb32 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "moilstack-md", - "version": "1.0.0-beta.2", + "version": "1.0.1", "description": "Markdown editor with AI assistance by MoilStack", "main": "src/main/index.js", "scripts": { "start": "nodemon --exec electron . --watch src --ext js,html,css", "test": "jest", + "make-app-icon": "node build/make-app-icon.js", "make-file-icon": "node build/make-file-icon.js", "package": "electron-builder" }, @@ -50,7 +51,7 @@ "include": "build/installer.nsh" }, "appx": { - "publisher": "CN=MoilStack Tech LLP", + "publisher": "CN=MoilStack", "publisherDisplayName": "MoilStack", "identityName": "MoilStack.Markdown", "applicationId": "MoilStackMarkdown", diff --git a/src/assets/icon.ico b/src/assets/icon.ico index 40a6d83..5f71e77 100644 Binary files a/src/assets/icon.ico and b/src/assets/icon.ico differ diff --git a/src/main/index.js b/src/main/index.js index 70ffd01..cbb2cd1 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -84,9 +84,7 @@ if (!gotLock) { /** * Create a new BrowserWindow. - * @param {{ filePath?: string, folderPath?: string, singleFileMode?: boolean }} [args] - * singleFileMode: when true the renderer opens the file without loading the - * folder sidebar (used by "Open in New Window" from the file context menu). + * @param {{ filePath?: string, folderPath?: string }} [args] */ function createWindow(args = {}) { // If there's an existing window, cascade the new one 40 px down-right so @@ -110,6 +108,14 @@ if (!gotLock) { title: 'MoilStack .md', icon: path.join(__dirname, '..', 'assets', process.platform === 'win32' ? 'icon.ico' : 'icon.png'), frame: false, + // Matches the dark theme's --bg so there's no white flash before the + // page paints. The window itself stays hidden until the renderer + // reports it has finished its startup UI work (see 'renderer:ready' + // in ipc.js) — 'ready-to-show' alone only guarantees a first paint, + // not that theme/sidebar-state/folder-listing JS has run, so relying + // on it left a brief flash of half-initialised UI. + backgroundColor: '#282c34', + show: false, webPreferences: { preload: path.join(__dirname, '..', 'preload', 'index.js'), contextIsolation: true, @@ -117,6 +123,14 @@ if (!gotLock) { }, }) + // Safety net: if the renderer never sends 'renderer:ready' (e.g. a + // startup script error), show the window anyway so it isn't stuck hidden. + win.once('ready-to-show', () => { + setTimeout(() => { + if (!win.isDestroyed() && !win.isVisible()) win.show() + }, 2000) + }) + // Forward maximize/unmaximize events so the renderer can update the button icon win.on('maximize', () => win.webContents.send('window:maximized-change', true)) win.on('unmaximize', () => win.webContents.send('window:maximized-change', false)) @@ -149,8 +163,13 @@ if (!gotLock) { // ── Unsaved-changes guard ───────────────────────────────────────────── // Button indices: 0 = Save 1 = Discard 2 = Cancel - win.webContents.on('will-prevent-unload', async (event) => { - const { response } = await dialog.showMessageBox(win, { + // event.preventDefault() on will-prevent-unload is synchronous-only in + // Electron — calling it after an await has no effect. Instead we send IPC + // messages so the renderer can set the bypass flag and call window.close(). + win.webContents.on('will-prevent-unload', (event) => { + // Prevent the default "keep window open" so we can manage close ourselves. + event.preventDefault() + dialog.showMessageBox(win, { type: 'warning', buttons: ['Save', 'Discard changes', 'Cancel'], defaultId: 0, @@ -158,14 +177,14 @@ if (!gotLock) { title: 'Unsaved changes', message: 'You have unsaved changes.', detail: 'Save before closing, or discard and lose your work?', + }).then(({ response }) => { + if (response === 0) { + win.webContents.send('app:save-and-close') + } else if (response === 1) { + win.webContents.send('app:discard-and-close') + } + // response === 2 (Cancel): do nothing → window stays open }) - - if (response === 0) { - win.webContents.send('app:save-and-close') - } else if (response === 1) { - event.preventDefault() - } - // response === 2 (Cancel): do nothing → window stays open }) // ── Send startup folder once the renderer is ready ─────────────────── diff --git a/src/main/ipc.js b/src/main/ipc.js index 39906b3..2c9bd97 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -222,11 +222,41 @@ function registerIpcHandlers() { ipcMain.on('window:close', (e) => { BrowserWindow.fromWebContents(e.sender)?.close() }) ipcMain.handle('window:is-maximized', (e) => BrowserWindow.fromWebContents(e.sender)?.isMaximized() ?? false) + // Renderer signals that startup UI work has finished — show the window now + // instead of on first paint, so the user never sees it mid-adjustment. + ipcMain.on('renderer:ready', (e) => { + const win = BrowserWindow.fromWebContents(e.sender) + if (win && !win.isDestroyed() && !win.isVisible()) win.show() + }) + // Open a URL in the default OS browser — only http/https allowed ipcMain.handle('shell:open-external', (_e, url) => { if (/^https?:\/\//i.test(url)) shell.openExternal(url) }) + /** + * dialog:confirm-unsaved — ask whether to save, discard, or cancel when an + * in-app action (e.g. starting a new untitled file) would otherwise discard + * unsaved changes. Mirrors the window-close unsaved-changes guard. + * + * Returns 'save' | 'discard' | 'cancel'. + */ + ipcMain.handle('dialog:confirm-unsaved', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + + const { response } = await dialog.showMessageBox(win, { + type: 'warning', + buttons: ['Save', 'Discard changes', 'Cancel'], + defaultId: 0, + cancelId: 2, + title: 'Unsaved changes', + message: 'You have unsaved changes.', + detail: 'Save before starting a new file, or discard and lose your work?', + }) + + return ['save', 'discard', 'cancel'][response] ?? 'cancel' + }) + // Set the Windows taskbar jump list with the "New Instance" task. updateJumpList() @@ -398,7 +428,7 @@ function registerIpcHandlers() { if (canceled || !filePath) return { ok: false, canceled: true } // Write HTML to a temp file so the hidden window can load it - const tmpFile = path.join(os.tmpdir(), 'markflow-export.html') + const tmpFile = path.join(os.tmpdir(), 'moilstack-export.html') await fs.writeFile(tmpFile, html, 'utf8') const hidden = new BrowserWindow({ @@ -439,16 +469,25 @@ function registerIpcHandlers() { /** * file:new — show a native Save dialog, then create an empty .md file. - * Used as a fallback when no folder is active. + * Used for Save-As, and as a fallback when no folder is active. * + * @param {string} [suggestedName] Filename to pre-fill the dialog with + * (extension appended if absent). Falls back to 'untitled.md'. + * @param {string} [folderPath] Directory to point the dialog at. Falls back to the OS default. * Returns { filePath: string } on success, or null when the user cancels. */ - ipcMain.handle('file:new', async (event) => { + ipcMain.handle('file:new', async (event, suggestedName, folderPath) => { const win = BrowserWindow.fromWebContents(event.sender) + const safeName = suggestedName ? path.basename(suggestedName.trim()) : '' + const finalName = safeName + ? (/\.[a-zA-Z0-9]+$/.test(safeName) ? safeName : `${safeName}.md`) + : 'untitled.md' + const defaultPath = folderPath ? path.join(folderPath, finalName) : finalName + const { canceled, filePath } = await dialog.showSaveDialog(win, { title: 'New File', - defaultPath: 'untitled.md', + defaultPath, filters: [ { name: 'Markdown & Text', extensions: ['md', 'markdown', 'txt'] }, { name: 'All Files', extensions: ['*'] }, @@ -553,6 +592,32 @@ 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). + */ + 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 contentMatch = content.toLowerCase().includes(q) + if (!nameMatch && !contentMatch) return null + + let snippet = '' + if (contentMatch) { + const idx = content.toLowerCase().indexOf(q) + const lineStart = content.lastIndexOf('\n', idx) + 1 + const lineEnd = content.indexOf('\n', idx) + snippet = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim() + } else { + snippet = content.split('\n').find(l => l.trim()) || '' + } + snippet = snippet.replace(/^#+\s*/, '').slice(0, 120) + + return { filePath: fullPath, fileName, snippet, matchType: nameMatch ? 'name' : 'content' } + } + /** * search:files — full-text + filename search across a folder tree. * @@ -579,29 +644,8 @@ function registerIpcHandlers() { if (e.isDirectory()) { await walk(fullPath) } else if (e.isFile() && /\.(md|markdown|txt)$/i.test(e.name)) { - const nameMatch = e.name.toLowerCase().includes(q) - let content = '' - try { content = await fs.readFile(fullPath, 'utf8') } catch { continue } - const contentMatch = content.toLowerCase().includes(q) - if (!nameMatch && !contentMatch) continue - - let snippet = '' - if (contentMatch) { - const idx = content.toLowerCase().indexOf(q) - const lineStart = content.lastIndexOf('\n', idx) + 1 - const lineEnd = content.indexOf('\n', idx) - snippet = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim() - } else { - snippet = content.split('\n').find(l => l.trim()) || '' - } - snippet = snippet.replace(/^#+\s*/, '').slice(0, 120) - - results.push({ - filePath: fullPath, - fileName: e.name, - snippet, - matchType: nameMatch ? 'name' : 'content', - }) + const result = await _matchFile(fullPath, e.name, q) + if (result) results.push(result) } } } @@ -610,6 +654,28 @@ function registerIpcHandlers() { return { results } }) + /** + * 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). + * + * Args: { filePaths: string[], query: string } + * Returns: { results: SearchResult[] } + */ + ipcMain.handle('search:recent-files', async (_event, { filePaths, query }) => { + if (!Array.isArray(filePaths) || !filePaths.length || !query) return { results: [] } + const q = query.toLowerCase() + const results = [] + + for (const fullPath of filePaths) { + if (results.length >= 30) break + const result = await _matchFile(fullPath, path.basename(fullPath), q) + if (result) results.push(result) + } + + return { results } + }) + /* ── AI Config CRUD ───────────────────────────────────────────────── All handlers read/write ai-config.json in userData. ──────────────────────────────────────────────────────────────── */ diff --git a/src/preload/index.js b/src/preload/index.js index 5c11f28..5c74886 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -37,8 +37,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Export rendered HTML as a PDF via the native save dialog exportPdf: (html, filename) => ipcRenderer.invoke('file:export-pdf', { html, filename }), - // Prompt for a filename and create a new Markdown file on disk (fallback / no active folder) - newFile: () => ipcRenderer.invoke('file:new'), + // Prompt for a filename/location via native Save dialog and create a new Markdown file on disk + newFile: (suggestedName, folderPath) => ipcRenderer.invoke('file:new', suggestedName, folderPath), // Create a new .md file directly inside an already-selected folder (no Save dialog) newFileInFolder: (folderPath, fileName) => @@ -113,7 +113,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Window close guard ────────────────────────────────────────────────── // Called by the main process when the user chose "Save" in the unsaved- // changes dialog. The renderer should save then call window.close(). - onSaveAndClose: (cb) => ipcRenderer.on('app:save-and-close', () => cb()), + onSaveAndClose: (cb) => ipcRenderer.on('app:save-and-close', () => cb()), + onDiscardAndClose: (cb) => ipcRenderer.on('app:discard-and-close', () => cb()), // ── OS file open ──────────────────────────────────────────────────────── // Called by the main process when a .md file is opened from Windows Explorer @@ -132,19 +133,27 @@ contextBridge.exposeInMainWorld('electronAPI', { // process — the existing Electron runtime creates the window directly). newWindow: () => ipcRenderer.invoke('app:new-window'), - // Open a specific file in a brand-new window — sidebar hidden, file-only mode. - // The file path is passed as a URL query param so the renderer can skip - // folder restoration synchronously during DOMContentLoaded (no flash). + // Open a specific file in a brand-new window. The file path is passed as + // a URL query param so the renderer can skip folder restoration + // synchronously during DOMContentLoaded (no flash). openInNewWindow: (filePath) => - ipcRenderer.invoke('app:new-window', { filePath, singleFileMode: true }), + ipcRenderer.invoke('app:new-window', { filePath }), // Search filenames and content within the active folder searchFiles: (folderPath, query) => ipcRenderer.invoke('search:files', { folderPath, query }), + // Search filenames and content across an explicit list of files (used in + // Custom/no-folder Explorer mode, where there is no folder tree to search) + searchRecentFiles: (filePaths, query) => + ipcRenderer.invoke('search:recent-files', { filePaths, query }), + // Open a URL in the default OS browser (http/https only) openExternal: (url) => ipcRenderer.invoke('shell:open-external', url), + // Ask the user to Save / Discard / Cancel before an in-app action would discard unsaved changes + confirmUnsaved: () => ipcRenderer.invoke('dialog:confirm-unsaved'), + // ── Custom title-bar window controls ────────────────────────────────────── window: { minimize: () => ipcRenderer.send('window:minimize'), @@ -154,4 +163,9 @@ contextBridge.exposeInMainWorld('electronAPI', { onMaximizedChange: (cb) => ipcRenderer.on('window:maximized-change', (_e, isMax) => cb(isMax)), }, + // Tells the main process that startup UI work (theme, sidebar state, + // folder listing) has finished, so the window can be shown without a + // flash of half-initialised content. See main/index.js createWindow(). + notifyReady: () => ipcRenderer.send('renderer:ready'), + }) diff --git a/src/renderer/aiConfig.js b/src/renderer/aiConfig.js index 9d4abbe..a0448d9 100644 --- a/src/renderer/aiConfig.js +++ b/src/renderer/aiConfig.js @@ -439,7 +439,7 @@ const AIConfigManager = (() => { /** Read persisted font preferences and apply them on startup. */ function _initEditorFont() { - const savedSize = parseInt(localStorage.getItem('editorFontSize') || '12', 10) + const savedSize = parseInt(localStorage.getItem('editorFontSize') || '13', 10) const savedFamily = localStorage.getItem('editorFontFamily') || '' _applyFontSize(savedSize, false) // false = don't re-save (already stored) @@ -459,6 +459,11 @@ const AIConfigManager = (() => { const savedExplorerMode = localStorage.getItem('explorerMode') || 'multi-level' const explorerModeSel = document.getElementById('explorerMode') if (explorerModeSel) explorerModeSel.value = savedExplorerMode + + // Seed the launch-behavior selector with the saved preference (default: untitled) + const savedLaunchBehavior = localStorage.getItem('launchBehavior') || 'untitled' + const launchBehaviorSel = document.getElementById('launchBehavior') + if (launchBehaviorSel) launchBehaviorSel.value = savedLaunchBehavior } /** @@ -468,7 +473,7 @@ const AIConfigManager = (() => { function _changeFontSize(delta) { const current = parseInt( getComputedStyle(document.documentElement) - .getPropertyValue('--editor-font-size') || '12', + .getPropertyValue('--editor-font-size') || '13', 10 ) const next = Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, current + delta)) @@ -658,7 +663,15 @@ const AIConfigManager = (() => { /* ── Explorer mode selector ────────────────────────────────────── */ document.getElementById('explorerMode')?.addEventListener('change', e => { localStorage.setItem('explorerMode', e.target.value) + FileTreeManager.updateFolderToolbarButtons() FileTreeManager.refresh() + RecentsPanel.applyExplorerMode() + RecentsPanel.render() + }) + + /* ── Launch behavior selector ─────────────────────────────────── */ + document.getElementById('launchBehavior')?.addEventListener('change', e => { + localStorage.setItem('launchBehavior', e.target.value) }) } diff --git a/src/renderer/chatPanel.js b/src/renderer/chatPanel.js index 5df906c..aca6e02 100644 --- a/src/renderer/chatPanel.js +++ b/src/renderer/chatPanel.js @@ -721,8 +721,8 @@ const ChatPanel = (() => { /** * "Copy" — copy the AI bubble text to the system clipboard. - * Falls back to `execCommand('copy')` for Electron contexts that block - * the Clipboard API. + * If the Clipboard API is unavailable, this is a no-op (with a warning); + * `execCommand('copy')` is no longer a reliable fallback in Electron. * @param {HTMLButtonElement} btn The clicked copy button. */ async function copyResponse(btn) { @@ -735,13 +735,7 @@ const ChatPanel = (() => { btn.style.color = 'var(--primary)'; setTimeout(() => { btn.innerHTML = origHTML; btn.style.color = ''; }, 1500); } catch { - // Fallback for Electron context - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); // eslint-disable-line no-undef - document.body.removeChild(ta); + console.warn('copyResponse: navigator.clipboard.writeText unavailable, unable to copy'); } } diff --git a/src/renderer/contextMenus.js b/src/renderer/contextMenus.js index 875b662..37fe2ac 100644 --- a/src/renderer/contextMenus.js +++ b/src/renderer/contextMenus.js @@ -119,7 +119,7 @@ const ContextMenus = (() => { _folderCtxDepth = depth; const subBtn = folderCtxMenuEl.querySelector('[data-folder-action="new-subfolder"]'); - if (subBtn) subBtn.classList.toggle('hidden', depth >= 4); + if (subBtn) subBtn.classList.toggle('hidden', depth >= 3); const isEmpty = _isFolderEmpty(folderPath); const delBtn = document.getElementById('folder-ctx-delete-btn'); diff --git a/src/renderer/editorCore.js b/src/renderer/editorCore.js index 65db084..ac9b2b8 100644 --- a/src/renderer/editorCore.js +++ b/src/renderer/editorCore.js @@ -454,7 +454,8 @@ const EditorCore = (() => { const sel = ta.value.substring(start, end); ta.focus(); ta.setSelectionRange(start, end); - document.execCommand('insertText', false, before + sel + after); + ta.setRangeText(before + sel + after, start, end, 'end'); + ta.dispatchEvent(new Event('input', { bubbles: true })); ta.setSelectionRange(start + before.length, start + before.length + sel.length); updateStats(); } @@ -469,7 +470,8 @@ const EditorCore = (() => { const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; ta.focus(); ta.setSelectionRange(lineStart, lineStart); - document.execCommand('insertText', false, prefix); + ta.setRangeText(prefix, lineStart, lineStart, 'end'); + ta.dispatchEvent(new Event('input', { bubbles: true })); ta.setSelectionRange(start + prefix.length, start + prefix.length); updateStats(); } @@ -486,7 +488,8 @@ const EditorCore = (() => { const insertion = before + selected + after; editor.focus(); editor.setSelectionRange(start, end); - document.execCommand('insertText', false, insertion); + editor.setRangeText(insertion, start, end, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); editor.setSelectionRange(start + before.length, start + before.length + selected.length); triggerUpdate(); } @@ -516,7 +519,8 @@ const EditorCore = (() => { const realEnd = lineEnd === -1 ? val.length : lineEnd; editor.focus(); editor.setSelectionRange(lineStart, realEnd); - document.execCommand('insertText', false, newBlock); + editor.setRangeText(newBlock, lineStart, realEnd, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); editor.setSelectionRange(lineStart, lineStart + newBlock.length); triggerUpdate(); } @@ -558,7 +562,8 @@ const EditorCore = (() => { const realEnd = lineEnd === -1 ? val.length : lineEnd; editor.focus(); editor.setSelectionRange(lineStart, realEnd); - document.execCommand('insertText', false, newBlock); + editor.setRangeText(newBlock, lineStart, realEnd, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); editor.setSelectionRange(lineStart, lineStart + newBlock.length); triggerUpdate(); }, @@ -588,7 +593,8 @@ const EditorCore = (() => { const insertion = '```\n' + selected + '\n```'; editor.focus(); editor.setSelectionRange(start, end); - document.execCommand('insertText', false, insertion); + editor.setRangeText(insertion, start, end, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); editor.setSelectionRange(start + 4, start + 4 + selected.length); triggerUpdate(); }, @@ -601,7 +607,8 @@ const EditorCore = (() => { const after = val[pos] === '\n' ? '' : '\n'; editor.focus(); editor.setSelectionRange(pos, pos); - document.execCommand('insertText', false, `${before}---${after}`); + editor.setRangeText(`${before}---${after}`, pos, pos, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); triggerUpdate(); }, }; @@ -668,14 +675,38 @@ const EditorCore = (() => { if (typeof ChatPanel !== 'undefined') ChatPanel.hideSelectionGhost(); }); - // Tab key → insert two spaces instead of focus-out + // Tab key → indent selected lines, or insert two spaces at cursor editor.addEventListener('keydown', e => { if (e.key === 'Tab') { e.preventDefault(); - const start = editor.selectionStart; - const end = editor.selectionEnd; - editor.setRangeText(' ', start, end, 'end'); - triggerUpdate(); + const selStart = editor.selectionStart; + const selEnd = editor.selectionEnd; + 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; + // If selEnd sits exactly at a line start (preceded by \n), leave that line alone + const atLineStart = selEnd > 0 && value[selEnd - 1] === '\n'; + const regionEnd = atLineStart ? selEnd - 1 : selEnd; + + 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); + 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'); + } + + editor.dispatchEvent(new Event('input', { bubbles: true })); } }); diff --git a/src/renderer/exportService.js b/src/renderer/exportService.js index 60cc1c1..4b843ec 100644 --- a/src/renderer/exportService.js +++ b/src/renderer/exportService.js @@ -1,15 +1,12 @@ /** - * Export Service — triggers a Markdown file download in the browser/renderer. + * Export Service — test utility only. Not part of the production export path. * * Loaded as a plain @@ -273,6 +275,20 @@ Open a folder to see files + +
+ +
+
+ @@ -953,7 +969,7 @@

Editor

- 12 + 13 px - -
-
- -
- No folder selected - -
-
-
- - - -
- -
- - -