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