Skip to content
Open
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
10 changes: 10 additions & 0 deletions scripts/documentLoadFailure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const session = readFileSync('src/lib/sessions/documentSession.svelte.ts', 'utf8');

test('a transient missing-file read error preserves the open tab', () => {
assert.doesNotMatch(session, /tabManager\.closeTab/);
assert.match(session, /options\.onError\('Error loading file', error\);/);
});
13 changes: 13 additions & 0 deletions scripts/fileAssociations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const config = JSON.parse(readFileSync('src-tauri/tauri.conf.json', 'utf8')) as {
bundle: { fileAssociations: Array<{ ext: string[] }> };
};

test('installer advertises only Markdown file associations', () => {
const extensions = config.bundle.fileAssociations.flatMap((association) => association.ext);
assert.deepEqual(extensions, ['md', 'markdown']);
assert.equal(extensions.includes('txt'), false);
});
20 changes: 20 additions & 0 deletions scripts/liveModeWatchedPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const runtime = readFileSync('src-tauri/src/window_runtime.rs', 'utf8');
const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

test('Live Mode routes a watcher notification to its watched path', () => {
assert.match(runtime, /emit_to\(event_label\.as_str\(\), "file-changed", watched_path\.clone\(\)\)/);
assert.match(viewer, /await appWindow\.listen\('file-changed', \(event\) => \{/);
assert.match(viewer, /const changedPath = event\.payload as string;/);
assert.match(viewer, /if \(!liveMode \|\| !currentFile \|\| changedPath !== currentFile\) return;/);
});

test('Live Mode follows the active file instead of retaining a previous tab watcher', () => {
assert.match(viewer, /if \(liveMode && currentFile\) \{\n\t\t\tinvoke\('watch_file', \{ path: currentFile \}\)/);
assert.doesNotMatch(readFileSync('src/lib/sessions/documentSession.svelte.ts', 'utf8'), /isLiveMode\(\)\) invoke\('watch_file'/);
const toggleLiveMode = viewer.slice(viewer.indexOf('function toggleLiveMode'), viewer.indexOf('async function saveImageAs'));
assert.doesNotMatch(toggleLiveMode, /loadMarkdown\(/);
});
16 changes: 16 additions & 0 deletions scripts/macosOpenDocumentPaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const runtime = readFileSync('src-tauri/src/window_runtime.rs', 'utf8');
const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8');
const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

test('macOS open-document events preserve every delivered file path', () => {
assert.match(runtime, /startup_files: Mutex<Vec<String>>/);
assert.match(tauriLib, /for url in urls/);
assert.match(tauriLib, /startup_files\.lock\(\)\.unwrap\(\)\.push\(path_str\.clone\(\)\)/);
assert.match(runtime, /startup_files\.lock\(\)\.unwrap\(\)\.drain\(\.\.\)\.collect\(\)/);
assert.match(runtime, /for path in startup_files\.into_iter\(\)\.rev\(\)/);
assert.match(viewer, /for \(const path of args\) await loadMarkdown\(path\);/);
});
16 changes: 16 additions & 0 deletions scripts/macosPdfExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

test('all Markpad webviews may invoke Tauri native printing', () => {
const capability = JSON.parse(readFileSync('src-tauri/capabilities/default.json', 'utf8')) as {
windows: string[];
permissions: string[];
};

assert.deepEqual(capability.windows, ['main', 'installer', 'window-*']);
assert.ok(
capability.permissions.includes('core:webview:allow-print'),
'macOS replaces window.print() with Tauri native printing, which requires this permission',
);
});
12 changes: 12 additions & 0 deletions scripts/openMultipleFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');
const selectFile = viewer.slice(viewer.indexOf('async function selectFile'), viewer.indexOf('async function reloadFromDisk'));

test('Open File accepts multiple documents and loads each selected path', () => {
assert.match(selectFile, /multiple: true/);
assert.match(selectFile, /const paths = Array\.isArray\(selected\) \? selected : \[selected\];/);
assert.match(selectFile, /for \(const path of paths\) await loadMarkdown\(path\);/);
});
6 changes: 3 additions & 3 deletions scripts/reloadOpenToolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco without doub
assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/);
});

test('editor toolbar delegates to Monaco actions and exposes expected Markdown commands', () => {
test('editor toolbar forwards Monaco actions and optional payloads', () => {
assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/);
assert.match(markdownViewer, /<EditorToolbar[\s\S]*onaction=\{\(actionId\) => editorPane\?\.runEditorAction\(actionId\)\}/);
assert.match(editor, /export function runEditorAction\(actionId: string\)/);
assert.match(markdownViewer, /<EditorToolbar[\s\S]*onaction=\{\(actionId, payload\) => editorPane\?\.runEditorAction\(actionId, payload\)\}/);
assert.match(editor, /export function runEditorAction\(actionId: string, payload\?: any\)/);

for (const actionId of [
'fmt-bold',
Expand Down
14 changes: 14 additions & 0 deletions scripts/windowTitleCapability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

test('all Markpad windows may update their native title', () => {
const capability = JSON.parse(readFileSync('src-tauri/capabilities/default.json', 'utf8')) as {
permissions: string[];
};

assert.ok(
capability.permissions.includes('core:window:allow-set-title'),
'window tags and active document names call appWindow.setTitle(), which requires this permission',
);
});
4 changes: 3 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"opener:allow-open-url",
"opener:allow-open-path",
"dialog:default",
"core:webview:allow-print",
"core:window:allow-start-dragging",
"core:window:allow-set-title",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
Expand All @@ -25,4 +27,4 @@
"updater:default",
"process:default"
]
}
}
22 changes: 17 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ mod tests {
fs::remove_dir_all(root).unwrap();
fs::remove_dir_all(outside).unwrap();
}

#[test]
fn theme_slug_collapses_punctuation_runs() {
assert_eq!(theme_slug("SynthWave '84"), "synthwave-84");
}
}

mod setup;
Expand Down Expand Up @@ -818,6 +823,15 @@ async fn get_app_mode() -> String {
}
}

fn theme_slug(value: &str) -> String {
let lowercase = value.to_lowercase();
lowercase
.split(|c: char| !c.is_alphanumeric())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("-")
}

#[tauri::command]
async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result<String, String> {
use std::io::{Cursor, Read};
Expand Down Expand Up @@ -892,9 +906,7 @@ async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result<String, Strin
.unwrap_or("");
let path = t.get("path").and_then(|p| p.as_str()).unwrap_or("");

let label_slug = label
.to_lowercase()
.replace(|c: char| !c.is_alphanumeric(), "-");
let label_slug = theme_slug(label);

// If theme_name is empty, just take the first one
if theme_name.is_empty()
Expand Down Expand Up @@ -1569,12 +1581,12 @@ pub fn run() {
.run(|_app_handle, _event| {
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = _event {
if let Some(url) = urls.first() {
for url in urls {
if let Ok(path_buf) = url.to_file_path() {
let path_str = path_buf.to_string_lossy().to_string();

let state = _app_handle.state::<AppState>();
*state.startup_file.lock().unwrap() = Some(path_str.clone());
state.startup_files.lock().unwrap().push(path_str.clone());

if let Some(window) = pick_delivery_window(_app_handle) {
let _ = _app_handle.emit_to(window.label(), "file-path", path_str);
Expand Down
10 changes: 6 additions & 4 deletions src-tauri/src/window_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ impl WatcherState {
}

pub struct AppState {
pub(crate) startup_file: Mutex<Option<String>>,
pub(crate) startup_files: Mutex<Vec<String>>,
pub(crate) last_focused_viewer: Mutex<Option<String>>,
window_registry: Mutex<HashMap<String, WindowMeta>>,
window_counter: AtomicU64,
Expand All @@ -30,7 +30,7 @@ pub struct AppState {
impl AppState {
pub fn new() -> Self {
Self {
startup_file: Mutex::new(None),
startup_files: Mutex::new(Vec::new()),
last_focused_viewer: Mutex::new(None),
window_registry: Mutex::new(HashMap::new()),
window_counter: AtomicU64::new(0),
Expand Down Expand Up @@ -288,10 +288,11 @@ pub fn watch_file(
let mut watchers = state.watchers.lock().unwrap();
watchers.remove(&label);
let event_label = label.clone();
let watched_path = path.clone();
let mut watcher = RecommendedWatcher::new(
move |result: Result<notify::Event, notify::Error>| {
if result.is_ok() {
let _ = handle.emit_to(event_label.as_str(), "file-changed", ());
let _ = handle.emit_to(event_label.as_str(), "file-changed", watched_path.clone());
}
},
Config::default(),
Expand All @@ -314,7 +315,8 @@ pub fn send_markdown_path(state: State<'_, AppState>) -> Vec<String> {
.skip(1)
.filter(|arg| !arg.starts_with('-'))
.collect();
if let Some(path) = state.startup_file.lock().unwrap().take() {
let startup_files: Vec<String> = state.startup_files.lock().unwrap().drain(..).collect();
for path in startup_files.into_iter().rev() {
if !files.contains(&path) {
files.insert(0, path);
}
Expand Down
8 changes: 0 additions & 8 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,6 @@
"name": "Markdown File",
"description": "Opens Markdown files with Markpad",
"role": "Editor"
},
{
"ext": ["txt"],
"contentTypes": ["public.plain-text"],
"mimeType": "text/plain",
"name": "Text File",
"description": "Opens Text files with Markpad",
"role": "Editor"
}
],
"windows": {
Expand Down
30 changes: 17 additions & 13 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,6 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
scrollFuture = [];
},
renderMarkdown: renderMarkdownPreview,
isLiveMode: () => liveMode,
afterLoad: tick,
saveRecentFile,
deleteRecentFile,
Expand Down Expand Up @@ -620,6 +619,14 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
findOpen = false;
});

$effect(() => {
if (liveMode && currentFile) {
invoke('watch_file', { path: currentFile }).catch(console.error);
} else {
invoke('unwatch_file').catch(console.error);
}
});

function processHighlights(root: Element) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
Expand Down Expand Up @@ -1841,13 +1848,15 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu

async function selectFile() {
const selected = await open({
multiple: false,
multiple: true,
filters: [
{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (selected && typeof selected === 'string') loadMarkdown(selected);
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
for (const path of paths) await loadMarkdown(path);
}

async function reloadFromDisk() {
Expand Down Expand Up @@ -1907,14 +1916,8 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
if (currentFile) await invoke('open_file_folder', { path: currentFile });
}

async function toggleLiveMode() {
function toggleLiveMode() {
liveMode = !liveMode;
if (liveMode && currentFile) {
await invoke('watch_file', { path: currentFile });
if (tabManager.activeTabId) await loadMarkdown(currentFile);
} else {
await invoke('unwatch_file');
}
}

async function saveImageAs(src: string) {
Expand Down Expand Up @@ -2638,8 +2641,9 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
}),
);
unlisteners.push(
await appWindow.listen('file-changed', () => {
if (!liveMode || !currentFile) return;
await appWindow.listen('file-changed', (event) => {
const changedPath = event.payload as string;
if (!liveMode || !currentFile || changedPath !== currentFile) return;
// Skip events caused by our own auto-save / save invocations,
// otherwise the reload would clobber any keystrokes that landed
// between fs::write and this listener firing.
Expand Down Expand Up @@ -2943,7 +2947,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
try {
const args: string[] = await invoke('send_markdown_path');
if (!isDisposed && args?.length > 0) {
await loadMarkdown(args[0]);
for (const path of args) await loadMarkdown(path);
}
} catch (error) {
console.error('Error receiving Markdown file path:', error);
Expand Down
Loading
Loading