diff --git a/scripts/tabTransfer.test.ts b/scripts/tabTransfer.test.ts new file mode 100644 index 0000000..bac40d2 --- /dev/null +++ b/scripts/tabTransfer.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import type { Tab } from '../src/lib/stores/tabs.svelte.js'; +import { + buildTransferredTab, + snapshotTab, + transferredTabTitle, + validateTransferPayload, + type TransferableTab, +} from '../src/lib/utils/tabTransfer.js'; + +// Cross-window tab transfer: snapshot in the source window → JSON through +// the Rust broker → strict validation → rebuild in the destination. The +// snapshot is independent of serializeState (window shape only, no content): +// a transferred tab MUST carry its unsaved buffer. Validation is strict and +// rejecting — a past bug built tabs with undefined rawContent, the editor +// attributed a stale buffer to them, and auto-save destroyed real files. + +function makeTab(overrides: Partial = {}): Tab { + return { + id: 'source-id', + path: '/notes/todo.md', + title: 'todo.md', + content: '

rendered

', + rawContent: '# unsaved edits', + originalContent: '# saved on disk', + scrollTop: 120, + isDirty: true, + isEditing: true, + history: ['/notes/readme.md', '/notes/todo.md'], + historyIndex: 1, + editorViewState: { cursorState: 'monaco-live-object' }, + scrollPercentage: 0.42, + anchorLine: 7, + isSplit: true, + splitRatio: 0.3, + isScrollSynced: true, + ...overrides, + }; +} + +function roundTrip(tab: Tab): TransferableTab { + const snap = snapshotTab(tab); + const validated = validateTransferPayload(JSON.stringify(snap)); + assert.ok(validated, 'a snapshot of a real tab must validate'); + return validated; +} + +test('snapshot → JSON → validate → insert preserves the tab, including unsaved content', () => { + const source = makeTab(); + const arrived = buildTransferredTab(roundTrip(source), [], 'Untitled'); + + assert.equal(arrived.path, source.path); + assert.equal(arrived.title, source.title); + assert.equal(arrived.rawContent, source.rawContent); + assert.equal(arrived.originalContent, source.originalContent); + assert.equal(arrived.isDirty, true); + assert.equal(arrived.isEditing, true); + assert.equal(arrived.isSplit, true); + assert.equal(arrived.splitRatio, source.splitRatio); + assert.equal(arrived.isScrollSynced, true); + assert.equal(arrived.scrollTop, source.scrollTop); + assert.equal(arrived.scrollPercentage, source.scrollPercentage); + assert.equal(arrived.anchorLine, source.anchorLine); + assert.deepEqual(arrived.history, source.history); + assert.equal(arrived.historyIndex, source.historyIndex); + + // regenerated / non-serializable state starts fresh at the destination + assert.equal(arrived.content, ''); + assert.equal(arrived.editorViewState, null); + assert.notEqual(arrived.id, source.id); +}); + +test('each insert gets a fresh id', () => { + const snap = snapshotTab(makeTab()); + const a = buildTransferredTab(snap, [], 'Untitled'); + const b = buildTransferredTab(snap, [], 'Untitled'); + assert.notEqual(a.id, b.id); +}); + +test('snapshot never mutates the source tab', () => { + const source = makeTab(); + const snap = snapshotTab(source); + + snap.history.push('/injected.md'); + snap.rawContent = 'clobbered'; + snap.title = 'clobbered'; + + assert.deepEqual(source.history, ['/notes/readme.md', '/notes/todo.md']); + assert.equal(source.rawContent, '# unsaved edits'); + assert.equal(source.title, 'todo.md'); + assert.equal(source.editorViewState.cursorState, 'monaco-live-object'); +}); + +test('the snapshot excludes rendered content and editorViewState', () => { + const snap = snapshotTab(makeTab()) as Record; + assert.ok(!('content' in snap)); + assert.ok(!('editorViewState' in snap)); + assert.ok(!('id' in snap)); +}); + +test('validation rejects invalid JSON', () => { + assert.equal(validateTransferPayload('not json {'), null); + assert.equal(validateTransferPayload(''), null); +}); + +test('validation rejects non-object payloads', () => { + assert.equal(validateTransferPayload('null'), null); + assert.equal(validateTransferPayload('[]'), null); + assert.equal(validateTransferPayload('"a tab"'), null); +}); + +test('validation rejects a missing rawContent — no default, no coercion', () => { + const snap = snapshotTab(makeTab()) as Record; + delete snap.rawContent; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects a non-string rawContent', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.rawContent = 5; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects a history that is not an array', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.history = 'nope'; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation rejects history entries that are not strings', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.history = [1, 2]; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('validation is strict for every field — even cosmetic numerics get no default', () => { + for (const field of [ + 'path', + 'title', + 'originalContent', + 'isDirty', + 'isEditing', + 'isSplit', + 'isScrollSynced', + 'splitRatio', + 'scrollTop', + 'scrollPercentage', + 'anchorLine', + 'historyIndex', + ]) { + const snap = snapshotTab(makeTab()) as Record; + delete snap[field]; + assert.equal(validateTransferPayload(JSON.stringify(snap)), null, `missing ${field}`); + } +}); + +test('validation rejects non-finite numbers (NaN serializes to null)', () => { + const snap = snapshotTab(makeTab()) as Record; + snap.scrollTop = NaN; // JSON.stringify turns this into null + assert.equal(validateTransferPayload(JSON.stringify(snap)), null); +}); + +test('untitled arrivals keep their title unless the destination has taken it', () => { + const source = makeTab({ path: '', title: 'Untitled 2' }); + const snap = roundTrip(source); + + // The title is the document's identity: a fresh detach window (or any + // destination without a clash) never renames. + assert.equal(transferredTabTitle(snap, [], 'Untitled'), 'Untitled 2'); + assert.equal(transferredTabTitle(snap, ['Untitled 1'], 'Untitled'), 'Untitled 2'); + assert.equal(transferredTabTitle(snap, ['notes.md'], 'Untitled'), 'Untitled 2'); + + // Only an exact clash re-numbers, to the destination's smallest free + // number per untitledTitle semantics. + assert.equal( + transferredTabTitle(snap, ['Untitled 1', 'Untitled 2'], 'Untitled'), + 'Untitled 3', + ); + assert.equal(transferredTabTitle(snap, ['Untitled 2'], 'Untitled'), 'Untitled 1'); + + const arrived = buildTransferredTab(snap, ['Untitled 1', 'Untitled 2'], 'Untitled'); + assert.equal(arrived.title, 'Untitled 3'); + // the buffer still travels even when the title changed + assert.equal(arrived.rawContent, source.rawContent); +}); + +test('file-backed arrivals keep their title', () => { + const snap = roundTrip(makeTab()); + assert.equal(transferredTabTitle(snap, ['todo.md'], 'Untitled'), 'todo.md'); +}); + +// insertTransferredTab lives in the Svelte store ($state rune, not loadable +// in node), so — like windowStateRestore.test.ts — the thin store wrapper is +// checked statically; all decision logic above is exercised directly. +test('insertTransferredTab is a thin wrapper: build, push, activate, return the id', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + const start = tabs.indexOf('insertTransferredTab('); + assert.ok(start !== -1, 'insertTransferredTab not found'); + const fn = tabs.slice(start, tabs.indexOf('closeTab(', start)); + + assert.match(fn, /buildTransferredTab\(/); + // untitled re-numbering uses this window's titles and localized base + assert.match(fn, /this\.tabs\.map\(\(tab\) => tab\.title\)/); + assert.match(fn, /t\('tabs\.untitled', settings\.language\)/); + // the new tab is pushed, activated, and its id returned + assert.match(fn, /this\.tabs\.push\(tab\);/); + assert.match(fn, /this\.activeTabId = tab\.id;/); + assert.match(fn, /return tab\.id;/); +}); + +test('serializeState still persists window shape only (untouched by transfer)', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + const fn = tabs.slice(tabs.indexOf('serializeState()'), tabs.indexOf('restoreState(')); + assert.doesNotMatch(fn, /rawContent/); + assert.doesNotMatch(fn, /TransferableTab/); +}); diff --git a/scripts/untitledTitle.test.ts b/scripts/untitledTitle.test.ts new file mode 100644 index 0000000..f87b283 --- /dev/null +++ b/scripts/untitledTitle.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { nextUntitledTitle } from '../src/lib/utils/untitledTitle.js'; + +// Untitled tabs get distinct numbered titles ("Untitled 1", "Untitled 2", …) +// so the tab strip — and the per-tab close dialogs — can name exactly which +// tab they are talking about. Two dirty untitled tabs used to both be called +// "Untitled", making the close dialog ambiguous. + +test('the first untitled tab is number 1', () => { + assert.equal(nextUntitledTitle([], 'Untitled'), 'Untitled 1'); +}); + +test('numbers increment past the ones in use', () => { + assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 2'], 'Untitled'), 'Untitled 3'); +}); + +test('freed numbers are reused (smallest available wins)', () => { + assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 3'], 'Untitled'), 'Untitled 2'); +}); + +test('a legacy unnumbered title occupies slot 1', () => { + assert.equal(nextUntitledTitle(['Untitled'], 'Untitled'), 'Untitled 2'); +}); + +test('titled tabs and lookalike names are ignored', () => { + assert.equal( + nextUntitledTitle(['notes.md', 'Untitled draft', 'Untitled 1x'], 'Untitled'), + 'Untitled 1', + ); +}); + +test('works with localized bases', () => { + assert.equal(nextUntitledTitle(['无标题 1'], '无标题'), '无标题 2'); +}); + +test('new tabs are created with numbered untitled titles', () => { + const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); + assert.match(tabs, /nextUntitledTitle\(/); + // both creation paths go through the helper + const addNewTab = tabs.slice(tabs.indexOf('addNewTab()'), tabs.indexOf('addHomeTab()')); + assert.match(addNewTab, /nextUntitledTitle\(/); +}); diff --git a/scripts/windowClosePerTab.test.ts b/scripts/windowClosePerTab.test.ts new file mode 100644 index 0000000..dbcad0c --- /dev/null +++ b/scripts/windowClosePerTab.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +// Window close (issue #189): instead of one aggregate "you have N unsaved +// files" modal, the red close button walks the dirty tabs one at a time — +// activating each and showing the SAME localized unsaved-changes dialog a +// single tab close shows (canCloseTab). Cancel stops the walk; the window +// stays open with the remaining tabs. + +function closeHandler(): string { + const start = viewer.indexOf('appWindow.onCloseRequested'); + assert.ok(start !== -1, 'close handler registration not found'); + return viewer.slice(start, viewer.indexOf('onDragDropEvent', start)); +} + +test('the aggregate unsaved-files modal is gone from the close handler', () => { + const handler = closeHandler(); + assert.doesNotMatch(handler, /youHaveUnsavedFiles/); + // and the old "clear all dirty flags then close" discard path with it + assert.doesNotMatch(handler, /tabManager\.tabs\.forEach\(\(t\) => \(t\.isDirty = false\)\)/); +}); + +test('dirty tabs are reviewed one at a time through the existing canCloseTab flow', () => { + const handler = closeHandler(); + // activate the tab under review so the user sees what the dialog is about + assert.match(handler, /tabManager\.setActive\(dirty\.id\);/); + // the existing localized per-tab dialog decides save / discard / cancel + assert.match(handler, /await canCloseTab\(dirty\.id\)/); + // a resolved tab actually closes before moving on + assert.match(handler, /tabManager\.closeTab\(dirty\.id\);/); +}); + +test('cancelling the per-tab dialog stops the walk and keeps the window open', () => { + const handler = closeHandler(); + assert.match(handler, /if \(!\(await canCloseTab\(dirty\.id\)\)\) return;/); +}); + +test('the close is prevented synchronously before the per-tab walk', () => { + const handler = closeHandler(); + const branchStart = handler.indexOf('if (dirtyTabs.length > 0) {'); + assert.ok(branchStart !== -1, 'dirty branch not found'); + const prevent = handler.indexOf('event.preventDefault()', branchStart); + const walk = handler.indexOf('canCloseTab(dirty.id)', branchStart); + assert.ok(prevent !== -1 && walk !== -1 && prevent < walk); +}); + +test('the window closes only after every dirty tab is resolved', () => { + const handler = closeHandler(); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + const close = handler.indexOf('appWindow.close()', walk); + assert.ok(walk !== -1 && close !== -1 && walk < close); +}); + +test('a second close request cannot start a competing walk', () => { + const handler = closeHandler(); + // The native red button bypasses the dialog overlay; re-entry must be + // swallowed while a walk is active, or two walks fight over setActive + // and the highlighted tab stops matching the dialog. + assert.match(handler, /if \(isCloseWalkActive\) \{\s*event\.preventDefault\(\);\s*return;\s*\}/); + // and the flag is always released, even when the user cancels mid-walk + assert.match(handler, /finally \{\s*isCloseWalkActive = false;\s*\}/); +}); + +test('the walk proceeds in strict tab-strip order', () => { + const handler = closeHandler(); + // Predictable left-to-right order: always the first dirty tab in the + // array; no active-first shortcut that made the sequence look random. + assert.match(handler, /const dirty = tabManager\.tabs\.find\(\(t\) => t\.isDirty\);/); + assert.doesNotMatch(handler, /active\?\.isDirty/); +}); + +test('the untitled save dialog prefills the numbered tab title', () => { + const fn = viewer.slice(viewer.indexOf('async function saveContent')); + const scope = fn.slice(0, fn.indexOf('async function saveContentAs')); + assert.match(scope, /defaultPath: tab\.title/); +}); + +test('the restore-on-reopen branch persists window state via the shared helper', () => { + const handler = closeHandler(); + assert.match(handler, /persistWindowState\(\);/); + // no durable-write experiment left behind + assert.doesNotMatch(viewer, /saveSessionState|sessionState\.js/); +}); diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts new file mode 100644 index 0000000..a564364 --- /dev/null +++ b/scripts/windowStateRestore.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +// Session restore persists WINDOW state only: which files are open, the +// active tab, and per-tab UI (edit mode, split, scroll). Document content +// always lives on disk — the snapshot never carries rawContent, so unsaved +// changes are handled exclusively by the per-tab close dialogs. + +function slice(source: string, from: string, to: string): string { + const start = source.indexOf(from); + assert.ok(start !== -1, `${from} not found`); + const end = source.indexOf(to, start); + assert.ok(end !== -1, `${to} not found after ${from}`); + return source.slice(start, end); +} + +test('serializeState writes window state only', () => { + const fn = slice(tabs, 'serializeState()', 'restoreState('); + assert.match(fn, /version: 2/); + // untitled tabs have no disk backing; they are resolved at close, never persisted + assert.match(fn, /filter\(\(t\) => t\.path !== ''\)/); + // no full-object spread and no content fields in the snapshot + assert.doesNotMatch(fn, /\.\.\.t/); + assert.doesNotMatch(fn, /rawContent/); + assert.doesNotMatch(fn, /originalContent/); + assert.doesNotMatch(fn, /isDirty/); + assert.doesNotMatch(fn, /history/); +}); + +test('restoreState rebuilds clean tabs and drops legacy untitled entries', () => { + const fn = slice(tabs, 'restoreState(', 'addTab('); + // path is the identity of a restored tab; entries without one are skipped + assert.match(fn, /saved\.path === ''\) continue;/); + // restored tabs start clean; content is read from disk afterwards + assert.match(fn, /isDirty: false/); + assert.match(fn, /rawContent: ''/); + // a stale activeTabId falls back to the first restored tab + assert.match(fn, /activeTabId/); +}); + +test('startup restore reads content from disk, not from the snapshot', () => { + const init = slice(viewer, 'localStorage.getItem(WINDOW_STATE_KEY)', 'urlParams'); + assert.match(init, /read_file_content/); + // a missing file drops its tab instead of restoring a ghost + assert.match(init, /closeTab\(/); +}); + +test('the discard choice reverts the tab to its last saved content', () => { + const fn = slice(viewer, 'async function canCloseTab', 'async function toggleEdit'); + assert.match(fn, /tab\.rawContent = tab\.originalContent;/); + assert.match(fn, /tab\.isDirty = false;/); +}); + +test('the close flow resolves dirty tabs before serializing window state', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + const persist = handler.indexOf('persistWindowState()'); + assert.ok(walk !== -1, 'per-tab walk not found'); + assert.ok(persist !== -1, 'window-state serialization not found'); + assert.ok(walk < persist, 'dirty tabs must be resolved before the snapshot is written'); +}); + +test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys removed)', () => { + // An older build restoring a snapshot it cannot understand ends up with + // undefined tab content; its editor then attributes a stale buffer to the + // wrong tab and auto-save writes it to disk. The snapshot now lives in a + // Rust-written file (setItem is an async message to the WebKit storage + // process and loses a flush race when the last window's close ends the + // process); both localStorage keys are removed on write, so a downgraded + // build starts a fresh session instead of misreading anything. + const helper = viewer.slice(viewer.indexOf('async function persistWindowState')); + const scope = helper.slice(0, helper.indexOf('\n\t}')); + assert.match(scope, /invoke\('save_window_state'/); + assert.doesNotMatch(scope, /setItem\(/); + assert.match(scope, /removeItem\(WINDOW_STATE_KEY\)/); + assert.match(scope, /removeItem\(LEGACY_STATE_KEY\)/); + assert.match(viewer, /const WINDOW_STATE_KEY = 'savedTabsDataV2';/); + // only the main window persists: secondary labels are per-session, and a + // shared write slot would let the last window closed overwrite the rest + assert.match(scope, /if \(!isMainWindow\) return;/); + // startup prefers the Rust file and falls back to the localStorage keys + // (v2 first, then legacy) for one-time migration of older snapshots + assert.match(viewer, /invoke\('load_window_state'\)/); + assert.match( + viewer, + /localStorage\.getItem\(WINDOW_STATE_KEY\) \?\?\n?\s*localStorage\.getItem\(LEGACY_STATE_KEY\)/, + ); + // explicit exit clears the file and both keys + const exitFn = viewer.slice(viewer.indexOf('async function appExit')); + const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}')); + assert.match(exitScope, /clear_window_state/); + assert.match(exitScope, /removeItem\(WINDOW_STATE_KEY\)/); + assert.match(exitScope, /removeItem\(LEGACY_STATE_KEY\)/); +}); + +test('with restore enabled resolved titled tabs stay open for the snapshot', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + // tabs are closed one-by-one only when restore is off (or untitled) + assert.match(handler, /restoreStateOnReopen \|\| dirty\.path === ''/); +}); + +test('auto-save fast path silently saves titled tabs before the walk', () => { + const handlerStart = viewer.indexOf('appWindow.onCloseRequested'); + const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart)); + const fastPath = handler.indexOf('settings.autoSave && !settings.confirmBeforeSave'); + const walk = handler.indexOf('canCloseTab(dirty.id)'); + assert.ok(fastPath !== -1 && walk !== -1 && fastPath < walk); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dce211d..625b673 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Markpad" -version = "2.6.11" +version = "2.6.12" dependencies = [ "arboard", "base64 0.22.1", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4fe2319..955f675 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,10 +1,11 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", + "description": "Capability for all Markpad windows", "windows": [ "main", - "installer" + "installer", + "window-*" ], "permissions": [ "core:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 542b325..8aec434 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,10 +135,11 @@ mod tests { } struct WatcherState { - watcher: Mutex>, + watchers: Mutex>, } mod setup; +mod tab_transfer; #[tauri::command] async fn show_window(window: tauri::Window) { @@ -147,12 +148,127 @@ async fn show_window(window: tauri::Window) { let _ = window.set_focus(); } +// Window-state snapshots are written through Rust instead of localStorage: +// setItem is an async message to the WebKit storage process and dies in +// transit when the last window's close ends the process, whereas an +// awaited invoke keeps the close handler — and therefore the process — +// alive until the bytes are on disk. +fn window_state_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| e.to_string())?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("window-state-v2.json")) +} + +#[tauri::command] +fn save_window_state(app: AppHandle, json: String) -> Result<(), String> { + fs::write(window_state_path(&app)?, json).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn load_window_state(app: AppHandle) -> Option { + fs::read_to_string(window_state_path(&app).ok()?).ok() +} + +#[tauri::command] +fn clear_window_state(app: AppHandle) -> Result<(), String> { + let path = window_state_path(&app)?; + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} + fn bring_webview_window_to_front(window: &tauri::WebviewWindow) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } +/// Picks the viewer window that should receive an externally opened file: +/// the focused viewer if any, else the viewer the user focused most +/// recently, else any viewer. The middle rung matters for Finder opens — +/// Finder is frontmost at that moment, so is_focused() is false for every +/// Markpad window and delivery would otherwise degrade to arbitrary map +/// order. Viewer windows are "main" and detached "window-*" windows; +/// "installer" never receives files. +fn pick_delivery_window(app: &AppHandle) -> Option { + let viewers: Vec = app + .webview_windows() + .into_iter() + .filter(|(label, _)| label == "main" || label.starts_with("window-")) + .map(|(_, window)| window) + .collect(); + + if let Some(focused) = viewers + .iter() + .find(|window| window.is_focused().unwrap_or(false)) + { + return Some(focused.clone()); + } + + let last = app + .state::() + .last_focused_viewer + .lock() + .unwrap() + .clone(); + if let Some(label) = last { + if let Some(window) = viewers.iter().find(|w| w.label() == label) { + return Some(window.clone()); + } + } + + viewers.into_iter().next() +} + +/// Creates the destination window for a tab transfer. The window's label +/// embeds the transfer token ("window-"), so the new frontend can +/// derive which pending transfer to claim from its own label — no URL +/// query involved (the asset protocol 404s on "index.html?x=y" paths). +/// Deliberately NOT async: sync commands run on the main thread, which +/// window creation requires on macOS. +#[tauri::command] +fn create_transfer_window(app: AppHandle, token: String) -> Result<(), String> { + let label = format!("window-{token}"); + + #[allow(unused_mut)] + let mut window_builder = tauri::WebviewWindowBuilder::new( + &app, + &label, + tauri::WebviewUrl::App("index.html".into()), + ) + .title("Markpad") + .inner_size(1000.0, 800.0) + .min_inner_size(400.0, 300.0) + .visible(false) + .resizable(true); + + #[cfg(target_os = "macos")] + { + // Decorated macOS windows keep their shadow. The main window's + // shadow(false) is resurrected as a side effect of the window-state + // plugin restoring its frame at startup; a fresh secondary window + // gets no such restore, so it must opt in explicitly or it renders + // shadowless and blends into the window behind it. + window_builder = window_builder + .decorations(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .shadow(true); + } + + #[cfg(not(target_os = "macos"))] + { + window_builder = window_builder.decorations(false).shadow(false); + } + + window_builder.build().map_err(|e| e.to_string())?; + Ok(()) +} + fn process_internal_embeds(content: &str) -> Cow<'_, str> { let re = Regex::new(r"(?s)```.*?```|`.*?`|!\[\[(.*?)\]\]").unwrap(); @@ -400,21 +516,24 @@ fn rename_file(old_path: String, new_path: String) -> Result<(), String> { #[tauri::command] fn watch_file( + window: tauri::Window, handle: AppHandle, state: State<'_, WatcherState>, path: String, ) -> Result<(), String> { - let mut watcher_lock = state.watcher.lock().unwrap(); + let label = window.label().to_string(); + let mut watchers_lock = state.watchers.lock().unwrap(); - *watcher_lock = None; + watchers_lock.remove(&label); let path_to_watch = path.clone(); let app_handle = handle.clone(); + let event_label = label.clone(); let mut watcher = RecommendedWatcher::new( move |res: Result| { if let Ok(_) = res { - let _ = app_handle.emit("file-changed", ()); + let _ = app_handle.emit_to(event_label.as_str(), "file-changed", ()); } }, Config::default(), @@ -425,20 +544,121 @@ fn watch_file( .watch(Path::new(&path_to_watch), RecursiveMode::NonRecursive) .map_err(|e| e.to_string())?; - *watcher_lock = Some(watcher); + watchers_lock.insert(label, watcher); Ok(()) } #[tauri::command] -fn unwatch_file(state: State<'_, WatcherState>) -> Result<(), String> { - let mut watcher_lock = state.watcher.lock().unwrap(); - *watcher_lock = None; +fn unwatch_file(window: tauri::Window, state: State<'_, WatcherState>) -> Result<(), String> { + let mut watchers_lock = state.watchers.lock().unwrap(); + watchers_lock.remove(window.label()); Ok(()) } struct AppState { startup_file: Mutex>, + // Label of the viewer window the user focused most recently. When an + // OS file-open arrives, Finder is frontmost and is_focused() is false + // for every Markpad window — without this the delivery target degrades + // to arbitrary HashMap order. + last_focused_viewer: Mutex>, + // Display metadata for every viewer window, pushed by each window's + // frontend (tab state lives in its own WebView, so Rust cannot derive + // it). Serves the "Move to window …" menu, the window tag chip, and + // the ⌘⇧M cycle order. `number` is a session-stable creation ordinal + // (main = 1); names/colors are the user's optional window tags. + window_registry: Mutex>, + window_counter: std::sync::atomic::AtomicU64, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Default)] +struct WindowMeta { + number: u64, + tag_name: Option, + tag_color: Option, + active_tab_title: String, + tab_count: usize, +} + +/// Registered by each viewer window's frontend on boot and whenever its +/// displayable state changes (active tab, tab count, tag edits). The first +/// call from a window assigns its session-stable number. +#[tauri::command] +fn set_window_meta( + window: tauri::Window, + state: State<'_, AppState>, + tag_name: Option, + tag_color: Option, + active_tab_title: String, + tab_count: usize, +) { + let label = window.label().to_string(); + if label != "main" && !label.starts_with("window-") { + return; + } + let mut registry = state.window_registry.lock().unwrap(); + let entry = registry.entry(label).or_insert_with(|| WindowMeta { + number: state + .window_counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1, + ..WindowMeta::default() + }); + entry.tag_name = tag_name; + entry.tag_color = tag_color; + entry.active_tab_title = active_tab_title; + entry.tab_count = tab_count; +} + +#[derive(Clone, serde::Serialize)] +struct WindowListEntry { + label: String, + #[serde(flatten)] + meta: WindowMeta, +} + +/// Live viewer windows in creation order (registry entries are pruned on +/// window destruction, so no liveness filtering is needed beyond that). +#[tauri::command] +fn list_viewer_windows(state: State<'_, AppState>) -> Vec { + let registry = state.window_registry.lock().unwrap(); + let mut list: Vec = registry + .iter() + .map(|(label, meta)| WindowListEntry { + label: label.clone(), + meta: meta.clone(), + }) + .collect(); + list.sort_by_key(|e| e.meta.number); + list +} + +/// Brings a specific viewer window to the front — used when ⌘⇧M carries a +/// tab to the next window and focus should follow it. +#[tauri::command] +fn focus_window(app: AppHandle, label: String) -> Result<(), String> { + let window = app + .get_webview_window(&label) + .ok_or_else(|| format!("no such window: {label}"))?; + bring_webview_window_to_front(&window); + Ok(()) +} + +/// Offers a staged tab transfer to an EXISTING window: the destination's +/// frontend claims the token through the same broker path a detach window +/// uses, and the source deletes its tab only on the claim acknowledgement. +#[tauri::command] +fn offer_tab_to_window( + app: AppHandle, + target_label: String, + token: String, +) -> Result<(), String> { + if app.get_webview_window(&target_label).is_none() { + return Err(format!("no such window: {target_label}")); + } + app.emit_to(target_label.as_str(), "tab-transfer-offer", token) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -448,9 +668,12 @@ fn send_markdown_path(state: State<'_, AppState>) -> Vec { .filter(|arg| !arg.starts_with("-")) .collect(); - if let Some(startup_path) = state.startup_file.lock().unwrap().as_ref() { - if !files.contains(startup_path) { - files.insert(0, startup_path.clone()); + // take(): the stash is a one-shot boot buffer (Opened arriving before + // the frontend was ready); once delivered it must not feed any later + // caller another copy. + if let Some(startup_path) = state.startup_file.lock().unwrap().take() { + if !files.contains(&startup_path) { + files.insert(0, startup_path); } } @@ -919,15 +1142,19 @@ pub fn run() { tauri::Builder::default() .manage(AppState { startup_file: Mutex::new(None), + last_focused_viewer: Mutex::new(None), + window_registry: Mutex::new(std::collections::HashMap::new()), + window_counter: std::sync::atomic::AtomicU64::new(0), }) .manage(WatcherState { - watcher: Mutex::new(None), + watchers: Mutex::new(std::collections::HashMap::new()), }) + .manage(tab_transfer::TabTransferBroker::new()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { println!("Single Instance Args: {:?}", args); - let Some(window) = app.get_webview_window("main") else { + let Some(window) = pick_delivery_window(app) else { return; }; @@ -947,7 +1174,7 @@ pub fn run() { cwd_path.join(path).display().to_string() }; - let _ = window.emit("file-path", resolved_path); + let _ = app.emit_to(window.label(), "file-path", resolved_path); } bring_webview_window_to_front(&window); })) @@ -963,6 +1190,15 @@ pub fn run() { | tauri_plugin_window_state::StateFlags::VISIBLE | tauri_plugin_window_state::StateFlags::FULLSCREEN, ) + // Detached tab windows share one saved state instead of + // accumulating a state entry per generated label. + .map_label(|label| { + if label.starts_with("window-") { + "secondary" + } else { + label + } + }) .build(), ) .setup(|app| { @@ -1197,16 +1433,52 @@ pub fn run() { delete_file, copy_file, cleanup_empty_img_dir, - list_directory_contents + list_directory_contents, + tab_transfer::stage_detached_tab, + tab_transfer::claim_detached_tab, + tab_transfer::cancel_detached_tab, + create_transfer_window, + set_window_meta, + list_viewer_windows, + offer_tab_to_window, + focus_window, + save_window_state, + load_window_state, + clear_window_state ]) + .on_window_event(|window, event| { + match event { + tauri::WindowEvent::Focused(true) => { + let label = window.label(); + if label == "main" || label.starts_with("window-") { + let state = window.state::(); + *state.last_focused_viewer.lock().unwrap() = Some(label.to_string()); + } + } + tauri::WindowEvent::Destroyed => { + // Drop this window's file watcher so a closed window + // never leaves a dangling notify handle behind. + let state = window.state::(); + state.watchers.lock().unwrap().remove(window.label()); + // And its registry entry, so the "Move to window …" + // menu never lists a dead destination. + let app_state = window.state::(); + app_state + .window_registry + .lock() + .unwrap() + .remove(window.label()); + } + _ => {} + } + }) .on_menu_event(|app, event| { let id = event.id().as_ref(); - // Emit to the focused webview window rather than `app.emit(...)`, - // which would broadcast to every webview. Markpad is currently - // single-window, but additional webviews (e.g. detached tabs) - // would otherwise receive duplicate New/Close/Save invocations. - // Falls back to "main" if no window is focused (e.g. menu fired - // while the app is in the background). + // Emit to the focused webview window's label rather than + // `window.emit(...)`, which broadcasts to every webview and + // would fire menu actions (New/Close/Save…) in all windows at + // once. Falls back to "main" if no window is focused (e.g. menu + // fired while the app is in the background). let target = app .webview_windows() .into_values() @@ -1215,12 +1487,12 @@ pub fn run() { let Some(window) = target else { return }; if id == "check-updates" { - let _ = window.emit("menu-check-updates", ()); + let _ = app.emit_to(window.label(), "menu-check-updates", ()); } else if id == "menu-app-quit" || id.starts_with("menu-file-") || id.starts_with("menu-edit-") { - let _ = window.emit(id, ()); + let _ = app.emit_to(window.label(), id, ()); } }) .build(tauri::generate_context!()) @@ -1235,8 +1507,8 @@ pub fn run() { let state = _app_handle.state::(); *state.startup_file.lock().unwrap() = Some(path_str.clone()); - if let Some(window) = _app_handle.get_webview_window("main") { - let _ = window.emit("file-path", path_str); + if let Some(window) = pick_delivery_window(_app_handle) { + let _ = _app_handle.emit_to(window.label(), "file-path", path_str); bring_webview_window_to_front(&window); } } diff --git a/src-tauri/src/tab_transfer.rs b/src-tauri/src/tab_transfer.rs new file mode 100644 index 0000000..7e944f6 --- /dev/null +++ b/src-tauri/src/tab_transfer.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, State}; + +/// Maximum number of staged transfers kept in memory; oldest entries are +/// evicted first so an abandoned drag can never grow the map unbounded. +const MAX_PENDING_TRANSFERS: usize = 16; + +pub struct PendingTransfer { + pub payload: String, + pub source_label: String, +} + +#[derive(Default)] +struct BrokerInner { + entries: HashMap, + insertion_order: Vec, +} + +/// In-memory broker for moving a tab between windows: the source window +/// stages a JSON snapshot and hands the returned token to the new window, +/// which claims the payload exactly once. +pub struct TabTransferBroker { + inner: Mutex, + counter: AtomicU64, +} + +impl TabTransferBroker { + pub fn new() -> Self { + TabTransferBroker { + inner: Mutex::new(BrokerInner::default()), + counter: AtomicU64::new(0), + } + } + + fn stage(&self, payload: String, source_label: String) -> String { + let token = format!("t{}", self.counter.fetch_add(1, Ordering::Relaxed) + 1); + let mut inner = self.inner.lock().unwrap(); + inner.entries.insert( + token.clone(), + PendingTransfer { + payload, + source_label, + }, + ); + inner.insertion_order.push(token.clone()); + while inner.entries.len() > MAX_PENDING_TRANSFERS { + let oldest = inner.insertion_order.remove(0); + inner.entries.remove(&oldest); + } + token + } + + fn claim(&self, token: &str) -> Option { + let mut inner = self.inner.lock().unwrap(); + inner.insertion_order.retain(|t| t != token); + inner.entries.remove(token) + } + + fn cancel(&self, token: &str) { + self.claim(token); + } +} + +#[tauri::command] +pub fn stage_detached_tab( + window: tauri::Window, + state: State<'_, TabTransferBroker>, + payload: String, +) -> String { + state.stage(payload, window.label().to_string()) +} + +#[tauri::command] +pub fn claim_detached_tab( + app: AppHandle, + state: State<'_, TabTransferBroker>, + token: String, +) -> Option { + let transfer = state.claim(&token)?; + let _ = app.emit_to( + transfer.source_label.as_str(), + "tab-transfer-claimed", + token, + ); + Some(transfer.payload) +} + +#[tauri::command] +pub fn cancel_detached_tab(state: State<'_, TabTransferBroker>, token: String) { + state.cancel(&token); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_then_claim_returns_payload_and_removes_entry() { + let broker = TabTransferBroker::new(); + let token = broker.stage("{\"tab\":1}".to_string(), "main".to_string()); + + let transfer = broker.claim(&token).expect("staged transfer should exist"); + assert_eq!(transfer.payload, "{\"tab\":1}"); + assert_eq!(transfer.source_label, "main"); + + assert!(broker.claim(&token).is_none()); + } + + #[test] + fn cancel_then_claim_returns_none() { + let broker = TabTransferBroker::new(); + let token = broker.stage("{}".to_string(), "window-1".to_string()); + + broker.cancel(&token); + assert!(broker.claim(&token).is_none()); + } + + #[test] + fn tokens_are_unique_across_stages() { + let broker = TabTransferBroker::new(); + let first = broker.stage("a".to_string(), "main".to_string()); + let second = broker.stage("b".to_string(), "main".to_string()); + assert_ne!(first, second); + } + + #[test] + fn staging_beyond_cap_evicts_oldest() { + let broker = TabTransferBroker::new(); + let mut tokens = Vec::new(); + for i in 0..=MAX_PENDING_TRANSFERS { + tokens.push(broker.stage(format!("payload-{}", i), "main".to_string())); + } + + // The very first entry was evicted; every later one is still claimable. + assert!(broker.claim(&tokens[0]).is_none()); + for (i, token) in tokens.iter().enumerate().skip(1) { + let transfer = broker.claim(token).expect("entry within cap should remain"); + assert_eq!(transfer.payload, format!("payload-{}", i)); + } + } +} diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 1d77a3c..cc127a3 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1,9 +1,9 @@