diff --git a/scripts/tabTransfer.test.ts b/scripts/tabTransfer.test.ts index 15062b80..cb3af998 100644 --- a/scripts/tabTransfer.test.ts +++ b/scripts/tabTransfer.test.ts @@ -225,5 +225,5 @@ test('source removal waits for destination completion', () => { assert.match(broker, /pub fn complete_detached_tab/); const claim = broker.slice(broker.indexOf('pub fn claim_detached_tab'), broker.indexOf('pub fn complete_detached_tab')); assert.doesNotMatch(claim, /tab-transfer-claimed/); - assert.match(session, /invoke\('complete_detached_tab', \{ token: claimToken \}\)/); + assert.match(session, /invoke\('complete_detached_tab', \{ token \}\)/); }); diff --git a/scripts/windowOrganization.test.ts b/scripts/windowOrganization.test.ts new file mode 100644 index 00000000..15bb6104 --- /dev/null +++ b/scripts/windowOrganization.test.ts @@ -0,0 +1,35 @@ +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 session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8'); +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const tab = readFileSync('src/lib/components/Tab.svelte', 'utf8'); +const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); + +test('the runtime registers live viewer windows in stable creation order', () => { + assert.match(runtime, /window_registry/); + assert.match(runtime, /window_counter/); + assert.match(runtime, /pub fn set_window_meta/); + assert.match(runtime, /pub fn list_viewer_windows/); + assert.match(runtime, /list\.sort_by_key\(\|entry\| entry\.meta\.number\)/); + assert.match(runtime, /window_registry[\s\S]*?remove\(window\.label\(\)\)/); +}); + +test('moving to an existing window uses the acknowledged transfer protocol', () => { + assert.match(session, /async function transfer\(/); + assert.match(session, /invoke\('complete_detached_tab', \{ token \}\)/); + assert.match(session, /onTransferClaimed\(tabId\)/); + assert.match(session, /async function acceptOfferedTransfer/); + assert.match(viewer, /invoke\('offer_tab_to_window', \{ targetLabel, token \}\)/); +}); + +test('window organization exposes move, merge, and carry actions', () => { + assert.match(tab, /list_viewer_windows/); + assert.match(tab, /menu-tab-move/); + assert.match(viewer, /async function mergeAllWindowsHere/); + assert.match(viewer, /async function carryActiveTabToNextWindow/); + assert.match(viewer, /cmdOrCtrl && e\.shiftKey && key === 'm'/); + assert.match(titleBar, /onmergeAllWindows/); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae690539..a15ee0b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -330,6 +330,31 @@ fn clear_window_state(app: AppHandle) -> Result<(), String> { window_runtime::clear_window_state(app) } +#[tauri::command] +fn set_window_meta( + window: tauri::Window, + state: State<'_, AppState>, + active_tab_title: String, + tab_count: usize, +) { + window_runtime::set_window_meta(window, state, active_tab_title, tab_count) +} + +#[tauri::command] +fn list_viewer_windows(state: State<'_, AppState>) -> Vec { + window_runtime::list_viewer_windows(state) +} + +#[tauri::command] +fn offer_tab_to_window(app: AppHandle, target_label: String, token: String) -> Result<(), String> { + window_runtime::offer_tab_to_window(app, target_label, token) +} + +#[tauri::command] +fn focus_window(app: AppHandle, label: String) -> Result<(), String> { + window_runtime::focus_window(app, label) +} + /// Byte ranges of code regions — fenced code blocks and inline code spans — /// paired with CommonMark's rules. The regex alternation previously used for /// protection (```` ```.*?```|`.*?` ````) cannot express them: a fence closes @@ -1487,6 +1512,10 @@ pub fn run() { tab_transfer::complete_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 diff --git a/src-tauri/src/window_runtime.rs b/src-tauri/src/window_runtime.rs index 0d424576..fa859a9a 100644 --- a/src-tauri/src/window_runtime.rs +++ b/src-tauri/src/window_runtime.rs @@ -2,7 +2,10 @@ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::HashMap; use std::fs; use std::path::Path; -use std::sync::Mutex; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, +}; use tauri::{AppHandle, Emitter, Manager, State}; pub struct WatcherState { @@ -20,6 +23,8 @@ impl WatcherState { pub struct AppState { pub(crate) startup_file: Mutex>, pub(crate) last_focused_viewer: Mutex>, + window_registry: Mutex>, + window_counter: AtomicU64, } impl AppState { @@ -27,10 +32,79 @@ impl AppState { Self { startup_file: Mutex::new(None), last_focused_viewer: Mutex::new(None), + window_registry: Mutex::new(HashMap::new()), + window_counter: AtomicU64::new(0), } } } +#[derive(Clone, serde::Serialize)] +struct WindowMeta { + number: u64, + active_tab_title: String, + tab_count: usize, +} + +#[derive(Clone, serde::Serialize)] +pub struct WindowListEntry { + label: String, + #[serde(flatten)] + meta: WindowMeta, +} + +pub fn set_window_meta( + window: tauri::Window, + state: State<'_, AppState>, + 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, Ordering::SeqCst) + 1, + active_tab_title: String::new(), + tab_count: 0, + }); + entry.active_tab_title = active_tab_title; + entry.tab_count = tab_count; +} + +pub 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(|entry| entry.meta.number); + list +} + +pub 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(|error| error.to_string()) +} + +pub 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_to_front(&window); + Ok(()) +} + pub async fn show_window(window: tauri::Window) { let _ = window.show(); let _ = window.unminimize(); @@ -200,6 +274,12 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { tauri::WindowEvent::Destroyed => { let state = window.state::(); state.watchers.lock().unwrap().remove(window.label()); + let app_state = window.state::(); + app_state + .window_registry + .lock() + .unwrap() + .remove(window.label()); } _ => {} } diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index ed605e5b..45d4b193 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1,8 +1,9 @@