From 7bfdfebdc1580ddea7025a431f11b8693295a415 Mon Sep 17 00:00:00 2001 From: auslander969 <134359370+auslander969@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:02:04 +0200 Subject: [PATCH] Several windows at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Ctrl+Shift+N` opens another window on the same folder with nothing in it, `Ctrl+Shift+W` closes one, and `file → exit` still ends the app: every window gets the unsaved-changes question and the app goes when the last of them has closed itself. A window is its own webview with its own store, so most of this is already true — two windows share nothing, and neither has to know what the other is showing. What had to be settled is the four things they would otherwise fight over. They live in src-tauri/src/windows.rs, with the front end's half in src/app/windows.ts. Which window a file belongs to. A path from Finder, from a second launch or from the command line is parked on the window the user was last in, and only that window is nudged — rather than every window racing to drain one queue. One file, one window. Two windows on one document would be two buffers, two drafts under the same name and two saves racing each other (spec §8), so opening a file another window already has brings that window forward instead. Unless this window opened something of its own: then the focus stays here and the status bar says where the other one went. state.json. Version 2 is a list with one entry per window; version 1 was the single object one window wrote, and still reads as the one window it was. No window can see the others' entries, so the file is assembled and written in Rust. Closing a window drops its entry; quitting keeps them all, and so does closing the last window, because that is how the app is quit on Windows. settings.json. One file, so a theme changed in one window changes in all of them. Without that the other windows would carry on with the old value and write it back over the new one on the way out. The watcher is now one per window rather than one shared: a window only hears about its own files, and a window opening a document does not make every other window's library be indexed again. Starting one walks the whole tree, which is long enough to be seen on a large library, so `watch` is `async` and does that off the main thread — on the main thread it is the window not drawing for as long as it takes. Recovery drafts are the app's, not a window's, so only the first window of a run offers them. Three windows each offering to restore the same file would be three chances to answer one question, and two of them wrong. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- src-tauri/capabilities/default.json | 4 +- src-tauri/src/lib.rs | 184 ++++++------ src-tauri/src/watch.rs | 77 +++-- src-tauri/src/windows.rs | 421 ++++++++++++++++++++++++++++ src/app/App.tsx | 4 + src/app/bootstrap.ts | 59 +++- src/app/close.ts | 44 +-- src/app/commands.ts | 80 ++++-- src/app/menu.ts | 2 + src/app/registry.ts | 14 + src/app/session.test.ts | 54 +++- src/app/session.ts | 80 ++++-- src/app/settings.ts | 12 + src/app/windows.test.ts | 129 +++++++++ src/app/windows.ts | 171 +++++++++++ 16 files changed, 1141 insertions(+), 197 deletions(-) create mode 100644 src-tauri/src/windows.rs create mode 100644 src/app/windows.test.ts create mode 100644 src/app/windows.ts diff --git a/README.md b/README.md index 86e9699..d21df43 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Double-click a `.md` file and you are reading. Make a change, and the file stays - **All the Markdown you need.** Tables, task lists, footnotes, callouts like `> [!note]`, KaTeX math, Mermaid diagrams, syntax-highlighted code, `==highlights==`, `[[wikilinks]]`, and images from your folder. - **A folder as a library.** A file tree, create, rename, delete to the Recycle Bin or Trash, and full-text search across the folder. - **Quick open.** `Ctrl+K` finds files by name, lists recent ones, and runs commands. +- **As many windows as you like.** `Ctrl+Shift+N` opens another one on the same folder, with nothing in it yet. Each window keeps its own documents and its own place in them; a file only ever opens in one of them, and the windows you had come back the next time you start. - **Careful saving.** Atomic writes, encodings and line endings preserved, recovery drafts in case of a crash, and a warning when a file changes on disk while you have it open. - **A quiet interface.** One monospace font, light and dark themes, no toolbars, no icons. @@ -55,7 +56,7 @@ On Windows in `%APPDATA%\Plain` (that is `C:\Users\\AppData\Roaming\Plain`) |---|---| | `settings.json` | settings; easier to change with `Ctrl+,` | | `drafts\` | recovery drafts of unsaved edits | -| `state.json` | open files, reading positions, the last folder | +| `state.json` | the windows, what each had open, reading positions, the last folder | | `history\` | snapshots of what you saved, kept for thirty days | On Windows, a folder called `data` next to `plain.exe` makes Plain keep all four there instead (portable mode); the macOS app is a bundle, so there is no portable mode there. Plain keeps no indexes, caches, or hidden service files in your folders. The only things it writes there are the documents you save and, if you paste an image, the `assets/` folder next to the document. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cac481b..5370925 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/windows-schema.json", "identifier": "default", - "description": "What the main window is allowed to do in v0.1", - "windows": ["main"], + "description": "What a window is allowed to do. Every window is the same window, so the second one and the twentieth get exactly what the first one has.", + "windows": ["main", "w*"], "permissions": [ "core:default", "core:app:allow-version", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3546a08..d6be26f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,23 +7,15 @@ pub mod history; pub mod search; pub mod tree; mod watch; +pub mod windows; use std::path::{Path, PathBuf}; -use std::sync::Mutex; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; -use tauri::{Emitter, Manager, Runtime, State, Url}; +use tauri::{Emitter, Manager, Runtime, Url}; use tauri_plugin_fs::FsExt; -/// Paths waiting for the frontend to collect them: launch arguments and -/// whatever a second launch handed over. -#[derive(Default)] -struct PendingPaths(Mutex>); - -/// The same, for folders: a folder given as an argument opens as the library -/// (spec §6). -#[derive(Default)] -struct PendingFolders(Mutex>); +use windows::QUIT_REQUESTED; /// argv -> absolute paths. A relative argument belongs to the working /// directory of the process that produced it, which for a second launch is @@ -84,11 +76,6 @@ pub fn data_dir(app: &tauri::AppHandle) -> PathBuf { #[cfg(target_os = "macos")] const QUIT_ID: &str = "plain-quit"; -/// The event the front end answers by running the ordinary close-everything -/// scenario, dialog and all. -#[cfg(target_os = "macos")] -const QUIT_REQUESTED: &str = "plain:quit-requested"; - /// macOS puts an application menu in the bar whether we ask for one or not, /// and the default it builds owns `⌘Q` through native `terminate:` — which /// closes the app without the unsaved-changes question §8 promises. So the @@ -149,12 +136,6 @@ fn build_app_menu(app: &tauri::AppHandle) -> tauri::Result>(argv: I, cwd: &Path) -> Vec>(manager: &M, paths: Vec) { - if paths.is_empty() { +/// in a dialog — so the paths can be read once a window asks for them. +fn allow_paths>(manager: &M, paths: &[String], folders: &[String]) { + let Some(scope) = manager.try_fs_scope() else { return; + }; + for path in paths { + let _ = scope.allow_file(path); } - if let Some(scope) = manager.try_fs_scope() { - for path in &paths { - let _ = scope.allow_file(path); - } - } - if let Ok(mut queue) = manager.state::().0.lock() { - queue.extend(paths); + for folder in folders { + let _ = scope.allow_directory(folder, true); } } -/// Parks folder arguments the same way, and widens the scope they need. -fn queue_folders>(manager: &M, folders: Vec) { - if folders.is_empty() { +/// Parks arguments on one window and nudges it to come and take them. Which +/// window: the focused one, so a file from Finder lands where the user is +/// looking, and never in all of them at once (spec §6, §13a). +/// +/// A path that arrives before that window has a listener is not lost — the +/// event is only a nudge, and the parcel keeps until the window drains it. +fn hand_over(app: &tauri::AppHandle, paths: Vec, folders: Vec) { + if paths.is_empty() && folders.is_empty() { return; } - if let Some(scope) = manager.try_fs_scope() { - for folder in &folders { - let _ = scope.allow_directory(folder, true); - } - } - if let Ok(mut queue) = manager.state::().0.lock() { - queue.extend(folders); + allow_paths(app, &paths, &folders); + let Some(label) = windows::target_window(app) else { + return; + }; + if windows::queue_for(app, &label, paths, folders) { + windows::surface_window(app, &label); + // No payload: the event only says "there is something to take". + let _ = app.emit_to(&label, "open-path", ()); } } -/// Drained by the frontend at startup and again on every `open-path` signal, -/// so a path that arrives before the listener exists is never lost. -#[tauri::command] -fn take_pending_paths(pending: State<'_, PendingPaths>) -> Vec { - pending - .0 - .lock() - .map(|mut queue| std::mem::take(&mut *queue)) - .unwrap_or_default() -} - -#[tauri::command] -fn take_pending_folders(pending: State<'_, PendingFolders>) -> Vec { - pending - .0 - .lock() - .map(|mut queue| std::mem::take(&mut *queue)) - .unwrap_or_default() -} - /// Hands a folder to the asset protocol so its images can be shown, and to /// the file-system scope so links inside it can be opened (spec §9). /// Called by the frontend for the folder of the open file, for the library, @@ -257,14 +222,12 @@ fn allow_asset_dir(app: tauri::AppHandle, path: String) -> Result<(), String> { Ok(()) } -/// Brings the one window back in front of the user: out of the Dock or the -/// taskbar if it was minimised, and focused. What a second launch, a click on -/// the Dock icon and a double-clicked file all end with. +/// Brings a window back in front of the user: out of the Dock or the taskbar +/// if it was minimised, and focused. What a second launch and a click on the +/// Dock icon end with — the window the user was last in, of however many. fn surface(app: &tauri::AppHandle) { - if let Some(window) = app.get_webview_window("main") { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); + if let Some(label) = windows::target_window(app) { + windows::surface_window(app, &label); } } @@ -290,14 +253,7 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { surface(app); let cwd = Path::new(&cwd); - let paths = path_args(argv.clone(), cwd); - let folders = folder_args(argv, cwd); - if !paths.is_empty() || !folders.is_empty() { - queue_paths(app, paths); - queue_folders(app, folders); - // No payload: the event only says "there is something to take". - let _ = app.emit("open-path", ()); - } + hand_over(app, path_args(argv.clone(), cwd), folder_args(argv, cwd)); })) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_persisted_scope::init()) @@ -308,9 +264,20 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(navigation_guard()) - .manage(PendingPaths::default()) - .manage(PendingFolders::default()) + .manage(windows::Windows::default()) .manage(watch::Watcher::default()) + // Which window the user is in decides where a file from outside goes, + // and a window that has gone must not still be holding files. + .on_window_event(|window, event| match event { + tauri::WindowEvent::Focused(true) => { + windows::remember_focus(window.app_handle(), window.label()); + } + tauri::WindowEvent::Destroyed => { + windows::drop_window(window.app_handle(), window.label()); + watch::forget(window.app_handle(), window.label()); + } + _ => {} + }) .setup(|app| { // Idempotent, and only for a real build: a `tauri dev` run would // otherwise point every `.md` at target/debug (spec §13). @@ -318,8 +285,11 @@ pub fn run() { assoc::register_quietly(); let cwd = std::env::current_dir().unwrap_or_default(); let argv: Vec = std::env::args().collect(); - queue_paths(app.handle(), path_args(argv.clone(), &cwd)); - queue_folders(app.handle(), folder_args(argv, &cwd)); + hand_over( + app.handle(), + path_args(argv.clone(), &cwd), + folder_args(argv, &cwd), + ); // Our own folder is ours to read and write, wherever it is. The // capability grants %APPDATA%, so portable mode would otherwise // leave the front end able to write drafts through Rust but not @@ -346,15 +316,21 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - take_pending_paths, - take_pending_folders, allow_asset_dir, assoc::register_file_association, assoc::unregister_file_association, assoc::file_association_registered, data_path, - exit_app, print_page, + windows::new_window, + windows::take_opening, + windows::focus_window, + windows::set_open_docs, + windows::holding_window, + windows::show_doc_in, + windows::put_session, + windows::forget_session, + windows::request_quit, fs::read_file, fs::canonical_path, fs::write_file_atomic, @@ -380,15 +356,19 @@ pub fn run() { .expect("error while building Plain") .run(|_app, _event| { // Anything that asks the app to go without a code — held back and - // handed to the same scenario as `⌘Q` (review #1). But only while - // there is a window to answer: the same event comes when the last - // window is destroyed, which is how the window's X ends after its - // own question. Preventing the exit then left the process alive - // with no window, nobody to receive the event, and no way to get - // a window back from the Dock or from a double-clicked file. + // handed to the same scenario as `⌘Q` (review #1). Every window + // gets the question, and the app goes when the last of them has + // closed itself. + // + // Only while there is a window to answer: the same event comes + // when the last window is destroyed, which is how the window's X + // ends after its own question. Preventing the exit then left the + // process alive with no window, nobody to receive the event, and + // no way to get a window back from the Dock or a double-clicked + // file. #[cfg(target_os = "macos")] if let tauri::RunEvent::ExitRequested { api, code, .. } = &_event { - if code.is_none() && _app.get_webview_window("main").is_some() { + if code.is_none() && !_app.webview_windows().is_empty() { api.prevent_exit(); let _ = _app.emit(QUIT_REQUESTED, ()); } @@ -403,27 +383,19 @@ pub fn run() { // Finder does not pass a path in argv: it sends the app an Apple // Event, which Tauri turns into this (spec §13a). Same queue the // command line uses, same nudge to the front end — and the window - // comes forward, out of the Dock if it was minimised there. + // it goes to comes forward, out of the Dock if it was minimised. #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = _event { surface(_app); - let paths: Vec = urls - .iter() - .filter_map(|url| url.to_file_path().ok()) - .filter(|path| path.is_file()) - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - let folders: Vec = urls + let files: Vec = urls .iter() .filter_map(|url| url.to_file_path().ok()) - .filter(|path| path.is_dir()) - .map(|path| path.to_string_lossy().into_owned()) .collect(); - if !paths.is_empty() || !folders.is_empty() { - queue_paths(_app, paths); - queue_folders(_app, folders); - let _ = _app.emit("open-path", ()); - } + let name = |path: &PathBuf| path.to_string_lossy().into_owned(); + let of = |want: fn(&PathBuf) -> bool| -> Vec { + files.iter().filter(|path| want(path)).map(name).collect() + }; + hand_over(_app, of(|path| path.is_file()), of(|path| path.is_dir())); } }); } diff --git a/src-tauri/src/watch.rs b/src-tauri/src/watch.rs index c853069..5dec491 100644 --- a/src-tauri/src/watch.rs +++ b/src-tauri/src/watch.rs @@ -1,8 +1,13 @@ -// One debounced watcher for the library root and for the folders of files -// that are open outside it (spec §6). Whatever it sees goes to the front end -// as a single `fs-change` event; deciding what a change means is the front -// end's job, because only it knows the hash the buffer came from. +// One debounced watcher per window, for its library root and for the folders +// of the files it has open outside it (spec §6). Whatever it sees goes to +// that window as a single `fs-change` event; deciding what a change means is +// the front end's job, because only it knows the hash the buffer came from. +// +// One watcher each, rather than one covering them all: a window only ever +// hears about its own files, and a window opening a document does not make +// every other window's library be indexed again. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; @@ -10,10 +15,13 @@ use std::time::Duration; use notify::{RecommendedWatcher, RecursiveMode}; use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, RecommendedCache}; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, Runtime, State, WebviewWindow}; +type Running = Debouncer; + +/// Window label -> the watcher running for it. #[derive(Default)] -pub struct Watcher(Mutex>>); +pub struct Watcher(Mutex>); #[derive(Clone, Serialize)] struct Change { @@ -49,19 +57,13 @@ fn under(folder: &Path, root: &Path) -> bool { } } -/// Restarts the watcher over the given root and files. Called again whenever -/// the library or the set of open documents changes. -#[tauri::command] -pub fn watch( - app: AppHandle, +/// A watcher over one window's root and files, reporting to that window. +fn build( + app: &AppHandle, + label: String, root: Option, files: Vec, - state: State<'_, Watcher>, -) -> Result<(), String> { - let mut slot = state.0.lock().map_err(|error| error.to_string())?; - // Dropping the old one stops its thread; there is only ever one. - *slot = None; - +) -> Result { let handle = app.clone(); let mut debouncer = new_debouncer( Duration::from_millis(150), @@ -81,7 +83,9 @@ pub fn watch( } } if !paths.is_empty() { - let _ = handle.emit("fs-change", Change { paths }); + // To that window alone: the others have their own watchers + // and their own libraries, and a change here is not theirs. + let _ = handle.emit_to(&label, "fs-change", Change { paths }); } }, ) @@ -117,17 +121,46 @@ pub fn watch( .map_err(|error| error.to_string())?; } - *slot = Some(debouncer); + Ok(debouncer) +} + +/// Restarts this window's watcher over the given root and files. Called +/// again whenever its library or its set of open documents changes. +/// +/// `async`, and so off the main thread: starting a watcher walks the whole +/// tree to index it, which on a large library is long enough to be seen. On +/// the main thread that is the window not drawing for as long as it takes. +#[tauri::command] +pub async fn watch( + app: AppHandle, + window: WebviewWindow, + root: Option, + files: Vec, + state: State<'_, Watcher>, +) -> Result<(), String> { + let label = window.label().to_string(); + let running = build(&app, label.clone(), root, files)?; + let mut watchers = state.0.lock().map_err(|error| error.to_string())?; + // Dropping the old one stops its thread; there is only ever one per + // window, and the new one is already watching before it goes. + watchers.insert(label, running); Ok(()) } #[tauri::command] -pub fn unwatch(state: State<'_, Watcher>) -> Result<(), String> { - let mut slot = state.0.lock().map_err(|error| error.to_string())?; - *slot = None; +pub async fn unwatch(window: WebviewWindow, state: State<'_, Watcher>) -> Result<(), String> { + let mut watchers = state.0.lock().map_err(|error| error.to_string())?; + watchers.remove(window.label()); Ok(()) } +/// A window that has gone stops being watched, whether it said so or not. +pub fn forget(app: &tauri::AppHandle, label: &str) { + if let Ok(mut watchers) = app.state::().0.lock() { + watchers.remove(label); + } +} + #[cfg(test)] mod tests { use super::{ignored, under}; diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs new file mode 100644 index 0000000..bc16eaf --- /dev/null +++ b/src-tauri/src/windows.rs @@ -0,0 +1,421 @@ +// More than one window, and the little each one has to know about the others. +// +// A window is its own webview with its own store, so almost nothing is +// shared. What is here is only the four things that would otherwise be +// fought over: which window a launch argument belongs to, which window +// already has a file open, the slice of state.json each one owns, and the +// labels themselves. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Mutex; + +use serde::Serialize; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, Manager, Runtime, State, WebviewWindow}; + +/// What the front end answers by running the ordinary close-everything +/// scenario, dialog and all — in every window (spec §8). +pub const QUIT_REQUESTED: &str = "plain:quit-requested"; + +/// Told to the window that already holds a file, so it brings that document +/// forward instead of a second window opening it a second time. +const ACTIVATE_DOC: &str = "plain:activate-doc"; + +/// What was open and where you were in it (spec §8). Rust writes it because +/// every window owns one slice and no window can see the others'. +const SESSION_FILE: &str = "state.json"; + +/// A new window sits this far down and to the right of the one it came from. +const CASCADE: f64 = 28.0; + +/// The first window is the one tauri.conf.json builds, and it is called +/// `main` there. The rest are numbered from it, so a window that comes back +/// from the session gets the label it had last time — which is what lets the +/// window-state plugin give it its old size and place. +fn label_for(index: u32) -> String { + if index == 0 { + "main".to_string() + } else { + format!("w{}", index + 1) + } +} + +fn index_of(label: &str) -> Option { + if label == "main" { + return Some(0); + } + let number: u32 = label.strip_prefix('w')?.parse().ok()?; + number.checked_sub(1) +} + +/// What a window is handed at birth: the paths a launch or a second instance +/// asked it to open, and the slice of the session it comes back from. Drained +/// once, by the window itself, as it starts. +#[derive(Default, Clone, Serialize)] +pub struct Opening { + pub paths: Vec, + pub folders: Vec, + pub session: Option, +} + +/// Which window has a document open, and under which spelling of its path. +#[derive(Serialize)] +pub struct Holder { + label: String, + key: String, +} + +#[derive(Default)] +pub struct Windows(Mutex); + +#[derive(Default)] +struct Registry { + /// The next label to hand out. Never goes back inside one run: the + /// window-state plugin keeps geometry per label, and a reused label would + /// drop a new window exactly where the one that just closed had been. + next: u32, + opening: HashMap, + /// label -> the path keys that window has open. One file is one buffer, + /// one draft and one save, so it may only live in one window (spec §8). + docs: HashMap>, + /// Window index -> its slice of state.json, so the windows are written + /// out in the order they were made. + sessions: BTreeMap, + /// Where a file from Finder or from a second launch goes. Not whichever + /// window happens to ask first, and not nowhere when the app is behind + /// another one and no window is focused at all. + focused: Option, +} + +impl Registry { + fn next_label(&mut self) -> String { + // Index 0 is `main`, which tauri.conf.json built before any of this. + self.next = self.next.max(1); + let index = self.next; + self.next += 1; + label_for(index) + } + + /// The window a path should go to: the focused one, else the first there + /// is, so a launch argument is never parked where nobody will look. + fn target(&self, app: &AppHandle) -> Option { + if let Some(label) = self.focused.as_ref() { + if app.get_webview_window(label).is_some() { + return Some(label.clone()); + } + } + app.webview_windows().keys().next().cloned() + } +} + +/* --------------------------------------------------------------- session */ + +/// Every window's slice, in the order the windows were made. Version 2 is +/// this list; version 1 was the single object one window wrote (spec §8). +fn document(registry: &Registry) -> Value { + json!({ + "version": 2, + "windows": registry.sessions.values().cloned().collect::>(), + }) +} + +/// Losing the session is not worth a complaint — a fresh start is not a bug. +fn write_session(app: &AppHandle, registry: &Registry) { + let path = crate::data_dir(app).join(SESSION_FILE); + let _ = crate::fs::write_text_atomic( + path.to_string_lossy().into_owned(), + document(registry).to_string(), + ); +} + +/// The window says what to keep for it, and the file is written with every +/// other window's slice still in place. +#[tauri::command] +pub fn put_session( + app: AppHandle, + window: WebviewWindow, + session: Value, + state: State<'_, Windows>, +) -> Result<(), String> { + let Some(index) = index_of(window.label()) else { + return Ok(()); + }; + let mut registry = state.0.lock().map_err(|error| error.to_string())?; + registry.sessions.insert(index, session); + write_session(&app, ®istry); + Ok(()) +} + +/// A window that was closed on purpose does not come back next time. +/// Quitting does not come through here: every window is meant to return then. +/// +/// The last window is the exception. Closing it is how the app is quit on +/// Windows and how `file → exit` ends everywhere, and quitting comes back +/// where it left off — so the last one out keeps its entry (spec §8). +#[tauri::command] +pub fn forget_session( + app: AppHandle, + window: WebviewWindow, + state: State<'_, Windows>, +) -> Result<(), String> { + let Some(index) = index_of(window.label()) else { + return Ok(()); + }; + let mut registry = state.0.lock().map_err(|error| error.to_string())?; + // The window asking is still open — it closes once this has answered — + // so anything above one means there is somebody else to come back to. + if app.webview_windows().len() > 1 { + registry.sessions.remove(&index); + } + write_session(&app, ®istry); + Ok(()) +} + +/* --------------------------------------------------------------- windows */ + +/// Built from the window in tauri.conf.json, so the second window is the same +/// window as the first: same size, same drag and drop, same everything but +/// the label and where it sits. +fn build(app: &AppHandle, label: &str) -> tauri::Result> { + let mut config = app + .config() + .app + .windows + .first() + .cloned() + .unwrap_or_default(); + config.label = label.to_string(); + tauri::WebviewWindowBuilder::from_config(app, &config)?.build() +} + +/// Down and to the right of the window it came from — landing exactly on top +/// looks like nothing happened. Only for a window that is genuinely new: one +/// coming back from the session already has a place of its own. +fn cascade(from: &WebviewWindow, to: &WebviewWindow) { + let (Ok(position), Ok(scale)) = (from.outer_position(), from.scale_factor()) else { + return; + }; + let step = (CASCADE * scale) as i32; + let _ = to.set_position(tauri::PhysicalPosition::new( + position.x + step, + position.y + step, + )); +} + +/// `file → new window`, and the way the session brings back the windows it +/// had. The new window drains what it was given as it starts. +/// +/// `restoring` is the difference between the two: a window coming back from +/// state.json has a place of its own already, and one the user just asked +/// for has to be put somewhere the window it came from is not. +#[tauri::command] +pub fn new_window( + app: AppHandle, + window: WebviewWindow, + session: Option, + paths: Vec, + restoring: bool, + state: State<'_, Windows>, +) -> Result { + // The lock is held only long enough to claim a label and park what the + // window is to open: building the window itself must not hold it, and + // the window drains its parcel from inside `build`. + let label = { + let mut registry = state.0.lock().map_err(|error| error.to_string())?; + let label = registry.next_label(); + registry.opening.insert( + label.clone(), + Opening { + paths, + folders: Vec::new(), + session, + }, + ); + label + }; + + let built = build(&app, &label).map_err(|error| error.to_string())?; + if !restoring { + cascade(&window, &built); + } + Ok(label) +} + +/// Everything the window was given at birth, taken once. +#[tauri::command] +pub fn take_opening(window: WebviewWindow, state: State<'_, Windows>) -> Opening { + state + .0 + .lock() + .map(|mut registry| registry.opening.remove(window.label()).unwrap_or_default()) + .unwrap_or_default() +} + +/// Brings a window forward: out of the Dock or the taskbar if it was +/// minimised, and focused. +pub fn surface_window(app: &AppHandle, label: &str) { + if let Some(window) = app.get_webview_window(label) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[tauri::command] +pub fn focus_window(app: AppHandle, label: String) { + surface_window(&app, &label); +} + +/// Whichever window a path from outside should go to. +pub fn target_window(app: &AppHandle) -> Option { + app.state::() + .0 + .lock() + .ok() + .and_then(|registry| registry.target(app)) +} + +/// Parks paths and folders on one window and returns whether there is +/// anything for it to take. +pub fn queue_for( + app: &AppHandle, + label: &str, + paths: Vec, + folders: Vec, +) -> bool { + if paths.is_empty() && folders.is_empty() { + return false; + } + let state = app.state::(); + let Ok(mut registry) = state.0.lock() else { + return false; + }; + let opening = registry.opening.entry(label.to_string()).or_default(); + opening.paths.extend(paths); + opening.folders.extend(folders); + true +} + +/// Remembers which window took the focus last, so a file from Finder has +/// somewhere to go even when the app itself is in the background. +pub fn remember_focus(app: &AppHandle, label: &str) { + if let Ok(mut registry) = app.state::().0.lock() { + registry.focused = Some(label.to_string()); + } +} + +/// A window that has gone takes its share of the registry with it. Its +/// session slice does not: closing on purpose says so through +/// `forget_session`, and quitting means to keep every window. +pub fn drop_window(app: &AppHandle, label: &str) { + if let Ok(mut registry) = app.state::().0.lock() { + registry.opening.remove(label); + registry.docs.remove(label); + if registry.focused.as_deref() == Some(label) { + registry.focused = None; + } + } +} + +/* ----------------------------------------------------- one file, one window */ + +/// The window says what it has open, by the same path keys the front end +/// compares documents with. +#[tauri::command] +pub fn set_open_docs( + window: WebviewWindow, + keys: Vec, + state: State<'_, Windows>, +) -> Result<(), String> { + let mut registry = state.0.lock().map_err(|error| error.to_string())?; + registry.docs.insert(window.label().to_string(), keys); + Ok(()) +} + +/// Which other window already has one of these files open. Two windows with +/// the same file would be two buffers, two drafts under one name and two +/// saves racing each other, so the answer is a window to bring forward +/// instead of a file to open again (spec §8). +#[tauri::command] +pub fn holding_window( + app: AppHandle, + window: WebviewWindow, + keys: Vec, + state: State<'_, Windows>, +) -> Result, String> { + let registry = state.0.lock().map_err(|error| error.to_string())?; + let mine = window.label(); + let mut held = Vec::new(); + for key in keys { + for (label, open) in ®istry.docs { + // A window that has gone still answers here until its close + // event lands; asking Tauri is what makes it a live answer. + if label == mine || app.get_webview_window(label).is_none() { + continue; + } + if open.iter().any(|open| open == &key) { + held.push(Holder { + label: label.clone(), + key, + }); + break; + } + } + } + Ok(held) +} + +/// Brings that window forward and tells it which document to show. +#[tauri::command] +pub fn show_doc_in(app: AppHandle, label: String, key: String) -> Result<(), String> { + surface_window(&app, &label); + app.emit_to(&label, ACTIVATE_DOC, key) + .map_err(|error| error.to_string()) +} + +/* ------------------------------------------------------------------ quit */ + +/// `file → exit`, and on macOS `⌘Q` and the Dock's Quit. Every window runs +/// its own unsaved-changes question; the app goes when the last of them has +/// closed itself (spec §8). +#[tauri::command] +pub fn request_quit(app: AppHandle) { + let _ = app.emit(QUIT_REQUESTED, ()); +} + +#[cfg(test)] +mod tests { + use super::{index_of, label_for, Registry}; + + /// The label a window gets is a function of its place in the session, so + /// the same window comes back under the same name — which is how the + /// window-state plugin knows where to put it. + #[test] + fn labels_and_indexes_are_the_same_thing_twice() { + assert_eq!(label_for(0), "main"); + assert_eq!(label_for(1), "w2"); + assert_eq!(label_for(9), "w10"); + for index in 0..12u32 { + assert_eq!(index_of(&label_for(index)), Some(index)); + } + } + + /// Anything that is not one of ours has no slice of the session, and must + /// not be given somebody else's. + #[test] + fn a_label_we_did_not_make_has_no_index() { + assert_eq!(index_of("w0"), None); + assert_eq!(index_of("wobble"), None); + assert_eq!(index_of("w"), None); + assert_eq!(index_of(""), None); + } + + /// A label is handed out once. Reusing one would put a new window exactly + /// where the window that just closed had been. + #[test] + fn labels_are_never_handed_out_twice() { + let mut registry = Registry::default(); + let handed: Vec = (0..4).map(|_| registry.next_label()).collect(); + assert_eq!(handed, vec!["w2", "w3", "w4", "w5"]); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 2d61e60..f4fba08 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -32,6 +32,7 @@ import { installSession } from "./session"; import { activeDoc, useStore, type Comparison, type Screen } from "./store"; import { watchSystemTheme } from "./theme"; import { installWatcher } from "./watcher"; +import { installActivateDoc, installDocRegistry, installSettingsSync } from "./windows"; /** @@ -135,6 +136,9 @@ export function App() { useEffect(() => installTags(), []); useEffect(() => installBacklinks(), []); useEffect(() => installLibraryCounts(), []); + useEffect(() => installDocRegistry(), []); + useEffect(() => installActivateDoc(), []); + useEffect(() => installSettingsSync(), []); useEffect( () => watchSystemTheme((resolved) => useStore.getState().syncSystemTheme(resolved)), diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index e575a4a..414eb53 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -1,21 +1,21 @@ -// Startup: settings, then unsaved work, then either the paths this launch -// was asked to open or the session that was open last time (spec §6, §8). +// Startup: settings, then unsaved work, then either the paths this window +// was asked to open or the session it had last time (spec §6, §8). +// +// Every window runs this. Which entry of state.json a window restores is +// settled before it starts: the first window of a run takes the first entry +// and brings the other windows back, and each of those is handed its own +// (app/windows.ts). import { listen } from "@tauri-apps/api/event"; -import { - drainPendingPaths, - openLibraryPath, - openPaths, - pendingFolders, - pendingPaths, -} from "./commands"; +import { drainPendingPaths, openLibraryPath, openPaths } from "./commands"; import { listDrafts } from "./drafts"; import { inTauri } from "./env"; import { pathKey } from "./paths"; -import { loadSession, readingPosition, type SessionFile } from "./session"; +import { flushSession, loadState, readingPosition, seedReading, type SessionFile } from "./session"; import { restoreZoom } from "./zoom"; import { loadSettings } from "./settings"; import { useStore } from "./store"; +import { isFirstWindow, restoreWindow, takeOpening } from "./windows"; import { emitGotoHeading } from "../read/events"; /** Held while the `unsaved work found` screen is up (spec §8). */ @@ -28,6 +28,7 @@ export function recoveryDone(): void { void (async () => { await next?.(); fitRail(); + await flushSession(); })(); } @@ -48,6 +49,15 @@ async function restore(session: SessionFile): Promise { } } +/** + * The windows that were open besides this one, in the order they were made. + * Each is handed its own entry, so it restores itself rather than being told + * what to show once it is up. + */ +async function restoreWindows(state: SessionFile[]): Promise { + for (const session of state.slice(1)) await restoreWindow(session); +} + export async function bootstrap(): Promise { const { settings, invalid } = await loadSettings(); const store = useStore.getState(); @@ -65,12 +75,21 @@ export async function bootstrap(): Promise { // Listener first, then drain: a second launch can signal at any moment, and // the event is only a nudge to read the queue — the paths live in Rust. await listen("open-path", () => void drainPendingPaths()); - const args = await pendingPaths(); - const folders = await pendingFolders(); + const { paths: args, folders, session: given } = await takeOpening(); + + // The first window of a run is the one that reads state.json; a window it + // brings back was handed its own entry, and one opened with `new window` + // has none and starts empty. + const state = given ? [] : await loadState(); + const first = isFirstWindow(); + const session = given ?? (first ? (state[0] ?? null) : null); + + // The reading positions are the app's, not this window's, so a window with + // no entry of its own still takes them from the one that has them. + seedReading(session ?? state[0] ?? null); // The library, the recent list and the reading positions come back either // way; only the list of open files depends on the arguments (spec §6). - const session = await loadSession(); if (session) { if (session.library) await openLibraryPath(session.library, false); store.setCollapsed(session.collapsed); @@ -94,9 +113,17 @@ export async function bootstrap(): Promise { } if (folder) return; if (session && session.files.length > 0) await restore(session); + // Only now, and only for a window that came back as itself: a launch + // that asked for a file asked for that file, not for yesterday's + // windows, exactly as it already replaces yesterday's documents. + if (first && !given) await restoreWindows(state); }; - const drafts = await listDrafts(); + // The drafts folder is the app's, not this window's, so only the first + // window of a run offers what is in it. Three windows each offering to + // restore the same unsaved file would be three chances to answer the same + // question, and two of them wrong (spec §8). + const drafts = first && !given ? await listDrafts() : []; if (drafts.length > 0) { afterRecovery = open; store.setRecovery(drafts); @@ -104,6 +131,10 @@ export async function bootstrap(): Promise { } await open(); fitRail(); + // Rust now knows what this window holds even if nothing in it ever + // changes: without this an empty window would have no entry in state.json, + // and closing another window would write the file without it. + await flushSession(); } /** diff --git a/src/app/close.ts b/src/app/close.ts index 2ee5daf..732d151 100644 --- a/src/app/close.ts +++ b/src/app/close.ts @@ -2,17 +2,19 @@ // one explicit answer, and the answer has to still be true when the buffer // is actually thrown away (spec §8). -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { dropBuffer, flushActiveEditor, isDirty } from "../editor"; import { draftsSettled, dropDraft } from "./drafts"; import { inTauri } from "./env"; import { save } from "./save"; -import { toSession, writeSession } from "./session"; +import { dropSession, toSession, writeSession } from "./session"; import { flushSettings } from "./settings"; import { useStore, type Doc } from "./store"; +/** Rust asks every window for the close scenario under this name. */ +const QUIT_REQUESTED = "plain:quit-requested"; + /** Always measured on live editor text, never on a stale `Doc`. */ function unsaved(ids: string[]): Doc[] { flushActiveEditor(); @@ -125,12 +127,18 @@ export function closeActive(): void { } /** - * Everything that ends the app goes through here: the window's X, `Alt+F4`, - * and on macOS `⌘Q` and the Dock's Quit — Rust holds those back and asks for - * this instead, because native `terminate:` would take the unsaved text with - * it (review #1). + * Everything that closes a window goes through here: its X, `Alt+F4`, and on + * macOS `⌘Q` and the Dock's Quit — Rust holds those back and asks every + * window for this instead, because native `terminate:` would take the + * unsaved text with it (review #1). + * + * `keep` is the difference between the two. Quitting means to come back + * where you left off, so the window writes its entry in state.json; closing + * one window of several says that window is done, so its entry goes. The + * last window is quitting whichever way it is closed, and Rust knows that + * without being told (session.ts). */ -function leave(finish: () => Promise, leaving: { yes: boolean }): void { +function leave(finish: () => Promise, leaving: { yes: boolean }, keep: boolean): void { if (leaving.yes) return; const ids = useStore.getState().docs.map((d) => d.id); let session = toSession(); @@ -144,12 +152,17 @@ function leave(finish: () => Promise, leaving: { yes: boolean }): void { }, done: () => { leaving.yes = true; - void Promise.all([writeSession(session), flushSettings()]).then(finish); + const written = keep ? writeSession(session) : dropSession(); + void Promise.all([written, flushSettings()]).then(finish); }, }); } -/** The window's X and Alt+F4 go through the same one question. */ +/** + * The window's X and Alt+F4 go through the same one question, and so does + * quitting — in every window at once. Each window answers for its own + * documents and closes itself; the app goes when the last one has. + */ export function installCloseGuard(): () => void { if (!inTauri) return () => undefined; const leaving = { yes: false }; @@ -158,15 +171,14 @@ export function installCloseGuard(): () => void { const unlisten = window.onCloseRequested((event) => { if (leaving.yes) return; event.preventDefault(); - leave(async () => window.destroy(), leaving); + leave(async () => window.destroy(), leaving, false); }); - // `⌘Q`, the Dock's Quit and anything else macOS routes to `terminate:`. - // Rust prevented the exit and asked; the app goes when we say so. - const unlistenQuit = listen("plain:quit-requested", () => { - leave(async () => { - await invoke("exit_app").catch(() => undefined); - }, leaving); + // `⌘Q`, the Dock's Quit, `file → exit`, and anything else macOS routes to + // `terminate:`. Rust prevented the exit and asked; the app goes when the + // last window has closed itself. + const unlistenQuit = listen(QUIT_REQUESTED, () => { + leave(async () => window.destroy(), leaving, true); }); return () => { diff --git a/src/app/commands.ts b/src/app/commands.ts index 9c2b4b4..db45654 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -17,18 +17,43 @@ import { newFile } from "../library/ops"; import { copyText, isMac } from "./platform"; import { basename, dirname, pathKey } from "./paths"; import { reloadFromDisk } from "./save"; +import { toSession } from "./session"; import { serializeSettings, settingsPath } from "./settings"; import { activeDoc, makeDoc, useStore, type Doc } from "./store"; +import { + heldElsewhere, + label, + newWindow, + requestQuit, + showDocIn, + takeOpening, + type Holder, +} from "./windows"; /** * Reads each path into the open list; the first one becomes active. * Returns whether anything ended up open. + * + * A file another window already has is not read a second time: two windows + * with one file would be two buffers, two drafts under the same name and two + * saves racing each other (spec §8). That window is brought forward instead — + * but only if this one has nothing of its own to show, or asking for three + * files would throw the focus somewhere else over the one of them that had + * moved out. */ export async function openPaths(paths: string[]): Promise { const { activate, openDoc, showBanner } = useStore.getState(); let opened = false; + const held = await heldElsewhere(paths.map(pathKey)); + /** The first file that turned out to live in another window. */ + let moved: { holder: Holder; path: string } | null = null; for (const path of paths) { + const label = held.get(pathKey(path)); + if (label) { + moved ??= { holder: { label, key: pathKey(path) }, path }; + continue; + } // The same spelling is already open: show it, and do not read the file // a second time. const known = useStore.getState().docs.find((d) => d.id === pathKey(path)); @@ -79,6 +104,15 @@ export async function openPaths(paths: string[]): Promise { // purpose — the rail, quick search and `recent` all come through here. useStore.getState().closeScreen(); } + if (moved) { + // Nothing of ours to show: go to the window that has it. Otherwise stay + // where we are and say where the other file went. + if (opened) { + useStore.getState().setMessage(`${basename(moved.path)} is open in another window`); + } else { + await showDocIn(moved.holder); + } + } return opened; } @@ -107,12 +141,14 @@ export function newDoc(): void { void newFile(store.libraryPath); return; } - // Unique across runs as well as within one: a restored draft keeps the id - // it was written under, and two sessions must not collide on it (spec §8). + // Unique across runs as well as within one, and across windows as well as + // within one: a restored draft keeps the id it was written under, and two + // sessions — or two windows counting from one each — must not collide on + // it (spec §8). untitled += 1; store.openDoc( makeDoc({ - id: `untitled-${Date.now().toString(36)}-${untitled}`, + id: `untitled-${label()}-${Date.now().toString(36)}-${untitled}`, path: null, text: "", mode: "edit", @@ -140,23 +176,16 @@ export function refresh(): void { } /** - * Takes whatever Rust parked for us — launch arguments, or the argv of a - * second launch. Opening a file this way collapses the rail (spec §6). + * Takes whatever Rust parked for this window — launch arguments, or the argv + * of a second launch. Only the window the paths were addressed to is nudged, + * so a file from Finder opens once and in the window the user was in, rather + * than in all of them at once. Opening a file this way collapses the rail + * (spec §6). */ -export async function pendingPaths(): Promise { - return inTauri ? invoke("take_pending_paths") : []; -} - -/** Folder arguments, parked separately: a folder is a library (spec §6). */ -export async function pendingFolders(): Promise { - return inTauri ? invoke("take_pending_folders") : []; -} - export async function drainPendingPaths(): Promise { - const folders = await pendingFolders(); + const { paths, folders } = await takeOpening(); const first = folders[0]; if (first) await openLibraryPath(first); - const paths = await pendingPaths(); if (paths.length === 0) return; // A file on its own arrives with the rail out of the way; a file that came // with its folder does not, because the folder is the point. @@ -368,12 +397,29 @@ export async function showAbout(): Promise { }); } -/** `file → exit`: the same one question as the window's X (spec §8). */ +/** + * `file → exit`: the same one question as the window's X, in every window + * there is. The app goes when the last of them has closed itself (spec §8). + */ export async function exitApp(): Promise { + await requestQuit(); +} + +/** `file → close window`: this window only, and its documents with it. */ +export async function closeWindow(): Promise { if (!inTauri) return; await getCurrentWindow().close(); } +/** + * `file → new window`. The same library, because a second window on the same + * folder is what it is usually for; none of the documents, because the file + * you want there is not the one already showing here. + */ +export async function openNewWindow(): Promise { + await newWindow({ ...toSession(), files: [], active: null }); +} + /* -------------------------------------------------- plain text (wave 5b) */ /** Chrome the reader sees but a paste should not carry. */ diff --git a/src/app/menu.ts b/src/app/menu.ts index 756b6d6..f304704 100644 --- a/src/app/menu.ts +++ b/src/app/menu.ts @@ -151,6 +151,7 @@ export function menuModel(): MenuSection[] { label: "file", items: [ entry("file.new"), + entry("file.newWindow"), entry("file.open"), entry("file.openLibrary"), { kind: "submenu", key: "recent", label: "recent", items: recentItems() }, @@ -174,6 +175,7 @@ export function menuModel(): MenuSection[] { entry("file.history"), separator(), entry("file.close"), + entry("file.closeWindow"), entry("file.exit"), ], }, diff --git a/src/app/registry.ts b/src/app/registry.ts index 4335667..6d719ff 100644 --- a/src/app/registry.ts +++ b/src/app/registry.ts @@ -3,6 +3,7 @@ // or a title is written exactly once (spec §4, §12). import { + closeWindow, copyPath, copyPlainText, exitApp, @@ -10,6 +11,7 @@ import { exportPdf, exportPlainText, newDoc, + openNewWindow, openFile, openFolderSearch, openLibrary, @@ -99,6 +101,12 @@ const inEdit = () => { export const commands: Command[] = [ /* ------------------------------------------------------------- file */ { id: "file.new", title: "new file", chord: "Ctrl+N", run: () => newDoc() }, + { + id: "file.newWindow", + title: "new window", + chord: "Ctrl+Shift+N", + run: () => void openNewWindow(), + }, { id: "file.open", title: "open file…", chord: "Ctrl+O", run: () => void openFile() }, { id: "file.openLibrary", @@ -154,6 +162,12 @@ export const commands: Command[] = [ when: hasDoc, }, { id: "file.close", title: "close", chord: "Ctrl+W", run: () => closeActive(), when: hasDoc }, + { + id: "file.closeWindow", + title: "close window", + chord: "Ctrl+Shift+W", + run: () => void closeWindow(), + }, { id: "file.reload", title: "reload from disk", diff --git a/src/app/session.test.ts b/src/app/session.test.ts index 880c982..e0d87f3 100644 --- a/src/app/session.test.ts +++ b/src/app/session.test.ts @@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import { clearBuffers } from "../editor/buffers"; import { READING_LIMIT, - loadSession, + loadState, parseSession, + parseState, readingPosition, rememberReading, toSession, @@ -112,7 +113,56 @@ describe("state.json", () => { }); it("does nothing outside Tauri", async () => { - await expect(loadSession()).resolves.toBeNull(); + await expect(loadState()).resolves.toEqual([]); + }); +}); + +describe("state.json, one entry per window", () => { + function slice(path: string): string { + return JSON.stringify({ files: [{ path, mode: "read", caret: null }] }); + } + + it("reads every window back, in the order they were made", () => { + const text = JSON.stringify({ + version: 2, + windows: [JSON.parse(slice("C:/a.md")), JSON.parse(slice("C:/b.md"))], + }); + const back = parseState(text); + expect(back).toHaveLength(2); + expect(back[0]?.files[0]?.path).toBe("C:/a.md"); + expect(back[1]?.files[0]?.path).toBe("C:/b.md"); + }); + + /** + * Version 1 was the single object one window wrote. Upgrading must not be + * the moment somebody's open files disappear. + */ + it("reads a version 1 file as the one window that wrote it", () => { + const back = parseState(slice("C:/old.md")); + expect(back).toHaveLength(1); + expect(back[0]?.files[0]?.path).toBe("C:/old.md"); + }); + + it("treats a broken file as no windows at all", () => { + expect(parseState("not json")).toEqual([]); + expect(parseState("null")).toEqual([]); + }); + + it("drops an entry it cannot use and keeps the rest", () => { + const text = JSON.stringify({ + version: 2, + windows: [null, JSON.parse(slice("C:/b.md")), 7], + }); + const back = parseState(text); + expect(back).toHaveLength(1); + expect(back[0]?.files[0]?.path).toBe("C:/b.md"); + }); + + /** A window that saved nothing is still a window, and comes back empty. */ + it("keeps a window with nothing open in it", () => { + const text = JSON.stringify({ version: 2, windows: [{}] }); + expect(parseState(text)).toHaveLength(1); + expect(parseState(text)[0]?.files).toEqual([]); }); }); diff --git a/src/app/session.ts b/src/app/session.ts index f6cb2ef..af1bc26 100644 --- a/src/app/session.ts +++ b/src/app/session.ts @@ -1,10 +1,15 @@ // %APPDATA%\Plain\state.json — what was open and where you were in it // (spec §8). Window geometry is not here: the window-state plugin owns it. +// +// One entry per window. A window only ever knows its own, so the file itself +// is assembled and written in Rust (src-tauri/src/windows.rs), which is the +// one place that can see them all. Version 1 was the single object one +// window wrote, and still reads as the one window it was. +import { invoke } from "@tauri-apps/api/core"; import { join } from "@tauri-apps/api/path"; import { exists, readTextFile } from "@tauri-apps/plugin-fs"; import { inTauri } from "./env"; -import { writeTextAtomic } from "./fs"; import { pathKey } from "./paths"; import { dataDir } from "./settings"; import { clampRailWidth, useStore, type LibrarySort, type Mode, type RailView } from "./store"; @@ -93,12 +98,15 @@ export function toSession(): SessionFile { /** Anything unreadable is simply "no session"; a fresh start is not a bug. */ export function parseSession(text: string): SessionFile | null { - let raw: unknown; try { - raw = JSON.parse(text); + return shapeSession(JSON.parse(text)); } catch { return null; } +} + +/** One window's entry, from an already-parsed value. */ +function shapeSession(raw: unknown): SessionFile | null { if (!raw || typeof raw !== "object") return null; const value = raw as Partial; const files = Array.isArray(value.files) ? value.files : []; @@ -148,35 +156,73 @@ export function parseSession(text: string): SessionFile | null { }; } -export async function loadSession(): Promise { - if (!inTauri) return null; +/** + * Every window's entry, oldest window first. Version 1 was one object rather + * than a list, and reads as the single window that wrote it. + */ +export function parseState(text: string): SessionFile[] { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return []; + } + if (!raw || typeof raw !== "object") return []; + const windows = (raw as { windows?: unknown }).windows; + if (!Array.isArray(windows)) { + const one = shapeSession(raw); + return one ? [one] : []; + } + return windows.map(shapeSession).filter((one): one is SessionFile => one !== null); +} + +export async function loadState(): Promise { + if (!inTauri) return []; try { const file = await join(await dataDir(), FILE); - if (!(await exists(file))) return null; - const session = parseSession(await readTextFile(file)); - if (session) { - positions.clear(); - for (const [path, heading] of session.reading) positions.set(path, heading); - } - return session; + if (!(await exists(file))) return []; + return parseState(await readTextFile(file)); } catch { - return null; + return []; } } +/** + * The reading positions this window starts from. They are one list for the + * whole app rather than one per window, so a window with no entry of its own + * still takes them from whichever window has them (spec §8). + */ +export function seedReading(session: SessionFile | null): void { + positions.clear(); + for (const [path, heading] of session?.reading ?? []) positions.set(path, heading); +} + /** * Takes the session it is given, because closing the window empties the open * list before the app is allowed to go — the snapshot has to be older. + * + * Rust keeps this window's entry and writes the file with every other + * window's entry still in it. */ export async function writeSession(session: SessionFile): Promise { if (!inTauri) return; clearTimeout(timer); timer = undefined; - try { - await writeTextAtomic(await join(await dataDir(), FILE), JSON.stringify(session)); - } catch { + await invoke("put_session", { session }).catch(() => { /* losing the session is not worth a banner */ - } + }); +} + +/** + * A window closed on purpose does not come back next time. The last window + * is the exception, and Rust makes it: closing the last window is how the + * app is quit on Windows, and quitting comes back where it left off. + */ +export async function dropSession(): Promise { + if (!inTauri) return; + clearTimeout(timer); + timer = undefined; + await invoke("forget_session").catch(() => undefined); } export function flushSession(): Promise { diff --git a/src/app/settings.ts b/src/app/settings.ts index 53cc503..5cbad3b 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -22,6 +22,13 @@ export interface Settings { export const SETTINGS_FILE = "settings.json"; +/** + * One file, however many windows: the window that writes it says so, and the + * others apply the same values instead of carrying on with the old ones and + * writing those back over the top (app/windows.ts). + */ +export const SETTINGS_CHANGED = "plain:settings-changed"; + /** The reading column, in px (spec §3, §10). The `−`/`+` step is the same. */ export const CONTENT_WIDTH = { min: 440, max: 1400, step: 20 } as const; @@ -232,6 +239,11 @@ async function write(settings: Settings): Promise { try { const path = await settingsPath(); await invoke("write_text_atomic", { path, text: serializeSettings(settings) }); + // Only after the file is really there: a window that applied a change it + // was told about would otherwise be showing something nothing holds. + const { emit } = await import("@tauri-apps/api/event"); + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + await emit(SETTINGS_CHANGED, { from: getCurrentWindow().label, settings }); } catch (error) { console.error("couldn't write settings.json", error); } diff --git a/src/app/windows.test.ts b/src/app/windows.test.ts new file mode 100644 index 0000000..a087807 --- /dev/null +++ b/src/app/windows.test.ts @@ -0,0 +1,129 @@ +// One file lives in one window (spec §8). Two windows on the same document +// would be two buffers, two drafts under one name and two saves racing each +// other, so opening a file another window already has brings that window +// forward instead of reading the file a second time. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileInfo } from "./fs"; + +const invoke = vi.fn<(command: string, args?: unknown) => Promise>(); +const readFile = vi.fn<(path: string) => Promise>(); + +vi.mock("./env", () => ({ inTauri: true })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (c: string, a?: unknown) => invoke(c, a) })); +vi.mock("@tauri-apps/api/event", () => ({ + emit: vi.fn(async () => undefined), + listen: vi.fn(async () => () => undefined), +})); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ label: "main" }), +})); +vi.mock("./fs", async (importOriginal) => ({ + ...(await importOriginal()), + readFile: (path: string) => readFile(path), + hashFile: vi.fn(async () => null), + writeTextAtomic: vi.fn(async () => undefined), +})); + +const { openPaths } = await import("./commands"); +const { useStore } = await import("./store"); +const { clearBuffers } = await import("../editor/buffers"); + +function fileInfo(path: string): FileInfo { + return { + path, + text: "text\n", + encoding: "utf-8", + bom: false, + eol: "lf", + dominantEol: "lf", + finalNewline: true, + hash: "hash", + mtimeMs: 0, + size: 5, + readOnly: false, + decodeErrors: false, + hardLinks: 1, + }; +} + +/** Rust's answer to `holding_window`: nothing is held anywhere else. */ +function heldByNobody(): void { + invoke.mockImplementation(async (command) => (command === "holding_window" ? [] : undefined)); +} + +/** …and its answer when another window has that one file. */ +function heldBy(label: string, key: string): void { + invoke.mockImplementation(async (command) => + command === "holding_window" ? [{ label, key }] : undefined, + ); +} + +beforeEach(() => { + invoke.mockReset(); + readFile.mockReset(); + readFile.mockImplementation(async (path) => fileInfo(path)); + clearBuffers(); + useStore.setState({ docs: [], activeId: null, recent: [], banners: [], message: "" }); +}); + +describe("opening a file another window has", () => { + it("does not read it again, and brings that window forward", async () => { + heldBy("w2", "c:/notes/a.md"); + + const opened = await openPaths(["C:/notes/a.md"]); + + expect(opened).toBe(false); + expect(readFile).not.toHaveBeenCalled(); + expect(useStore.getState().docs).toHaveLength(0); + expect(invoke).toHaveBeenCalledWith("show_doc_in", { label: "w2", key: "c:/notes/a.md" }); + }); + + /** + * The focus may only leave when there is nothing of ours to look at. + * Asking for three files and being thrown into another window over the one + * of them that had moved would lose the other two. + */ + it("stays put when it opened something of its own, and says where the rest went", async () => { + heldBy("w2", "c:/notes/taken.md"); + + const opened = await openPaths(["C:/notes/taken.md", "C:/notes/mine.md"]); + + expect(opened).toBe(true); + expect(useStore.getState().docs.map((d) => d.path)).toEqual(["C:/notes/mine.md"]); + expect(invoke).not.toHaveBeenCalledWith("show_doc_in", expect.anything()); + expect(useStore.getState().message).toContain("another window"); + }); + + it("opens it here when no other window has it", async () => { + heldByNobody(); + + const opened = await openPaths(["C:/notes/a.md"]); + + expect(opened).toBe(true); + expect(readFile).toHaveBeenCalledWith("C:/notes/a.md"); + expect(useStore.getState().docs.map((d) => d.path)).toEqual(["C:/notes/a.md"]); + }); + + /** + * The registry is Rust's, and a window that cannot reach it must still be + * able to open a file — the rule is worth less than the document. + */ + it("opens it here when the registry cannot be reached", async () => { + invoke.mockRejectedValue(new Error("no registry")); + + const opened = await openPaths(["C:/notes/a.md"]); + + expect(opened).toBe(true); + expect(useStore.getState().docs.map((d) => d.path)).toEqual(["C:/notes/a.md"]); + }); +}); + +describe("a nameless buffer", () => { + it("is named after the window that made it, so two windows cannot collide", async () => { + heldByNobody(); + const { newDoc } = await import("./commands"); + newDoc(); + expect(useStore.getState().docs[0]?.id).toMatch(/^untitled-main-/); + }); +}); diff --git a/src/app/windows.ts b/src/app/windows.ts new file mode 100644 index 0000000..78a9c74 --- /dev/null +++ b/src/app/windows.ts @@ -0,0 +1,171 @@ +// More than one window, from the front end's side (src-tauri/src/windows.rs +// holds the other one). +// +// A window is its own webview with its own store, so two windows share +// almost nothing and neither has to know what the other is showing. The +// exceptions are all here: which window a file belongs to, what a window was +// asked to open as it started, and the settings, which are one file and have +// to read the same in every window. + +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { inTauri } from "./env"; +import { normalizeSettings, SETTINGS_CHANGED, type Settings } from "./settings"; +import { useStore } from "./store"; +import type { SessionFile } from "./session"; + +/** Told to the window that already has a file open (windows.rs). */ +const ACTIVATE_DOC = "plain:activate-doc"; + +/** + * This window's label. `main` is the one tauri.conf.json builds; the rest are + * `w2`, `w3`… A browser has no windows to tell apart, so it is always the + * first one there. + */ +export function label(): string { + return inTauri ? getCurrentWindow().label : "main"; +} + +/** The window a run starts with, and the only one that restores the others. */ +export function isFirstWindow(): boolean { + return label() === "main"; +} + +/** What this window was handed at birth (windows.rs). Drained once. */ +export interface Opening { + paths: string[]; + folders: string[]; + session: SessionFile | null; +} + +export async function takeOpening(): Promise { + if (!inTauri) return { paths: [], folders: [], session: null }; + try { + return await invoke("take_opening"); + } catch { + return { paths: [], folders: [], session: null }; + } +} + +/** + * A window coming back from state.json: it keeps the place and the size it + * had, because the window-state plugin knows them under its label. + */ +export async function restoreWindow(session: SessionFile): Promise { + await open(session, [], true); +} + +/** + * `file → new window`. It starts on the same library — a second window on + * one folder is what this is usually for — with nothing open in it, and sits + * down and to the right of this one rather than exactly on top. + */ +export async function newWindow(session: SessionFile | null = null): Promise { + await open(session, [], false); +} + +async function open( + session: SessionFile | null, + paths: string[], + restoring: boolean, +): Promise { + if (!inTauri) return; + try { + await invoke("new_window", { session, paths, restoring }); + } catch (error) { + useStore.getState().setMessage(`couldn't open a window — ${String(error)}`); + } +} + +/** `⌘Q`, `file → exit`: every window is asked, and the last one out ends it. */ +export async function requestQuit(): Promise { + if (!inTauri) return; + await invoke("request_quit").catch(() => undefined); +} + +/* ------------------------------------------------- one file, one window */ + +export interface Holder { + label: string; + key: string; +} + +/** + * Which of these files another window already has open. Two windows with one + * file would be two buffers, two drafts under the same name and two saves + * racing each other, so the file is never opened twice (spec §8). + */ +export async function heldElsewhere(keys: string[]): Promise> { + if (!inTauri || keys.length === 0) return new Map(); + try { + const held = await invoke("holding_window", { keys }); + return new Map(held.map((one) => [one.key, one.label])); + } catch { + return new Map(); + } +} + +/** Brings that window forward with that document showing. */ +export async function showDocIn(holder: Holder): Promise { + if (!inTauri) return; + await invoke("show_doc_in", { label: holder.label, key: holder.key }).catch(() => undefined); +} + +/** What this window has open, so the other windows can be told. */ +export async function registerOpenDocs(keys: string[]): Promise { + if (!inTauri) return; + await invoke("set_open_docs", { keys }).catch(() => undefined); +} + +/** + * Keeps that list up to date. Only the documents with a file: a buffer that + * has never been written to disk is nobody else's to find. + */ +export function installDocRegistry(): () => void { + if (!inTauri) return () => undefined; + const report = () => { + const keys = useStore + .getState() + .docs.filter((doc) => doc.path !== null) + .map((doc) => doc.id); + void registerOpenDocs(keys); + }; + report(); + return useStore.subscribe((state, previous) => { + if (state.docs !== previous.docs) report(); + }); +} + +/** + * Another window asked us to show a document we already have. It is always + * one of ours: the asking window looked us up by it. + */ +export function installActivateDoc(): () => void { + if (!inTauri) return () => undefined; + const unlisten = listen(ACTIVATE_DOC, (event) => { + const store = useStore.getState(); + if (store.docs.some((doc) => doc.id === event.payload)) store.activate(event.payload); + }); + return () => void unlisten.then((off) => off()); +} + +/* --------------------------------------------------------------- settings */ + +/** + * settings.json is one file for the whole app, so a theme changed in one + * window changes in all of them at once. Without this the other windows keep + * showing the old value and, worse, write it back over the new one on the + * way out (spec §10). + */ +export function installSettingsSync(): () => void { + if (!inTauri) return () => undefined; + const mine = label(); + const unlisten = listen<{ from: string; settings: Settings }>(SETTINGS_CHANGED, (event) => { + if (event.payload.from === mine) return; + // Applied, not saved: the window that changed them has already written + // the file, and writing it again from here would be a loop. + useStore.getState().applySettings(normalizeSettings(event.payload.settings)); + }); + return () => void unlisten.then((off) => off()); +}