Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added assets/screenshot-edit-options.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square310x310Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Wide310x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions build/make-app-icon.js
Original file line number Diff line number Diff line change
@@ -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);
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down Expand Up @@ -50,7 +51,7 @@
"include": "build/installer.nsh"
},
"appx": {
"publisher": "CN=MoilStack Tech LLP",
"publisher": "CN=MoilStack",
"publisherDisplayName": "MoilStack",
"identityName": "MoilStack.Markdown",
"applicationId": "MoilStackMarkdown",
Expand Down
Binary file modified src/assets/icon.ico
Binary file not shown.
43 changes: 31 additions & 12 deletions src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -110,13 +108,29 @@ 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,
nodeIntegration: false,
},
})

// 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))
Expand Down Expand Up @@ -149,23 +163,28 @@ 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,
cancelId: 2,
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 ───────────────────
Expand Down
120 changes: 93 additions & 27 deletions src/main/ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: ['*'] },
Expand Down Expand Up @@ -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.
*
Expand All @@ -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)
}
}
}
Expand All @@ -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.
──────────────────────────────────────────────────────────────── */
Expand Down
Loading
Loading