diff --git a/CHANGELOG.md b/CHANGELOG.md index aedbe6a4..7e88db89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Added - Open several documents in one window as tabs. Opening a file, a recent file, an article in the navigator, a link, a search result, or a drop adds a tab after the one you are on, or switches to the tab already holding that file, so opening no longer replaces your document or asks about its unsaved changes. Each tab keeps its own unsaved edits, undo history, place in the document, and search. Switch with a click, `Ctrl+Tab` and `Ctrl+Shift+Tab`, `Ctrl+PageDown` and `Ctrl+PageUp`, or `Ctrl+1` to `Ctrl+9`; close one with `Ctrl+W`, its close button, or a middle click. Closing the window or the folder asks once about every tab with unsaved changes, Find in folder and Replace in folder include the unsaved text of every open tab, and the window's title in the taskbar names the active document. +- Open more than one Leafdown window with File > New window (`Ctrl+Shift+N`), to keep two documents side by side or two folders open at once. Each window has its own tabs, folder, navigator, folder search, and zoom, while settings, recent items, and Always on top apply to every window. A file is open in only one window at a time: opening it from another window switches to its tab and brings its window forward. Renaming a file moves its tabs in every window with their unsaved edits, deleting it leaves other windows' tabs open with the file shown as missing, Save as refuses a file another window has open, and Replace in folder leaves alone a file another window has unsaved edits in. Closing a window asks only about its own unsaved edits, and closing the last one quits Leafdown. File > Quit (`Ctrl+Q`, which no longer closes just the window) closes every window in turn, bringing each one with unsaved edits forward to ask; canceling there keeps that window and the rest open. The first window still reopens where you left it. - Give Markdown files associated with Leafdown a dedicated folded-page icon with the green leaf and Markdown symbol, separate from the application icon. Both Windows installers include it without changing your default app. - List a folder's other files, such as images and PDFs, in the article navigator, so a folder holding only those no longer looks empty. They are dimmed, hovering one says that Leafdown can only open Markdown files, and choosing one does nothing; its context menu can still reveal it in File Explorer or copy its path. The article count, the filter, Find in folder, and wiki link suggestions still cover Markdown files only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edc37410..c10bf7fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,7 @@ To run one scenario against an already built binary: pnpm test:e2e:desktop:run -- --scenario folder-watcher ``` -Targets are `block-selection`, `callouts`, `command-palette`, `diagnostics`, `document-lifecycle`, `document-watcher`, `find-and-replace`, `keyboard-shortcuts`, `folder-watcher`, `folder-actions`, `folder-search`, `heading-outline`, `help-pages`, `rendered-images`, `remote-images`, `rendered-html`, `rendered-math`, `rendered-mermaid`, `separator-presentation`, `missing-document-error`, `legacy-encoding`, `launch-document`, `persistence`, `release-notes`, `wiki-links`, and `window-lifecycle`. Focused runs use the same isolation, evidence capture, and cleanup as the full suite. The `:run` command does not check binary freshness; run `pnpm build:e2e:desktop` when its inputs change. +Targets are `block-selection`, `callouts`, `command-palette`, `diagnostics`, `document-lifecycle`, `document-watcher`, `find-and-replace`, `keyboard-shortcuts`, `multiple-windows`, `folder-watcher`, `folder-actions`, `folder-search`, `heading-outline`, `help-pages`, `rendered-images`, `remote-images`, `rendered-html`, `rendered-math`, `rendered-mermaid`, `separator-presentation`, `missing-document-error`, `legacy-encoding`, `launch-document`, `persistence`, `release-notes`, `wiki-links`, and `window-lifecycle`. Focused runs use the same isolation, evidence capture, and cleanup as the full suite. The `:run` command does not check binary freshness; run `pnpm build:e2e:desktop` when its inputs change. For release-build Mermaid timings, build the optimized E2E binary and run the three benchmark cases in PowerShell: diff --git a/docs/architecture.md b/docs/architecture.md index ffbd0c22..f1a3c150 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,7 @@ Session and editor workflows request yes/no decisions through the shared confirm - **Article:** a supported Markdown file. - **Article navigator:** the presentation of a folder context's articles, directories, and unsupported files. - **Unsupported file:** a regular file in a folder context that is not an article. The article navigator lists it but never opens it. +- **Window:** one Leafdown window, with its own frontend, session, and native watchers. Every window belongs to one application process, which owns settings, recent items, and the record of which window holds which document. - **Session:** the window's open documents, each in a tab with one of them active, plus an optional folder context. - **Tab:** one open document in a window, with the editor that holds it and that editor's state. - **Source projection:** a temporary editable source representation of a supported Markdown object while the editor retains its canonical document model. @@ -58,7 +59,7 @@ Session and editor workflows request yes/no decisions through the shared confirm The runtime tracks three primary state values: - **Current folder context:** The directory used for article navigation and folder workflows. -- **Open documents:** The saved or untitled Markdown documents open in the window's tabs, in tab order, one of them the active document. A saved file is open in at most one tab. The session keeps each document's Markdown as it was opened or last saved; its editor holds the current text, which workflows such as Save, rename, and folder search read through the editor bridge instead of a copy refreshed on each edit. Every tab keeps its editor mounted, hidden while inactive, so its `Undo` history, caret, scroll position, and search surface outlast switching away. The editor bridge holds every mounted editor by document key; requests that scroll or focus, such as following a heading link or choosing a search match, wait until their document's editor is the one shown. +- **Open documents:** The saved or untitled Markdown documents open in the window's tabs, in tab order, one of them the active document. A saved file is open in at most one tab across all windows. The session keeps each document's Markdown as it was opened or last saved; its editor holds the current text, which workflows such as Save, rename, and folder search read through the editor bridge instead of a copy refreshed on each edit. Every tab keeps its editor mounted, hidden while inactive, so its `Undo` history, caret, scroll position, and search surface outlast switching away. The editor bridge holds every mounted editor by document key; requests that scroll or focus, such as following a heading link or choosing a search match, wait until their document's editor is the one shown. - **Active document metadata:** File metadata and a content fingerprint utilized for dirty-state and external modification checks. ## Editor Architecture @@ -152,6 +153,9 @@ The Rust backend manages: - Keeping each window's watches apart: a window holds at most one folder watch, which a newer scope replaces, and any number of document watches, each started and stopped on its own. Change and watch-error events go only to the window that owns the watch, one window's scopes never stop or replace another's, and destroying a window stops its watches. - Inspecting an open document's file against its recorded metadata and fingerprint, reading the file's bytes only when the metadata differs. - Intercepting window close requests to prompt for unsaved changes before exit. A request goes only to the window it names, each window has at most one pending, and a window closes on its next request when its frontend leaves one unanswered. The frontend declines through a command, so the backend learns the window from the caller. +- Opening another window from the first window's configuration, hidden until its frontend shows it, placed offset from the window that asked or at its monitor's work-area corner when that would not fit. The window-state plugin tracks only the first window, so later windows keep no geometry. +- Quitting by closing the windows one at a time, the caller first and then the others in the order they were first seen, each through its own close request. A destroyed window moves the quit on and a declined request ends it; a window whose request is still pending is not asked again, since a second request would close it unanswered. +- Recording, for each window, the saved documents it holds and whether each has unsaved changes. A claim checks and records a document for a window in one step, comparing paths by the frontend's path identity, so two windows cannot both open one file; a claim on a document another window holds asks that window to show it and brings it forward instead. Each change is published to every window with an increasing revision, and a destroyed window's documents are released. Renames and deletions a window makes are forwarded to the others. - Blocking webview navigation to remote origins, and granting asset-protocol access only to resolved image paths. - Mapping permission and IO errors. - Writing bounded JSONL local diagnostic logs, owning diagnostic log envelope fields, and reporting the app log directory. @@ -171,7 +175,9 @@ The React frontend manages: - Presenting a fetched remote image through a `blob:` object URL owned by its image node view, which revokes it when the target changes or the view is destroyed and discards results that arrive after either. - Marker visibility rules, thematic styling, and error presentation. - Mirroring shared unexpected-error reports and feature-owned operational diagnostics into local logs as event-specific payloads, and exposing the Help diagnostics dialog. -- Showing the window once startup initialization finishes or fails, then opening the launch document, and answering intercepted close requests by destroying the window or declining the request. +- Showing the window once startup initialization finishes or fails, then opening the launch document, and answering intercepted close requests by destroying the window or declining the request. A window with unsaved documents comes to the front before it asks, since `Quit` asks each window in turn. +- Reporting the window's saved documents and their unsaved state to the backend, together with documents it has claimed but not yet opened, and following what other windows report: a document to show, a rename to follow, a deletion to check, and documents with unsaved changes that Replace in folder leaves alone. +- Applying the shared always-on-top setting to the window whenever another window changes it. - Suppressing standard window-level drag-and-drop navigation and routing native file and folder drops through session workflows. The frontend calls feature-owned Rust commands only through feature-owned Tauri API modules. See [Engineering Patterns](./patterns.md#tauri-api-modules) for the implementation rules for that boundary. @@ -190,6 +196,7 @@ The frontend owns all translated text. The backend returns typed error `kind`s a ## Data Contracts - Session owns the open documents in tab order, the active tab, the folder context, and the document metadata used for dirty-state and external-modification checks. Each tab carries a version that changes whenever its document is replaced, which workflows check before applying a result or a decision. +- Each window reports its saved documents to the backend as a list of paths with an unsaved flag. A window claims a path before it opens a file or saves to a new path, and keeps reporting the claim until a tab holds the document or the attempt ends. The backend publishes every window's list with a revision, and each window keeps only the newest. - Opening and saving return a fingerprint of the file's bytes beside its metadata, which together name the version of the file the document holds. The fingerprint is a 64-bit hash compared only for equality within one run of the application and never persisted. The document also records an external change: a newer version observed on disk, or a missing file. - An opened document carries its encoding, as an encoding name and a byte order mark flag, beside its line ending. Session holds it as document state and sends it back with each save. Opening takes an optional chosen encoding name, which the backend ignores when the file has a byte order mark. Session also keeps the encoding the file holds, so the document can be converted back to it, and remounts the editor when a reopen of the same path replaces its text. The frontend labels it but never decodes or encodes text. - Preferences own persisted settings and session history. @@ -202,11 +209,15 @@ The frontend owns all translated text. The backend returns typed error `kind`s a ### Open Workflow -Session activates the tab already holding the file, or backend reads target document -> Session opens it in a tab after the active one -> Session bootstraps folder context only when none exists. +Session activates the tab already holding the file -> Otherwise session claims the file for the window, and a window already holding it shows its tab and comes forward instead -> Backend reads target document -> Session opens it in a tab after the active one -> Session bootstraps folder context only when none exists. ### Launch Workflow -Backend records the launch document path -> Frontend starts persisted stores and shows the window -> Session takes the path once and runs the Open workflow. +Backend records the launch document path -> Frontend starts persisted stores and shows the window -> Session takes the path once and runs the Open workflow. A window opened later starts the same way and finds no launch document. + +### Quit Workflow + +`Quit` asks the backend to quit -> Backend orders the windows, the caller first -> Backend sends the next window a close request -> Its frontend comes forward when it has unsaved documents and asks about them, then destroys the window or declines -> A destroyed window moves the quit on, and a declined request ends it -> Destroying the last window ends the process. ### Drop Workflow @@ -214,7 +225,9 @@ Tauri reports native paths -> Backend classifies one dropped path -> Session rea ### Folder Entry Workflow -The article navigator names a target row and hands the action to the application layer; it holds no filesystem or session behavior of its own. Session confirms a deletion, and asks once about the unsaved changes of open documents inside an entry it deletes -> Backend validates and changes the filesystem -> Session opens a created file in a new tab, moves every open document, recent item, and expanded folder under a renamed path, or closes the tabs of trashed documents -> Session rescans the folder context -> The watcher's later refresh produces the same tree. +The article navigator names a target row and hands the action to the application layer; it holds no filesystem or session behavior of its own. Session confirms a deletion, and asks once about the unsaved changes of open documents inside an entry it deletes -> Backend validates and changes the filesystem -> Session opens a created file in a new tab, moves every open document, recent item, and expanded folder under a renamed path, or closes the tabs of trashed documents -> Backend tells the other windows, which move their own tabs and expanded folders under a renamed path, or check the documents under a deleted one -> Session rescans the folder context -> The watcher's later refresh produces the same tree. + +A deleted folder's own directory is not what a document watch observes, so a window learns of the documents a deletion removed from the window that made it rather than from its watchers. Renames and deletions queue behind a pending save, so a save in flight cannot recreate a file at its old path. The editor is keyed by document path, so a renamed open document remounts from the Markdown its editor held, keeping its unsaved changes and its metadata snapshot for the next freshness check. @@ -224,7 +237,7 @@ Serialize editor state to Markdown -> Verify metadata freshness via backend -> E ### Save As Workflow -Refuse a path open in another tab -> Write document to new path -> Update the tab's document path -> Bootstrap folder context when none exists, refresh the current folder context when the saved file is inside it, or leave the pinned folder context unchanged when the saved file is outside it. +Refuse a path open in another tab -> Claim the path for the window, refusing one another window holds -> Write document to new path -> Update the tab's document path -> Bootstrap folder context when none exists, refresh the current folder context when the saved file is inside it, or leave the pinned folder context unchanged when the saved file is outside it. ### HTML Export Workflow @@ -236,7 +249,7 @@ Session hands the folder-search engine the folder's articles in navigator order, Opening a result inspects its file against the version searched -> Session opens it through the Open workflow, with its unsaved-changes prompt -> The editor chooses the match once ready -> A file gone or a match no longer there is marked and searched again. -While the replace field shows, the same run plans a replacement: there is no result limit, a file whose kept text holds a match is read again for its text, encoding, and line ending, and each result carries the text to write and the version it was planned from, or the editor's settled Markdown for an open document. Applying a complete plan asks in turn about each open document with unsaved changes the plan includes, and a save or discard there plans again instead of writing -> Backend preflights every planned file -> Session writes the files in the plan's order, batching consecutive files for the backend and replacing each open document in its editor before saving it through the save queue with no prompt -> Each outcome is recorded, and the folder is planned again around the report. The folder watcher sees the writes as any atomic save, and an open document's save records the version it wrote, so neither is taken for an outside change. +While the replace field shows, the same run plans a replacement: there is no result limit, a file whose kept text holds a match is read again for its text, encoding, and line ending, and each result carries the text to write and the version it was planned from, or the editor's settled Markdown for an open document. A file another window holds with unsaved changes is skipped, and planned again when that changes. Applying a complete plan asks in turn about each open document with unsaved changes the plan includes, and a save or discard there plans again instead of writing -> Backend preflights every planned file -> Session writes the files in the plan's order, leaving any file that has gained unsaved changes in another window, batching consecutive files for the backend and replacing each open document in its editor before saving it through the save queue with no prompt -> Each outcome is recorded, and the folder is planned again around the report. The folder watcher sees the writes as any atomic save, and an open document's save records the version it wrote, so neither is taken for an outside change. ### External Change Workflow @@ -275,7 +288,7 @@ Neither writes an extension's default value or its `UserChoice`. The MSI's value Automated tests cover Markdown round trips; editor commands and projection; file, folder, watcher, and persistence workflows; path, encoding, size, symlink, and permission boundaries; local resource resolution; safe raw HTML; safe math rendering; and context popup behavior. Rendered HTML parsing, block layout, and native interactions also need the desktop WebView. -The Windows desktop E2E suite runs separately from `pnpm check`, locally and in CI. It uses a debug binary and isolated application state, WebDriver port, fixture tree, and artifacts for each worker. Workers start a fresh application process per scenario, except the ordered persistence restart group. The runner validates `--scenario` and `--workers <1-4>`; its default is one worker. `pnpm test:e2e:desktop:run` uses an already built binary, so rebuild when binary inputs change. The suite covers document lifecycle, opening a document named on the command line, folder watching, external changes to an open document, several documents open as tabs with unsaved edits closed through the window's unsaved-documents dialog, find and replace from the keyboard and the Edit menu, folder search from the keyboard and the Edit menu over an unsaved document in another tab, replacing across the folder through the open document's editor, math rendering, its preview, and source editing under the app's CSP, Mermaid image rendering and its opaque child network boundary, article navigator file actions through the native filesystem and Recycle Bin, backend errors, persisted settings, frame controls, diagnostics, window-close handling, and remote image request gating. +The Windows desktop E2E suite runs separately from `pnpm check`, locally and in CI. It uses a debug binary and isolated application state, WebDriver port, fixture tree, and artifacts for each worker. Workers start a fresh application process per scenario, except the ordered persistence restart group. The runner validates `--scenario` and `--workers <1-4>`; its default is one worker. `pnpm test:e2e:desktop:run` uses an already built binary, so rebuild when binary inputs change. The suite covers document lifecycle, opening a document named on the command line, folder watching, external changes to an open document, several documents open as tabs with unsaved edits closed through the window's unsaved-documents dialog, a second window that shows a file already open in the first in that first window, closes on its own, and is quit with the others window by window, find and replace from the keyboard and the Edit menu, folder search from the keyboard and the Edit menu over an unsaved document in another tab, replacing across the folder through the open document's editor, math rendering, its preview, and source editing under the app's CSP, Mermaid image rendering and its opaque child network boundary, article navigator file actions through the native filesystem and Recycle Bin, backend errors, persisted settings, frame controls, diagnostics, window-close handling, and remote image request gating. Acceptance assertions use state that outlives the action, such as saved files, editor contents, menu state, or diagnostic records. The E2E build holds toasts open when the notification itself is the outcome. Direct bridge calls corroborate diagnostics; filesystem and process access provide setup and native-boundary evidence. WebDriver dependencies and permissions stay in the E2E build, as does the allowance that lets the remote image fetch trust one runner-named host on loopback with a test certificate authority. The E2E build also replaces the opener plugin with one that records URL, path, and reveal requests as diagnostic records instead of handing them to the system shell, so the capability still gates each command but opener scopes and link-click interception are not exercised. diff --git a/docs/help/file-and-folder-workflows.md b/docs/help/file-and-folder-workflows.md index eb9fe157..3f015814 100644 --- a/docs/help/file-and-folder-workflows.md +++ b/docs/help/file-and-folder-workflows.md @@ -14,13 +14,21 @@ Choose a tab to switch to it. `Mod+Tab` and `Mod+Shift+Tab`, or `Mod+PageDown` a A newly opened document starts at the top. Following a heading link or choosing a search result instead brings its target into view. -On Windows, installing Leafdown adds it to File Explorer's **Open with** menu for these files and to **Settings > Apps > Default apps**, without changing which app opens them by default. Choose Leafdown there to open Markdown files from File Explorer. Files associated with Leafdown show a folded-page icon with the green leaf and Markdown symbol. Each file opened this way starts its own Leafdown window, with the file's folder as context. **File > Open** works either way. +## Work in more than one window + +**File > New window** (`Mod+Shift+N`) opens another Leafdown window, to keep two documents side by side or two folders open at once. Each window has its own tabs, folder, navigator, folder search, and zoom, while settings and recent items are shared and change in every window at once. A file is open in only one window at a time: opening a file that another window already has switches to its tab there and brings that window forward. + +Renaming a file or folder in the navigator moves its tabs in every window, keeping their unsaved edits. Deleting one closes its tabs in the window you deleted it from; other windows keep its tabs open with their text and show the file as missing. **Replace in folder** leaves alone a file that another window has with unsaved edits, and says so. + +**File > Close window** (`Alt+F4`) or the window's close button asks only about that window's unsaved edits, and closing the last window quits Leafdown. **File > Quit** (`Mod+Q`) closes every window in turn. A window with unsaved edits comes to the front to ask about them, and canceling there stops quitting with that window and those not yet asked still open. The first window reopens where you left it; other windows open offset from the one you opened them from. + +On Windows, installing Leafdown adds it to File Explorer's **Open with** menu for these files and to **Settings > Apps > Default apps**, without changing which app opens them by default. Choose Leafdown there to open Markdown files from File Explorer. Files associated with Leafdown show a folded-page icon with the green leaf and Markdown symbol. Each file opened this way starts another instance of Leafdown in its own window, with the file's folder as context; that instance does not know which files the other one has open. **File > Open** works either way. The welcome screen and **File > Open recent** show previously opened files and folders when **Record recent files and folders** is on. You can remove one recent item or clear both lists without deleting files. See [Settings reference](settings-reference.md) for the setting. ## Work in the article navigator -Choose a Markdown file row to open it. A row's context menu can create a file or folder, rename or delete an entry, reveal its location in the system file manager, or copy its absolute or folder-relative path. Empty navigator space offers actions for the folder root. New files open in a new tab. Deleting asks for confirmation and moves an entry to the system Recycle Bin or Trash; Leafdown does not permanently delete it on its own. Deleting an open file closes its tab. Renaming an open file keeps its unsaved edits and updates its recent path. Links in other files are not rewritten. +Choose a Markdown file row to open it. A row's context menu can create a file or folder, rename or delete an entry, reveal its location in the system file manager, or copy its absolute or folder-relative path. Empty navigator space offers actions for the folder root. New files open in a new tab. Deleting asks for confirmation and moves an entry to the system Recycle Bin or Trash; Leafdown does not permanently delete it on its own. Deleting an open file closes its tab. Renaming an open file keeps its unsaved edits and updates its recent path. Both reach tabs in other windows too, as [Work in more than one window](#work-in-more-than-one-window) describes. Links in other files are not rewritten. The navigator also lists the folder's other files, such as images and PDFs, dimmed. Leafdown can only open Markdown files, so choosing one of them does nothing, and its context menu does not rename or delete it. A folder holding only such files can still be expanded, while an empty folder has no arrow. diff --git a/docs/help/getting-started.md b/docs/help/getting-started.md index 8429731b..3e8b28db 100644 --- a/docs/help/getting-started.md +++ b/docs/help/getting-started.md @@ -18,6 +18,6 @@ Click in the document and type. Leafdown shows Markdown as formatted text while Choose **File > Save** to write changes. Saving an untitled document opens **Save as**, where you choose its filename and location. **File > Save as** also writes an existing document to a new path. A file saved outside the open folder does not replace that pinned folder context. -Opening a document never closes another: each opens in its own tab. When you close a tab, the folder, or the window with unsaved changes, Leafdown asks before discarding them, and canceling keeps everything open. If another program has changed or removed the file, Leafdown warns before a save can overwrite it or offers Save as for a missing file. +Opening a document never closes another: each opens in its own tab. When you close a tab, the folder, or the window with unsaved changes, or quit Leafdown, Leafdown asks before discarding them, and canceling keeps everything open. If another program has changed or removed the file, Leafdown warns before a save can overwrite it or offers Save as for a missing file. Continue with [File and folder workflows](file-and-folder-workflows.md) or [Settings reference](settings-reference.md). diff --git a/docs/help/settings-reference.md b/docs/help/settings-reference.md index f2ec034f..841133c1 100644 --- a/docs/help/settings-reference.md +++ b/docs/help/settings-reference.md @@ -10,7 +10,7 @@ Open **File > Preferences** to change the settings below. Changes persist across - **Status bar visibility:** Show or hide document status when a document is open. - **Article sort order:** Sort the navigator by name, modified date, or type. -**View > Always on top** is another persistent window setting; it keeps Leafdown above other windows until switched off. +**View > Always on top** is another persistent window setting; it keeps every Leafdown window above other windows until switched off. The **H1 to H6** control at the top of the document outline is also remembered across launches. It chooses how deep the outline lists headings, H1–H3 by default. diff --git a/docs/reference.md b/docs/reference.md index ace25dde..8c9561f4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -87,7 +87,7 @@ Shortcuts use `Mod` as the primary platform modifier (`Ctrl` on Windows/Linux, ` #### File Menu - **New** (`Mod+N`) -- **New window** (`Mod+Shift+N`, Deferred) +- **New window** (`Mod+Shift+N`) - **Open...** (`Mod+O`) - **Open folder...** (`Mod+Shift+O`) - **Open recent** @@ -106,7 +106,8 @@ Shortcuts use `Mod` as the primary platform modifier (`Ctrl` on Windows/Linux, ` - **Preferences...** (`Mod+,`) - **Close document** (`Mod+W`) - **Close folder** -- **Close window** (`Alt+F4` / `Mod+Q`) +- **Close window** (`Alt+F4`) +- **Quit** (`Mod+Q`) #### Edit Menu diff --git a/docs/specification.md b/docs/specification.md index 38c43507..db4ab990 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -46,6 +46,7 @@ Unsupported Markdown is outside Leafdown's supported editing surface. Leafdown t Primary user interface surfaces: +- **Window:** Leafdown can show several windows at once, all belonging to one running application. Each window has its own document tabs, folder context, article navigator, folder search view, title, and zoom, and its commands act on its own active document. Settings and recent items are shared: a change made in one window shows in every window at once. `New window` opens another window on the welcome screen. - **App shell:** wraps the custom titlebar, menu bar, article navigator, document surface with its document tabs and document outline, status bar, and modal layer. The window's title names the active document, such as `notes.md - Leafdown`, or is `Leafdown` while no document is open. - **Welcome screen:** appears when no document or folder context is open, spanning the workspace because no article navigator stands beside it. It offers new document, open file, open folder, recent files, and recent folders, and names the keyboard shortcut for each of the three actions. Recent lists appear only once a recent item exists. A recent item is listed by its own name with the folder holding it beside it and, once recorded, how long ago it was last opened, and carries its full path for hover. A single recent item can be removed from its list without changing any other entry. - **Menu bar:** provides top-level app commands grouped by File, Edit, Insert, Format, View, and Help. An editor command chosen from it, including one in a submenu, leaves focus in the editor with the command's result, while a command that opens its own surface, such as `Find...`, the code block language picker, or a dialog, moves focus into that surface. @@ -89,6 +90,8 @@ These state axes compose. A document session, for example, can have a folder con ### App State +Each window has its own app state. + - **Welcome:** no document or folder context is open. - **Folder-only session:** a folder context is open, but no document is active. - **Document session:** one or more documents are open as tabs, one of them active. A folder context may also be active. @@ -116,7 +119,10 @@ Every open document has its own state, kept while its tab is inactive: its edito - `Close document` closes only the active document's tab, asking about its unsaved changes by name. The tab to its right becomes active, or the tab to its left when it was the last, and its editor takes focus. Closing the last tab leaves the folder-only state when a folder context exists, which remains active, and the welcome screen otherwise. - `Close folder` closes the folder context and every tab, returning to the welcome screen. -- `Close window` closes the current app window and its tabs. +- `Close window` closes the current window and its tabs, asking only about that window's unsaved documents. Closing the last window quits Leafdown. +- `Quit` closes every window in turn, starting with the window it was chosen in. A window with unsaved documents comes to the front and shows its unsaved-documents dialog; a window without them closes without asking. `Cancel` stops quitting and leaves that window and every window not yet asked open, while windows already closed stay closed. +- `New window` opens a window on the welcome screen, offset down and to the right of the window it was opened from, or at the top left of the screen when it would not fit there. The first window opens at the size and position it was last closed at; windows opened later do not keep theirs. +- A saved file is open in at most one tab across all windows. Opening a file another window holds makes its tab active there and brings that window to the front, instead of opening it again. - Opening a file or folder, creating a document, and switching tabs never ask about unsaved changes, since no document is replaced. Closing a tab, the folder, or the window asks first when a document it closes has unsaved changes. One such document is named in a dialog offering `Discard changes` or `Keep editing`; several are named together in one dialog offering `Discard all` or `Cancel`. Declining keeps every tab open. - Discard, missing-file, and external-change decisions use an app dialog. The dialog puts focus inside, keeps keyboard focus within it, and returns focus to the initiating control or editor when it closes. Escape, the cancel action, and dismissing the dialog leave the pending action unperformed. A decision applies only to the version of each document it asked about: a document saved, reloaded, renamed, closed, or opened again while the dialog was open is not discarded, saved, or overwritten through it, even at the same path. @@ -313,12 +319,12 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - `Find in folder...` opens the folder search view in place of the article navigator, named for the folder context, with its query field focused, and shows the sidebar if it is hidden. It needs a folder context, with or without an open document, and searches apart from the document's search surface, whose query it neither reads nor changes. Asking again returns focus to the query field. The close button or `Escape` returns the sidebar to the article navigator and puts focus in the editor, with a chosen match selected, or on the navigator when no document is open. - It searches the articles the article navigator lists: supported Markdown files in the folder context, outside ignored directories, without following symbolic links, and only at the top level of a drive or partition root. A document outside the folder context and an untitled document are not searched. - Matching is the [Find And Replace](#find-and-replace) matching: literal text that a caret can reach, read from each file as the editor reads it once the file opens, with `Match case` and `Whole word`. Reading a file for a search renders nothing and fetches nothing. -- Each open document is searched as its editor holds it, whether or not its tab is active, with its unsaved changes and the encoding it was read in, even when its file has since changed or gone. A search the author starts settles an open source projection in the active document first, as opening the search surface does. Its results follow edits without saving, marking unsaved changes, or adding history, and a document whose tab closes is read from its file again. +- Each document open in the window is searched as its editor holds it, whether or not its tab is active, with its unsaved changes and the encoding it was read in, even when its file has since changed or gone. A search the author starts settles an open source projection in the active document first, as opening the search surface does. Its results follow edits without saving, marking unsaved changes, or adding history, and a document whose tab closes is read from its file again. - Every other file is read as Open File reads it, in the encoding its byte order mark names or otherwise as UTF-8, never in a guessed encoding. A file that is too large, is not valid in that encoding, or cannot be read is skipped, and the search goes on; the view names each skipped file and why. - Results are grouped by file in the article navigator's order, each file named by its path relative to the folder with its number of matches, and each file's matches in document order. A match shows the text around it, with the match highlighted. While it searches, the view says how many files it has read; once done, it gives the number of results and files, `No results`, or that the search was cancelled. Assistive technology hears the outcome rather than each step of progress. - A search starts once typing pauses, or at once on `Enter`, and an empty query reads no file. Changing the query or its options, closing the view, closing or changing the folder, or `Cancel` stops a search; a cancelled search keeps what it found and says so. Closing or changing the folder also clears the query. - A search stops after 10,000 results and says so, offering `Search further` to go on from there. -- Opening a match opens its file as Open File does, in a new tab, without asking about unsaved changes. A match in an open document makes its tab active and is found there without reopening it, keeping its unsaved changes. While focus stays in the results, the match is scrolled into view and highlighted without moving the caret, opening its source, or raising the selection popup, so the next one can be opened from the keyboard. Returning to the text with `Escape`, the close button, or `Tab` selects the match, which then opens source projection or the popup as any selection does; a click in the text places the caret there instead. A match whose file is gone, or whose text is no longer where it was, is marked as no longer in the file instead of selecting other text, and that file is searched again. +- Opening a match opens its file as Open File does, in a new tab, without asking about unsaved changes. A match in an open document makes its tab active and is found there without reopening it, keeping its unsaved changes. A match in a file another window holds brings that window to the front with the file's tab active, without choosing the match there. While focus stays in the results, the match is scrolled into view and highlighted without moving the caret, opening its source, or raising the selection popup, so the next one can be opened from the keyboard. Returning to the text with `Escape`, the close button, or `Tab` selects the match, which then opens source projection or the popup as any selection does; a click in the text places the caret there instead. A match whose file is gone, or whose text is no longer where it was, is marked as no longer in the file instead of selecting other text, and that file is searched again. - When files are added, removed, renamed, or changed, the search runs again with its results in place. Results last only while the view is open; searching writes nothing to disk and keeps nothing between sessions. ### Replace In Folder @@ -328,6 +334,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - A file no editor holds keeps every line the replacement does not change exactly as it has it: only the lines holding a replaced match are written, in the form Save would write them, after Leafdown checks that the result reads back as the document Save would write. When that cannot be shown, such as a match in a table whose column padding Save would recompute, the file is written as Save writes it, and its row is marked as changing other lines too. The file keeps its encoding, byte order mark, and line endings, and the `Insert final newline on save` and `Line ending` settings apply as they do to Save. - `Replace all` is offered once the plan is complete and while it holds something to replace. `Mod+Alt+Enter` in the replacement field does the same, and `Enter` there plans again at once. An edit to an open document, a file changing on disk, or a change to the settings above plans again, and `Replace all` waits until that plan is complete. - When open documents the plan includes have unsaved changes, `Replace all` first asks about each of them in turn, in tab order, to `Save`, `Discard`, or `Cancel`, without switching tabs. `Save` saves as Save does and `Discard` reads the file again as Open File does; either one plans again from the result and writes nothing, so the updated preview is shown before `Replace all` can write. `Cancel` stops asking and leaves the remaining documents as they were, and a declined or failed save or a failed read leaves that document as it was. Unsaved changes in an open document the plan does not include are neither saved nor discarded. +- A file another window holds with unsaved changes is read from disk like any other file, but the replacement leaves it alone: the plan lists it as skipped and names it, and it is planned again once those changes are saved or discarded there. A file that gains unsaved changes in another window after the plan is left as it was, and the report says why. - `Replace all` first checks every planned file against the version the plan read, without writing, then writes the files one at a time in the order the view lists them, showing its progress. Each file is checked again just before it is written, and one that changed or went missing since the plan is left as it is. Each file is written as Save writes one, completely or not at all, and a file that cannot be written leaves the files after it to be written. An open document, in any tab, is replaced in its editor as one `Undo` step and then saved; `Undo` there undoes the replacement in that document only. A replacement cannot be cancelled once it starts writing. - When it finishes, the view reports the matches and files replaced, and names each file left as it was with the reason: changed on disk or in the editor since the plan, missing, refused by its encoding or by permissions, or not written. An open document that took the replacement but could not be saved keeps it as unsaved changes and is reported as such. Files never reached because the replacement stopped are listed as not attempted. The report stays until it is dismissed or the query, the replacement, or an option changes, and the folder is planned again around it. - There is no Undo across files and no automatic rollback: files already written stay written when a later file fails, and Leafdown keeps no copy of the files it replaced. Leafdown's own writes are not reported as changes made outside it. @@ -455,7 +462,7 @@ Arrow keys move through results, and `Home` and `End` select the first and last ### Always On Top -- **Always on top** keeps the Leafdown window above windows that are not themselves on top. It is Off by default, global, and persists across launches. In the current single-window application it owns the one Leafdown window. +- **Always on top** keeps every Leafdown window above windows that are not themselves on top. It is Off by default, global, and persists across launches. Every open window, and every window opened later, follows it; a window that refuses the change reports the failure. - Turning it on or off changes the window first and records the setting only once the window accepts the change. A change the window refuses is reported, and the setting and its checkmark stay as they were. - On launch, a setting left On is applied before the window is shown. If the window refuses it, Leafdown still opens, turns the setting Off, and reports the failure. - `Restore defaults` turns it Off and takes the window off the top. If the window refuses, the other settings are still restored, the setting stays On, and the failure is reported. @@ -481,7 +488,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c ### Open File - Opening a file may be initiated from Open, recent files, article navigator selection, or the operating system, as described in [Open From The Operating System](#open-from-the-operating-system). -- When the file is already open, make its tab active instead of reading it again; its unsaved changes, caret, and scroll position stay as they were. +- When the file is already open, make its tab active instead of reading it again; its unsaved changes, caret, and scroll position stay as they were. A file open in another window has its tab made active there, and that window comes to the front. - Otherwise read the selected Markdown file and open it in a new tab after the active one, which becomes active, without waiting for a folder scan. - If no folder context is active or loading, set the current folder context to the file's parent folder and scan that folder for the article navigator, skipping ignored directories. Until the scan finishes, the article navigator shows placeholder rows under the folder's name and commands that need the article tree stay unavailable. A folder that cannot be scanned shows its scan error and leaves the document open without a folder context. - If a folder context is already active or loading, keep it unchanged. Opening a folder replaces a loading folder context, and the superseded scan's result is discarded. @@ -494,7 +501,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c - Set the current folder context to the selected folder. - Scan the folder for the article navigator, skipping ignored directories. - Keep every open tab, including documents outside the new folder context. -- Open a root-level configured index file when one exists, in a new tab as Open File does, or make its tab active when it is already open. Only a supported Markdown file can be an index file. +- Open a root-level configured index file when one exists, in a new tab as Open File does, or make its tab active when it is already open. An index file open in another window stays there, and the folder opens without it, leaving the other window where it is. Only a supported Markdown file can be an index file. - Match index file names case-insensitively in configured order. - For the same configured index name, prefer `.md`, then `.markdown`, `.mdown`, and `.mkd`. - Otherwise leave the tabs as they are, showing the folder-only state when none is open. @@ -514,7 +521,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c - The Windows installers register Leafdown as able to open each supported Markdown extension, so File Explorer offers it under Open with and Settings lists it under Default apps. Installing, upgrading, repairing, or reinstalling never makes Leafdown the default application: each extension's default and the user's choice stay as they were, and choosing Leafdown is the user's step in Windows. - Files associated with Leafdown use a document icon showing the green leaf above the Markdown M and down arrow on a folded page. The application and its shortcuts keep the leaf icon. -- Opening a file through that registration, or starting Leafdown with a path as its first command-line argument, starts a new Leafdown window with that file, even when another Leafdown window is open. A relative path resolves against the directory Leafdown started in; later arguments are ignored. +- Opening a file through that registration, or starting Leafdown with a path as its first command-line argument, starts another instance of Leafdown with that file in its own window, even while Leafdown is running. That instance does not know which files the other instance has open. A relative path resolves against the directory Leafdown started in; later arguments are ignored. - The file opens through [Open File](#open-file) once preferences have loaded, so it sets the folder context and records recents as File > Open does. A missing, unreadable, oversized, unsupported, or undecodable file shows its open error, as described in [Error Handling](#error-handling), and leaves the welcome screen. - Upgrading, reinstalling, or repairing points the registration at the installed executable. Uninstalling removes the registration that installation made and nothing else. The NSIS installation registers for the current user and the MSI installation for all users; while both are installed, Windows uses the current user's. - Opening files and folders from within Leafdown does not depend on the registration. @@ -530,7 +537,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c - Write the document to the chosen path. - The native picker offers every supported Markdown extension. A chosen name with a supported extension keeps it; a name without one receives the `Default extension for new documents`, and any other extension is refused. -- A path open in another tab is refused with an error naming that document, and nothing is written, since two tabs would then hold one file. Saving a document again at its own path is allowed. +- A path open in another tab or window is refused with an error naming that document, and nothing is written, since two tabs would then hold one file. Saving a document again at its own path is allowed. - Update the active document path. - If no folder context is active, set the current folder context to the saved file's parent folder. - If the saved file is inside the current folder context, refresh the article navigator for the current folder context. @@ -562,8 +569,8 @@ The article navigator's context menus act on the row or empty space they were op - A new file name without an extension receives the `Default extension for new documents`. A name ending in a supported Markdown extension, in any letter case, keeps it, and any other extension is refused. Leafdown then opens the new, empty document in a new tab and selects it. A new folder takes the focused row without changing the active document. - `Rename` edits the row's name in place, with the name before a file's extension selected. `Enter` or moving focus elsewhere in Leafdown commits it, and `Escape` or an unchanged name cancels it. A file renamed without an extension keeps its current one. Renaming only the letter case of a name is allowed. - A name that is empty, `.` or `..`, holds a path separator or a character the platform cannot store in a name, ends in a period or space on Windows, or is a reserved Windows device name is refused. So is a name another entry in the folder already has. A refused name leaves the filesystem unchanged and explains why; after `Enter` the name stays open for correction, and after focus moves elsewhere in Leafdown the edit ends. -- Renaming an open saved document, or a folder containing open documents, moves each of them to its new path in its own tab and keeps its editor content and unsaved changes. Recent files and folders at or inside the renamed path move with it, and folders expanded inside it stay expanded. Links in other documents are not rewritten. -- `Delete` asks for confirmation, then moves the entry to the system Recycle Bin or Trash; a folder goes with everything in it. Leafdown never deletes an entry permanently on its own: an entry the Recycle Bin or Trash cannot take is reported as an error, and on Windows the system may instead ask whether to delete it permanently. Deleting an open document, or a folder containing open documents, asks once about their unsaved changes first and closes their tabs afterward. Declining either question changes nothing. +- Renaming an open saved document, or a folder containing open documents, moves each of them to its new path in its own tab, in every window, and keeps its editor content and unsaved changes. Recent files and folders at or inside the renamed path move with it, and folders expanded inside it stay expanded. Links in other documents are not rewritten. +- `Delete` asks for confirmation, then moves the entry to the system Recycle Bin or Trash; a folder goes with everything in it. Leafdown never deletes an entry permanently on its own: an entry the Recycle Bin or Trash cannot take is reported as an error, and on Windows the system may instead ask whether to delete it permanently. Deleting an open document, or a folder containing open documents, asks once about the unsaved changes of those in the window first and closes their tabs afterward. Declining either question changes nothing. Tabs in other windows that hold a deleted document stay open with their text and show the file missing, as [Filesystem Watching](#filesystem-watching) describes for a file deleted outside Leafdown, with a warning that another window deleted it. - `Open file location` and `Open folder location` reveal the row's entry, or the folder context root from the empty space, in the system file manager. - `Copy path` copies the entry's absolute path. `Copy relative path` copies its path relative to the folder context root, using the platform's separator, and copies `.` for the root itself. Copying changes nothing else. - A completed action refreshes the article navigator at once and focuses the created or renamed row, or the row the action started from when it is cancelled. Missing entries, permission, collision, clipboard, trash, and other filesystem failures show an error and leave the navigator and session unchanged. diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 432087fa..9414fab6 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -253,6 +253,7 @@ const createWorkerContext = async (workerIndex: number): Promise { name: "document-watcher", recentFiles: [documentWatcherPath] }, { name: "find-and-replace", recentFiles: [searchPath] }, { name: "keyboard-shortcuts" }, + { name: "multiple-windows", recentFiles: [calloutsPath, citationsPath] }, { name: "heading-outline", recentFiles: [outlinePath] }, { name: "folder-watcher", recentFolders: [folderPath] }, { name: "folder-actions", recentFolders: [actionsFolderPath] }, diff --git a/e2e/desktop/specs/multiple-windows.spec.ts b/e2e/desktop/specs/multiple-windows.spec.ts new file mode 100644 index 00000000..1ee7e4ee --- /dev/null +++ b/e2e/desktop/specs/multiple-windows.spec.ts @@ -0,0 +1,137 @@ +import { $, browser, expect } from "@wdio/globals"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { Key } from "webdriverio"; + +import { getDiagnosticsSummary } from "../support/diagnostics.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { + countDocumentTabs, + dismissToasts, + findDocumentTab, + openRecentPath, + selectFileMenuItem, +} from "../support/ui.js"; + +const ACTIVE_EDITOR = "[data-testid=active-document-host] .ProseMirror"; +const OPEN_DIALOG = '[data-slot="dialog-content"][data-open]'; + +const isProcessRunning = (processId: number) => { + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +}; + +const waitForProcessExit = async (processId: number) => { + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + if (!isProcessRunning(processId)) { + return; + } + + await delay(100); + } + + throw new Error(`The application process ${processId} did not exit after Quit.`); +}; + +const appendToActiveEditor = async (text: string) => { + const editor = $(ACTIVE_EDITOR); + + await editor.click(); + await editor.addValue(text); + await expect(editor).toHaveText(expect.stringContaining(text)); +}; + +// Typing reaches a window reliably only while it is in front, so the steps below type in a window +// just after it opened or was brought forward. +const openNewWindow = async () => { + const handles = await browser.getWindowHandles(); + + await selectFileMenuItem("New window"); + + let opened: string | undefined; + + await browser.waitUntil( + async () => { + opened = (await browser.getWindowHandles()).find((handle) => !handles.includes(handle)); + return opened !== undefined; + }, + { timeoutMsg: "File > New window did not open a window." }, + ); + + await browser.switchToWindow(opened!); + await expect($("aria/New document")).toBeDisplayed(); + + return opened!; +}; + +describe("desktop multiple windows", () => { + it("keeps a file in one window, closes a window on its own, and quits window by window", async () => { + const diagnostics = await getDiagnosticsSummary(); + const processId = Number(diagnostics.runId.slice(diagnostics.runId.lastIndexOf("-") + 1)); + const { callouts, citations } = await getDesktopE2ERunContext(); + const calloutsName = path.basename(callouts.path); + const citationsName = path.basename(citations.path); + const mainWindow = await browser.getWindowHandle(); + + for (const filePath of [callouts.path, citations.path]) { + await openRecentPath(filePath); + await findDocumentTab(path.basename(filePath)); + } + await dismissToasts(); + await appendToActiveEditor("Main window edit"); + + const secondWindow = await openNewWindow(); + expect(await countDocumentTabs()).toBe(0); + await $("aria/New document").click(); + await appendToActiveEditor("Second window edit"); + + // The file is already open in the first window, which shows it instead. + await openRecentPath(callouts.path); + await browser.switchToWindow(mainWindow); + await expect(await findDocumentTab(calloutsName)).toHaveAttribute("aria-selected", "true"); + expect(await countDocumentTabs()).toBe(2); + await browser.switchToWindow(secondWindow); + expect(await countDocumentTabs()).toBe(1); + + // Closing a window asks only about its own unsaved documents and leaves the others running. + await $("aria/Close window").click(); + const closePrompt = $(OPEN_DIALOG); + await expect(closePrompt).toBeDisplayed(); + // One document named, not two: the first window's unsaved tab is not part of this close. + await expect(closePrompt).toHaveText(expect.stringContaining("Untitled has unsaved changes.")); + await $("aria/Discard changes").click(); + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { + timeoutMsg: "The second window did not close.", + }); + expect(isProcessRunning(processId)).toBe(true); + + // Quit closes the clean window it was chosen in, then asks the first window, where Cancel + // stops it. + await browser.switchToWindow(mainWindow); + await openNewWindow(); + await selectFileMenuItem("Quit"); + await browser.switchToWindow(mainWindow); + const quitPrompt = $(OPEN_DIALOG); + await expect(quitPrompt).toBeDisplayed(); + await expect(quitPrompt).toHaveText( + expect.stringContaining(`${citationsName} has unsaved changes.`), + ); + expect(await browser.getWindowHandles()).toEqual([mainWindow]); + await browser.keys(Key.Escape); + await expect(quitPrompt).not.toExist(); + expect(await countDocumentTabs()).toBe(2); + expect(isProcessRunning(processId)).toBe(true); + + await selectFileMenuItem("Quit"); + await expect($(OPEN_DIALOG)).toBeDisplayed(); + await $("aria/Discard changes").click(); + + await waitForProcessExit(processId); + }); +}); diff --git a/e2e/desktop/support/scenarioSelection.test.ts b/e2e/desktop/support/scenarioSelection.test.ts index 50326911..c2525856 100644 --- a/e2e/desktop/support/scenarioSelection.test.ts +++ b/e2e/desktop/support/scenarioSelection.test.ts @@ -17,6 +17,7 @@ void test("runs every scenario when no focused target is requested", () => { ["document-watcher"], ["find-and-replace"], ["keyboard-shortcuts"], + ["multiple-windows"], ["heading-outline"], ["folder-watcher"], ["folder-actions"], diff --git a/e2e/desktop/support/scenarioSelection.ts b/e2e/desktop/support/scenarioSelection.ts index c8c3065e..c036a586 100644 --- a/e2e/desktop/support/scenarioSelection.ts +++ b/e2e/desktop/support/scenarioSelection.ts @@ -10,6 +10,7 @@ const scenarioTargets = { "document-watcher": ["document-watcher"], "find-and-replace": ["find-and-replace"], "keyboard-shortcuts": ["keyboard-shortcuts"], + "multiple-windows": ["multiple-windows"], "heading-outline": ["heading-outline"], "folder-watcher": ["folder-watcher"], "folder-actions": ["folder-actions"], diff --git a/e2e/desktop/tauri.conf.json b/e2e/desktop/tauri.conf.json index b6fe1b29..c59b3db7 100644 --- a/e2e/desktop/tauri.conf.json +++ b/e2e/desktop/tauri.conf.json @@ -13,7 +13,7 @@ { "identifier": "desktop-e2e", "description": "Test-only WebdriverIO access for the Windows desktop smoke harness", - "windows": ["main"], + "windows": ["*"], "permissions": [ "wdio:allow-execute", "wdio:allow-get-window-states", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d4937992..ad78eb22 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,6 +24,7 @@ "core:window:allow-maximize", "core:window:allow-set-size", "core:window:allow-set-focus", + "core:window:allow-unminimize", "core:window:allow-is-maximized", "core:window:allow-is-fullscreen", "core:window:allow-start-dragging", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76adebd7..cd386cd6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ mod test_utils; mod text_encoding; mod watch_scope; mod window; +mod window_documents; const TITLEBAR_HEIGHT: u32 = 32; const TITLEBAR_BUTTON_WIDTH: u32 = 52; @@ -67,6 +68,7 @@ pub fn run() { tauri_plugin_window_state::Builder::new() // Startup shows the window after frontend initialization, including failure. .with_state_flags(StateFlags::all() ^ StateFlags::VISIBLE) + .with_filter(|label| label == window::MAIN_WINDOW_LABEL) .build(), ) .plugin(navigation::build_navigation_guard_plugin()) @@ -85,6 +87,9 @@ pub fn run() { .manage(folder::FolderWatcherState::default()) .manage(launch::LaunchDocument::from_process()) .manage(window::CloseRequestGuard::default()) + .manage(window::QuitSequence::default()) + .manage(window::WindowNumbering::default()) + .manage(window_documents::WindowDocuments::default()) .setup(|app| { let package_info = app.package_info(); let app_version = package_info.version.to_string(); @@ -106,6 +111,8 @@ pub fn run() { window::forget_close_request(window); document::stop_window_document_watchers(window); folder::stop_window_folder_watcher(window); + window_documents::forget_window_documents(window); + window::continue_quit(window); } _ => {} }) @@ -137,7 +144,13 @@ pub fn run() { folder::create_article_directory, folder::rename_folder_entry, folder::trash_folder_entry, - window::decline_window_close + window::decline_window_close, + window::open_new_window, + window::quit_application, + window_documents::set_window_documents, + window_documents::claim_window_document, + window_documents::notify_documents_moved, + window_documents::notify_documents_removed ]) .run(context) .unwrap_or_else(|error| { diff --git a/src-tauri/src/path_utils.rs b/src-tauri/src/path_utils.rs index c71ab96c..85c93634 100644 --- a/src-tauri/src/path_utils.rs +++ b/src-tauri/src/path_utils.rs @@ -195,6 +195,46 @@ pub(crate) fn normalize_path_lexically(path: &Path) -> PathBuf { normalized } +/// The frontend's path identity, so the backend matches open documents as their windows do: +/// separators become `/`, trailing separators are dropped, and Windows drive and UNC paths compare +/// case-insensitively. +pub(crate) fn path_identity_key(path: &str) -> String { + let normalized = trim_trailing_separators(path.replace('\\', "/").as_str()); + + if is_windows_path(normalized.as_str()) { + normalized.to_lowercase() + } else { + normalized + } +} + +fn trim_trailing_separators(path: &str) -> String { + if !path.is_empty() && path.bytes().all(|byte| byte == b'/') { + return "/".to_owned(); + } + + let trimmed = path.trim_end_matches('/'); + + if trimmed.len() < path.len() && is_windows_drive(trimmed) { + return format!("{trimmed}/"); + } + + trimmed.to_owned() +} + +fn is_windows_drive(path: &str) -> bool { + let bytes = path.as_bytes(); + + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn is_windows_path(path: &str) -> bool { + path.starts_with("//") + || path.get(..2).is_some_and(|drive| { + is_windows_drive(drive) && matches!(path.get(2..3), None | Some("/")) + }) +} + fn canonicalize_or_normalize(path: &Path) -> PathBuf { fs::canonicalize(path).unwrap_or_else(|_| normalize_path_lexically(path)) } @@ -238,10 +278,38 @@ mod tests { use super::{ IoErrorClass, MarkdownReferencePathResolution, canonicalize_or_original, classify_io_error, has_uri_scheme, is_network_or_device_target, normalize_path_lexically, parse_file_url_path, - resolve_markdown_reference_path, resolves_outside_folder, + path_identity_key, resolve_markdown_reference_path, resolves_outside_folder, }; use crate::test_utils::TestDirectory; + #[test] + fn keys_windows_paths_as_the_frontend_does() { + assert_eq!(path_identity_key(r"C:\Notes\Docs\"), "c:/notes/docs"); + assert_eq!(path_identity_key("C:/"), "c:/"); + assert_eq!(path_identity_key(r"C:\\"), "c:/"); + assert_eq!(path_identity_key("C:"), "c:"); + assert_eq!( + path_identity_key(r"\\Server\Share\Docs\Readme.md"), + "//server/share/docs/readme.md" + ); + assert_eq!( + path_identity_key(r"C:\Notas de Reunião\Ü.md"), + path_identity_key("c:/notas de reunião/ü.md") + ); + } + + #[test] + fn keeps_posix_path_identity_case_sensitive() { + assert_eq!(path_identity_key("/Users/Ada/Notes/"), "/Users/Ada/Notes"); + assert_eq!(path_identity_key("///"), "/"); + assert_eq!(path_identity_key(""), ""); + assert_ne!( + path_identity_key("/Users/Ada/Notes"), + path_identity_key("/users/ada/notes") + ); + assert_eq!(path_identity_key("Cx:/Notes"), "Cx:/Notes"); + } + #[test] fn parses_file_url_paths_and_decodes_percent_escapes() { let path = parse_file_url_path("file:///tmp/a%20b.md").expect("file URL should parse"); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 0debe44d..a40658a8 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1,12 +1,27 @@ use std::{ - collections::HashSet, - sync::{Mutex, MutexGuard, PoisonError}, + collections::{HashSet, VecDeque}, + sync::{ + Mutex, MutexGuard, PoisonError, + atomic::{AtomicU32, Ordering}, + }, }; -use tauri::{CloseRequestApi, Emitter, Manager, Runtime, State, Window}; +use serde::Serialize; +use tauri::{ + AppHandle, CloseRequestApi, Emitter, Manager, PhysicalPosition, PhysicalSize, Runtime, State, + WebviewWindowBuilder, Window, +}; + +use crate::window_documents::WindowDocuments; const WINDOW_CLOSE_REQUESTED_EVENT: &str = "leafdown://window-close-requested"; +/// The window the application starts with, the only one whose size and position are kept. +pub(crate) const MAIN_WINDOW_LABEL: &str = "main"; + +/// How far a new window sits right of and below the window it was opened from, in logical pixels. +const NEW_WINDOW_OFFSET: f64 = 32.0; + /// Tracks, for each window, whether a close request is waiting for its frontend's answer. #[derive(Debug, Default)] pub(crate) struct CloseRequestGuard { @@ -32,6 +47,10 @@ impl CloseRequestGuard { self.pending_windows().remove(window_label); } + fn is_pending(&self, window_label: &str) -> bool { + self.pending_windows().contains(window_label) + } + // A set of labels cannot be left half-updated, so a poisoned lock still holds a usable set. fn pending_windows(&self) -> MutexGuard<'_, HashSet> { self.pending_windows @@ -40,9 +59,156 @@ impl CloseRequestGuard { } } +/// Closes the windows one at a time while quitting, each through its own close request, and stops +/// at the first window whose close is declined. Windows not yet reached stay open. +#[derive(Debug, Default)] +pub(crate) struct QuitSequence { + progress: Mutex>, +} + +#[derive(Debug)] +struct QuitProgress { + current: String, + remaining: VecDeque, +} + +impl QuitSequence { + /// Returns the first window to close, or nothing while an earlier quit is still under way. + fn start(&self, window_labels: Vec) -> Option { + let mut progress = self.progress(); + + if progress.is_some() { + return None; + } + + let mut remaining = VecDeque::from(window_labels); + let current = remaining.pop_front()?; + + *progress = Some(QuitProgress { + current: current.clone(), + remaining, + }); + + Some(current) + } + + /// Returns the next window to close once the window being closed is gone. A window closed out + /// of turn is only dropped from those still to come. + fn window_closed(&self, window_label: &str) -> Option { + let mut progress = self.progress(); + let quit = progress.as_mut()?; + + if quit.current != window_label { + quit.remaining.retain(|label| label != window_label); + return None; + } + + match quit.remaining.pop_front() { + Some(next) => { + quit.current = next.clone(); + Some(next) + } + None => { + *progress = None; + None + } + } + } + + fn close_declined(&self, window_label: &str) { + let mut progress = self.progress(); + + if progress + .as_ref() + .is_some_and(|quit| quit.current == window_label) + { + *progress = None; + } + } + + fn stop(&self) { + *self.progress() = None; + } + + // Each update leaves whole values behind, so a poisoned lock still holds a usable sequence. + fn progress(&self) -> MutexGuard<'_, Option> { + self.progress.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// Numbers the windows opened after the first, so each has its own label. +#[derive(Debug)] +pub(crate) struct WindowNumbering { + next: AtomicU32, +} + +impl Default for WindowNumbering { + fn default() -> Self { + Self { + next: AtomicU32::new(2), + } + } +} + +impl WindowNumbering { + fn next_label(&self) -> String { + format!("window-{}", self.next.fetch_add(1, Ordering::Relaxed)) + } +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum OpenWindowError { + OpenFailed { message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Bounds { + x: i32, + y: i32, + width: u32, + height: u32, +} + +impl Bounds { + fn right(self) -> i64 { + i64::from(self.x) + i64::from(self.width) + } + + fn bottom(self) -> i64 { + i64::from(self.y) + i64::from(self.height) + } +} + +/// Places a new window down and to the right of the window it was opened from, or at the top left +/// of that window's work area when the offset window would not fit there. +fn cascade_position(reference: Bounds, offset: i32, work_area: Option) -> (i32, i32) { + let placed = Bounds { + x: reference.x.saturating_add(offset), + y: reference.y.saturating_add(offset), + ..reference + }; + + match work_area { + Some(area) if placed.right() > area.right() || placed.bottom() > area.bottom() => { + (area.x, area.y) + } + _ => (placed.x, placed.y), + } +} + #[tauri::command] -pub(crate) fn decline_window_close(window: Window, guard: State<'_, CloseRequestGuard>) { +pub(crate) fn decline_window_close( + window: Window, + guard: State<'_, CloseRequestGuard>, + quit: State<'_, QuitSequence>, +) { guard.mark_answered(window.label()); + quit.close_declined(window.label()); } pub(crate) fn handle_close_requested(window: &Window, api: &CloseRequestApi) { @@ -69,9 +235,147 @@ pub(crate) fn forget_close_request(window: &Window) { .mark_answered(window.label()); } +/// Quits by closing every window in turn, starting with the one that asked, as `Close window` would +/// close each. Closing the last window ends the application. +#[tauri::command] +pub(crate) fn quit_application( + app: AppHandle, + window: Window, + documents: State<'_, WindowDocuments>, + quit: State<'_, QuitSequence>, +) { + let mut labels = vec![window.label().to_owned()]; + + for label in documents + .window_labels() + .into_iter() + .chain(app.webview_windows().into_keys()) + { + if !labels.contains(&label) { + labels.push(label); + } + } + + close_next_window(&app, quit.start(labels)); +} + +pub(crate) fn continue_quit(window: &Window) { + let next = window.state::().window_closed(window.label()); + + close_next_window(window.app_handle(), next); +} + +fn close_next_window(app: &AppHandle, mut next: Option) { + let quit = app.state::(); + + while let Some(label) = next { + let Some(window) = app.get_webview_window(label.as_str()) else { + next = quit.window_closed(label.as_str()); + continue; + }; + + // Asking again would close a window whose frontend is still answering; its answer moves + // the quit on instead. + if app.state::().is_pending(label.as_str()) { + return; + } + + if let Err(error) = window.close() { + log::error!("stopped quitting: failed to close window {label}: {error}"); + quit.stop(); + } + + return; + } +} + +/// Opens another window on the welcome screen, offset from the window that asked for it. Only the +/// first window's size and position are kept between launches. +#[tauri::command] +pub(crate) async fn open_new_window( + app: AppHandle, + window: Window, + numbering: State<'_, WindowNumbering>, +) -> Result<(), OpenWindowError> { + let open_failed = |message: String| OpenWindowError::OpenFailed { message }; + let mut config = app + .config() + .app + .windows + .iter() + .find(|config| config.label == MAIN_WINDOW_LABEL) + .cloned() + .ok_or_else(|| open_failed("the main window has no configuration".to_owned()))?; + + config.label = numbering.next_label(); + + let created = WebviewWindowBuilder::from_config(&app, &config) + .and_then(|builder| builder.build()) + .map_err(|error| open_failed(error.to_string()))?; + + // The window stays hidden until its frontend shows it, so placing it after creation is unseen. + let placed = match cascade_bounds(&window) { + Some((size, position)) => created + .set_size(size) + .and_then(|()| created.set_position(position)), + None => created.center(), + }; + + if let Err(error) = placed { + log::warn!("failed to place window {}: {error}", created.label()); + } + + Ok(()) +} + +fn cascade_bounds( + reference: &Window, +) -> Option<(PhysicalSize, PhysicalPosition)> { + if reference.is_maximized().ok()? + || reference.is_fullscreen().ok()? + || reference.is_minimized().ok()? + { + return None; + } + + let position = reference.outer_position().ok()?; + let size = reference.inner_size().ok()?; + let scale_factor = reference.scale_factor().ok()?; + let work_area = reference.current_monitor().ok().flatten().map(|monitor| { + let area = monitor.work_area(); + + Bounds { + x: area.position.x, + y: area.position.y, + width: area.size.width, + height: area.size.height, + } + }); + let offset = (NEW_WINDOW_OFFSET * scale_factor).round() as i32; + let (x, y) = cascade_position( + Bounds { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }, + offset, + work_area, + ); + + Some((size, PhysicalPosition::new(x, y))) +} + #[cfg(test)] mod tests { - use super::{CloseRequestDecision, CloseRequestGuard}; + use super::{ + Bounds, CloseRequestDecision, CloseRequestGuard, QuitSequence, WindowNumbering, + cascade_position, + }; + + fn labels(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } #[test] fn asks_the_frontend_to_handle_a_close_request() { @@ -149,5 +453,144 @@ mod tests { guard.register_request("second"), CloseRequestDecision::Close ); + assert!(guard.is_pending("second")); + assert!(!guard.is_pending("third")); + } + + #[test] + fn quits_by_closing_each_window_in_turn() { + let quit = QuitSequence::default(); + + assert_eq!( + quit.start(labels(&["window-2", "main", "window-3"])), + Some("window-2".to_owned()) + ); + assert_eq!(quit.window_closed("window-2"), Some("main".to_owned())); + assert_eq!(quit.window_closed("main"), Some("window-3".to_owned())); + assert_eq!(quit.window_closed("window-3"), None); + assert_eq!( + quit.start(labels(&["main"])), + Some("main".to_owned()), + "a finished quit leaves the next one free to start" + ); + } + + #[test] + fn a_quit_declined_in_its_second_window_leaves_that_window_and_the_rest_open() { + let quit = QuitSequence::default(); + + quit.start(labels(&["main", "window-2", "window-3"])); + assert_eq!(quit.window_closed("main"), Some("window-2".to_owned())); + + quit.close_declined("window-2"); + + assert_eq!( + quit.window_closed("window-2"), + None, + "a declined quit closes nothing further" + ); + assert_eq!(quit.window_closed("window-3"), None); + assert_eq!( + quit.start(labels(&["window-2", "window-3"])), + Some("window-2".to_owned()) + ); + } + + #[test] + fn a_decline_from_a_window_not_being_closed_leaves_the_quit_going() { + let quit = QuitSequence::default(); + + quit.start(labels(&["main", "window-2"])); + quit.close_declined("window-2"); + + assert_eq!(quit.window_closed("main"), Some("window-2".to_owned())); + } + + #[test] + fn a_window_closed_out_of_turn_is_not_asked_again() { + let quit = QuitSequence::default(); + + quit.start(labels(&["main", "window-2", "window-3"])); + + assert_eq!(quit.window_closed("window-2"), None); + assert_eq!(quit.window_closed("main"), Some("window-3".to_owned())); + } + + #[test] + fn a_second_quit_while_one_is_under_way_changes_nothing() { + let quit = QuitSequence::default(); + + quit.start(labels(&["main", "window-2"])); + + assert_eq!(quit.start(labels(&["window-2", "main"])), None); + assert_eq!(quit.window_closed("main"), Some("window-2".to_owned())); + } + + #[test] + fn a_stopped_quit_closes_nothing_further() { + let quit = QuitSequence::default(); + + quit.start(labels(&["main", "window-2"])); + quit.stop(); + + assert_eq!(quit.window_closed("main"), None); + } + + #[test] + fn labels_each_new_window_once() { + let numbering = WindowNumbering::default(); + + assert_eq!(numbering.next_label(), "window-2"); + assert_eq!(numbering.next_label(), "window-3"); + } + + #[test] + fn cascades_a_new_window_from_the_window_it_was_opened_from() { + let reference = Bounds { + x: 100, + y: 80, + width: 800, + height: 600, + }; + let work_area = Bounds { + x: 0, + y: 0, + width: 1920, + height: 1040, + }; + + assert_eq!(cascade_position(reference, 48, Some(work_area)), (148, 128)); + assert_eq!(cascade_position(reference, 48, None), (148, 128)); + } + + #[test] + fn starts_over_at_the_work_area_corner_when_the_offset_window_would_not_fit() { + let work_area = Bounds { + x: -1920, + y: 0, + width: 1920, + height: 1040, + }; + let near_right = Bounds { + x: -820, + y: 100, + width: 800, + height: 600, + }; + let near_bottom = Bounds { + x: -1800, + y: 420, + width: 800, + height: 600, + }; + + assert_eq!( + cascade_position(near_right, 32, Some(work_area)), + (-1920, 0) + ); + assert_eq!( + cascade_position(near_bottom, 32, Some(work_area)), + (-1920, 0) + ); } } diff --git a/src-tauri/src/window_documents.rs b/src-tauri/src/window_documents.rs new file mode 100644 index 00000000..9d7b6862 --- /dev/null +++ b/src-tauri/src/window_documents.rs @@ -0,0 +1,517 @@ +use std::{ + path::Path, + sync::{Mutex, MutexGuard, PoisonError}, +}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, Runtime, State, Window}; + +use crate::path_utils::path_identity_key; + +const WINDOW_DOCUMENTS_CHANGED_EVENT: &str = "leafdown://window-documents-changed"; +const ACTIVATE_DOCUMENT_EVENT: &str = "leafdown://activate-document"; +const DOCUMENTS_MOVED_EVENT: &str = "leafdown://documents-moved"; +const DOCUMENTS_REMOVED_EVENT: &str = "leafdown://documents-removed"; + +/// A saved document open in a window's tab. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WindowDocument { + path: String, + unsaved: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct WindowDocumentsEntry { + window_label: String, + documents: Vec, +} + +/// Every window's documents. The revision orders snapshots that reach a window out of order. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct WindowDocumentsSnapshot { + revision: u64, + windows: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentPathPayload { + path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentsMovedPayload { + from_path: String, + to_path: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub(crate) enum ClaimWindowDocumentResult { + Claimed, + OpenElsewhere, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum WindowDocumentsError { + InvalidPath { path: String }, +} + +/// Which saved document each window holds, so a file is open in at most one tab across windows. +/// Windows are kept in the order they were first seen, which is the order `Quit` asks them in. +#[derive(Debug, Default)] +pub(crate) struct WindowDocuments { + state: Mutex, +} + +#[derive(Debug, Default)] +struct Registry { + revision: u64, + windows: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +enum Claim { + Claimed, + HeldBy { window_label: String, path: String }, +} + +impl WindowDocuments { + /// Replaces a window's documents, returning the snapshot to publish when they changed. + fn set_documents( + &self, + window_label: &str, + documents: Vec, + ) -> Option { + let mut registry = self.registry(); + let entry = registry.entry(window_label); + + if entry.documents == documents { + return None; + } + + entry.documents = documents; + + Some(registry.publish()) + } + + /// Records a document for a window unless another window already holds it, checking and + /// recording in one step so two windows cannot both open the same file. + fn claim(&self, window_label: &str, path: &str) -> (Claim, Option) { + let key = path_identity_key(path); + let mut registry = self.registry(); + + if let Some((holder, document)) = registry.windows.iter().find_map(|entry| { + entry + .documents + .iter() + .find(|document| path_identity_key(document.path.as_str()) == key) + .map(|document| (entry.window_label.as_str(), document)) + }) { + if holder != window_label { + return ( + Claim::HeldBy { + window_label: holder.to_owned(), + path: document.path.clone(), + }, + None, + ); + } + + return (Claim::Claimed, None); + } + + registry.entry(window_label).documents.push(WindowDocument { + path: path.to_owned(), + unsaved: false, + }); + + (Claim::Claimed, Some(registry.publish())) + } + + fn forget_window(&self, window_label: &str) -> Option { + let mut registry = self.registry(); + let count = registry.windows.len(); + + registry + .windows + .retain(|entry| entry.window_label != window_label); + + (registry.windows.len() != count).then(|| registry.publish()) + } + + /// The windows in the order they were first seen. + pub(crate) fn window_labels(&self) -> Vec { + self.registry() + .windows + .iter() + .map(|entry| entry.window_label.clone()) + .collect() + } + + // The registry holds plain values that each update replaces whole, so a poisoned lock still + // holds a usable registry. + fn registry(&self) -> MutexGuard<'_, Registry> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl Registry { + fn entry(&mut self, window_label: &str) -> &mut WindowDocumentsEntry { + let index = match self + .windows + .iter() + .position(|entry| entry.window_label == window_label) + { + Some(index) => index, + None => { + self.windows.push(WindowDocumentsEntry { + window_label: window_label.to_owned(), + documents: Vec::new(), + }); + self.windows.len() - 1 + } + }; + + &mut self.windows[index] + } + + fn publish(&mut self) -> WindowDocumentsSnapshot { + self.revision += 1; + + WindowDocumentsSnapshot { + revision: self.revision, + windows: self.windows.clone(), + } + } +} + +fn validate_path(path: &str) -> Result<(), WindowDocumentsError> { + if path.is_empty() || !Path::new(path).is_absolute() { + return Err(WindowDocumentsError::InvalidPath { + path: path.to_owned(), + }); + } + + Ok(()) +} + +fn publish(app: &AppHandle, snapshot: Option) { + if let Some(snapshot) = snapshot + && let Err(error) = app.emit(WINDOW_DOCUMENTS_CHANGED_EVENT, snapshot) + { + log::error!("failed to publish the windows' documents: {error}"); + } +} + +fn emit_to_other_windows( + app: &AppHandle, + sender_label: &str, + event: &str, + payload: S, +) { + for label in app.webview_windows().into_keys() { + if label != sender_label + && let Err(error) = app.emit_to(label.as_str(), event, payload.clone()) + { + log::error!("failed to emit {event} to window {label}: {error}"); + } + } +} + +fn bring_forward(app: &AppHandle, window_label: &str) { + let Some(window) = app.get_webview_window(window_label) else { + return; + }; + + for (step, result) in [ + ("unminimize", window.unminimize()), + ("show", window.show()), + ("focus", window.set_focus()), + ] { + if let Err(error) = result { + log::warn!("failed to {step} window {window_label}: {error}"); + } + } +} + +#[tauri::command] +pub(crate) fn set_window_documents( + app: AppHandle, + window: Window, + state: State<'_, WindowDocuments>, + documents: Vec, +) -> Result<(), WindowDocumentsError> { + for document in &documents { + validate_path(document.path.as_str())?; + } + + publish(&app, state.set_documents(window.label(), documents)); + + Ok(()) +} + +/// Claims a document for the calling window before it opens or saves to it. When another window +/// holds it, that window is asked to show it if `activate` is set, and the caller opens nothing. +#[tauri::command] +pub(crate) fn claim_window_document( + app: AppHandle, + window: Window, + state: State<'_, WindowDocuments>, + path: String, + activate: bool, +) -> Result { + validate_path(path.as_str())?; + + let (claim, snapshot) = state.claim(window.label(), path.as_str()); + + publish(&app, snapshot); + + match claim { + Claim::Claimed => Ok(ClaimWindowDocumentResult::Claimed), + Claim::HeldBy { window_label, path } => { + if activate { + if let Err(error) = app.emit_to( + window_label.as_str(), + ACTIVATE_DOCUMENT_EVENT, + DocumentPathPayload { path }, + ) { + log::error!("failed to ask window {window_label} to show a document: {error}"); + } + + bring_forward(&app, window_label.as_str()); + } + + Ok(ClaimWindowDocumentResult::OpenElsewhere) + } + } +} + +/// Tells the other windows that a rename moved `from_path`, so they move the tabs under it. +#[tauri::command] +pub(crate) fn notify_documents_moved( + app: AppHandle, + window: Window, + from_path: String, + to_path: String, +) -> Result<(), WindowDocumentsError> { + validate_path(from_path.as_str())?; + validate_path(to_path.as_str())?; + + emit_to_other_windows( + &app, + window.label(), + DOCUMENTS_MOVED_EVENT, + DocumentsMovedPayload { from_path, to_path }, + ); + + Ok(()) +} + +/// Tells the other windows that `path` was deleted. A deleted folder's own directory watch reports +/// nothing about the documents inside it, so each window checks the documents under the path. +#[tauri::command] +pub(crate) fn notify_documents_removed( + app: AppHandle, + window: Window, + path: String, +) -> Result<(), WindowDocumentsError> { + validate_path(path.as_str())?; + + emit_to_other_windows( + &app, + window.label(), + DOCUMENTS_REMOVED_EVENT, + DocumentPathPayload { path }, + ); + + Ok(()) +} + +pub(crate) fn forget_window_documents(window: &Window) { + let snapshot = window + .state::() + .forget_window(window.label()); + + publish(window.app_handle(), snapshot); +} + +#[cfg(test)] +mod tests { + use super::{ + Claim, ClaimWindowDocumentResult, WindowDocument, WindowDocuments, WindowDocumentsError, + validate_path, + }; + + fn document(path: &str, unsaved: bool) -> WindowDocument { + WindowDocument { + path: path.to_owned(), + unsaved, + } + } + + #[test] + fn claims_a_document_no_window_holds() { + let registry = WindowDocuments::default(); + + let (claim, snapshot) = registry.claim("main", r"C:\Notes\a.md"); + + assert_eq!(claim, Claim::Claimed); + let snapshot = snapshot.expect("a new claim should be published"); + assert_eq!(snapshot.windows.len(), 1); + assert_eq!(snapshot.windows[0].window_label, "main"); + assert_eq!( + snapshot.windows[0].documents, + [document(r"C:\Notes\a.md", false)] + ); + } + + #[test] + fn refuses_a_document_another_window_holds_under_any_spelling() { + let registry = WindowDocuments::default(); + + registry.set_documents("main", vec![document(r"C:\Notes\A.md", true)]); + + assert_eq!( + registry.claim("window-2", "c:/notes/a.md").0, + Claim::HeldBy { + window_label: "main".to_owned(), + path: r"C:\Notes\A.md".to_owned(), + } + ); + assert_eq!( + registry.claim("window-2", r"C:\Notes\b.md").0, + Claim::Claimed + ); + } + + #[test] + fn lets_a_window_claim_a_document_it_already_holds() { + let registry = WindowDocuments::default(); + + registry.set_documents("main", vec![document(r"C:\Notes\a.md", false)]); + + let (claim, snapshot) = registry.claim("main", r"c:\notes\A.md"); + + assert_eq!(claim, Claim::Claimed); + assert!( + snapshot.is_none(), + "nothing changed, so nothing is published" + ); + } + + #[test] + fn a_claim_lasts_until_the_window_reports_its_documents() { + let registry = WindowDocuments::default(); + + registry.claim("main", r"C:\Notes\a.md"); + assert!(matches!( + registry.claim("window-2", r"C:\Notes\a.md").0, + Claim::HeldBy { .. } + )); + + registry.set_documents("main", Vec::new()); + + assert_eq!( + registry.claim("window-2", r"C:\Notes\a.md").0, + Claim::Claimed + ); + } + + #[test] + fn publishes_only_changed_documents_with_increasing_revisions() { + let registry = WindowDocuments::default(); + + let first = registry + .set_documents("main", vec![document("/notes/a.md", false)]) + .expect("new documents should be published"); + assert!( + registry + .set_documents("main", vec![document("/notes/a.md", false)]) + .is_none() + ); + let second = registry + .set_documents("main", vec![document("/notes/a.md", true)]) + .expect("a document gaining unsaved changes should be published"); + + assert!(second.revision > first.revision); + assert_eq!(second.windows[0].documents, [document("/notes/a.md", true)]); + } + + #[test] + fn forgets_a_destroyed_window_and_frees_its_documents() { + let registry = WindowDocuments::default(); + + registry.set_documents("main", vec![document("/notes/a.md", true)]); + registry.set_documents("window-2", Vec::new()); + + let snapshot = registry + .forget_window("main") + .expect("forgetting a window should be published"); + + assert_eq!(snapshot.windows.len(), 1); + assert_eq!(registry.claim("window-2", "/notes/a.md").0, Claim::Claimed); + assert!(registry.forget_window("main").is_none()); + } + + #[test] + fn lists_windows_in_the_order_they_were_first_seen() { + let registry = WindowDocuments::default(); + + registry.set_documents("main", Vec::new()); + registry.claim("window-3", "/notes/a.md"); + registry.set_documents("window-2", Vec::new()); + registry.set_documents("main", vec![document("/notes/b.md", false)]); + + assert_eq!(registry.window_labels(), ["main", "window-3", "window-2"]); + } + + #[test] + fn refuses_relative_and_empty_paths() { + assert_eq!( + validate_path("notes/a.md"), + Err(WindowDocumentsError::InvalidPath { + path: "notes/a.md".to_owned() + }) + ); + assert!(validate_path("").is_err()); + } + + #[test] + fn serializes_claims_and_snapshots_for_the_frontend() { + let registry = WindowDocuments::default(); + let (_, snapshot) = registry.claim("main", "/notes/a.md"); + + assert_eq!( + serde_json::to_value(snapshot).expect("the snapshot should serialize"), + serde_json::json!({ + "revision": 1, + "windows": [{ + "windowLabel": "main", + "documents": [{ "path": "/notes/a.md", "unsaved": false }], + }], + }) + ); + assert_eq!( + serde_json::to_value(ClaimWindowDocumentResult::OpenElsewhere) + .expect("the claim should serialize"), + serde_json::json!({ "kind": "openElsewhere" }) + ); + assert_eq!( + serde_json::to_value(WindowDocumentsError::InvalidPath { + path: "a.md".to_owned() + }) + .expect("the error should serialize"), + serde_json::json!({ "kind": "invalidPath", "path": "a.md" }) + ); + } +} diff --git a/src/app.test.tsx b/src/app.test.tsx index cc831ec7..8b9ef2e2 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -140,6 +140,37 @@ describe("App", () => { } }); + it("follows always on top when another window changes the setting", async () => { + const appWindow = getCurrentWindow(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + render(); + + await waitFor(() => { + expect(appWindow.show).toHaveBeenCalled(); + }); + expect(appWindow.setAlwaysOnTop).not.toHaveBeenCalled(); + + useSettingsStore.getState().updateSetting("alwaysOnTop", true); + + await waitFor(() => expect(appWindow.setAlwaysOnTop).toHaveBeenLastCalledWith(true)); + + vi.mocked(appWindow.setAlwaysOnTop).mockRejectedValueOnce(new Error("window unavailable")); + useSettingsStore.getState().updateSetting("alwaysOnTop", false); + + await waitFor(() => + expect(toastManager.add).toHaveBeenCalledWith({ + description: "window unavailable", + title: "Could not apply always on top to this window.", + type: "error", + }), + ); + } finally { + consoleError.mockRestore(); + } + }); + it("syncs explicit theme settings without reading the native window theme", async () => { const appWindow = getCurrentWindow(); setDefaultSettings({ theme: "dark" }); @@ -274,6 +305,7 @@ describe("App", () => { await handleCloseRequested({ payload: undefined }); expect(confirmation.useConfirmationStore.getState().current).toBeNull(); + expect(appWindow.setFocus).not.toHaveBeenCalled(); await waitFor(() => { expect(getLastDiagnosticPayload("info")).toMatchObject({ event: "operationLifecycle", @@ -308,6 +340,9 @@ describe("App", () => { const closeRequest = handleCloseRequested({ payload: undefined }); const prompt = await screen.findByRole("dialog", { name: "Unsaved changes" }); expect(prompt).toHaveTextContent("Untitled has unsaved changes."); + // Quit asks each window in turn, so the window asking comes forward first. + expect(appWindow.unminimize).toHaveBeenCalledOnce(); + expect(appWindow.setFocus).toHaveBeenCalledOnce(); await user.click(screen.getByRole("button", { name: "Keep editing" })); await closeRequest; expect(appWindow.destroy).not.toHaveBeenCalled(); diff --git a/src/app.tsx b/src/app.tsx index 622647a3..0bf237e0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/toast"; import { writeDiagnosticOperationLifecycle } from "@/features/diagnostics"; import { applyPersistedAlwaysOnTop, + followAlwaysOnTopSetting, recentItemsStoreTauriHandler, settingsStoreTauriHandler, useSettingsStore, @@ -16,7 +17,7 @@ import { } from "@/features/preferences"; import { initializeReleaseNotes } from "@/features/release-notes"; import { - confirmDiscardAllDocumentChanges, + confirmWindowClose, declineWindowClose, DroppedPathOverlay, openLaunchDocument, @@ -50,6 +51,8 @@ export function App() { const droppedPathIndicator = useDroppedPathListener(); useEffect(() => { + const disposables = new DisposableStore(); + const initializeApp = async () => { try { await Promise.all([ @@ -74,6 +77,15 @@ export function App() { "applyPersistedAlwaysOnTop", ), ); + disposables.add( + followAlwaysOnTopSetting((error) => + notifyOperationFailure( + localizer.current.t("app.applyAlwaysOnTopFailed"), + error, + "followAlwaysOnTopSetting", + ), + ), + ); await updateTheme(useSettingsStore.getState().theme); } finally { // A hidden window has no chrome, focus, or taskbar entry, so it cannot be asked to close. @@ -93,6 +105,10 @@ export function App() { ) .then(() => openLaunchDocument()) .catch((error) => handleUnexpectedError(error, "openLaunchDocument")); + + return () => { + disposables.dispose(); + }; }, []); useTauriEvent( @@ -100,7 +116,7 @@ export function App() { async () => { // The backend closes on the next request while one is pending, so only a deliberate // decision answers it: a handler that keeps failing must stay silent to stay closable. - if (!(await confirmDiscardAllDocumentChanges())) { + if (!(await confirmWindowClose())) { await declineWindowClose(); return; } diff --git a/src/commands/actions/file.test.ts b/src/commands/actions/file.test.ts index 86b9b231..92600fca 100644 --- a/src/commands/actions/file.test.ts +++ b/src/commands/actions/file.test.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { describe, expect, it, vi } from "vitest"; @@ -15,16 +16,21 @@ import { TEST_NESTED_MARKDOWN_FILE_PATH, } from "@/test/fixtures/paths"; import { setDefaultRecentItems, setDefaultSettings } from "@/test/utils/appStores"; +import { mockInvokeCommands } from "@/test/utils/tauri"; import { mockTauriApiCommand } from "@/test/utils/tauriApi"; import { useCommandUIStore } from "../stores/commandUi"; import { clearRecentItems, exportDocumentAsHtml, + OPEN_NEW_WINDOW_COMMAND, openMarkdownFile, openLocation, + openNewWindow, openPreferences, openRecentMarkdownFile, + quit, + QUIT_APPLICATION_COMMAND, revealInSidebar, } from "./file"; @@ -200,4 +206,37 @@ describe("file actions", () => { openPreferences(); expect(useCommandUIStore.getState().preferencesOpen).toBe(true); }); + + it("asks the backend for a new window and reports a window that cannot open", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mockInvokeCommands({ [OPEN_NEW_WINDOW_COMMAND]: () => undefined }); + + await openNewWindow(); + + expect(invoke).toHaveBeenCalledWith(OPEN_NEW_WINDOW_COMMAND); + + mockInvokeCommands({ + [OPEN_NEW_WINDOW_COMMAND]: () => + Promise.reject({ kind: "openFailed", message: "out of handles" }), + }); + + try { + await openNewWindow(); + + expect(toastManager.add).toHaveBeenCalledWith( + expect.objectContaining({ title: "Could not open a new window.", type: "error" }), + ); + } finally { + consoleError.mockRestore(); + } + }); + + it("quits through the backend, which closes each window in turn", async () => { + mockInvokeCommands({ [QUIT_APPLICATION_COMMAND]: () => undefined }); + + await quit(); + + expect(invoke).toHaveBeenCalledWith(QUIT_APPLICATION_COMMAND); + expect(toastManager.add).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/actions/file.ts b/src/commands/actions/file.ts index b75c0bbc..01fe91b2 100644 --- a/src/commands/actions/file.ts +++ b/src/commands/actions/file.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; @@ -38,6 +39,9 @@ import { import { disabled, enabled } from "../statePrimitives"; import { useCommandUIStore } from "../stores/commandUi"; +export const OPEN_NEW_WINDOW_COMMAND = "open_new_window"; +export const QUIT_APPLICATION_COMMAND = "quit_application"; + const saveWithFeedback = async (saveFn: () => Promise) => { try { const saved = await saveFn(); @@ -232,6 +236,23 @@ export const closeWindow = async () => { } }; +export const openNewWindow = async () => { + try { + await invoke(OPEN_NEW_WINDOW_COMMAND); + } catch (error) { + notifyOperationFailure(t("commands.file.newWindowFailed"), error, "openNewWindow"); + } +}; + +/** Closes every window in turn, each asking about its own unsaved documents. */ +export const quit = async () => { + try { + await invoke(QUIT_APPLICATION_COMMAND); + } catch (error) { + notifyOperationFailure(t("commands.file.quitFailed"), error, "quit"); + } +}; + export const getClearRecentItemsState = (context: AppCommandContext) => context.recentItems.recentFiles.length > 0 || context.recentItems.recentFolders.length > 0 ? enabled() diff --git a/src/commands/application.ts b/src/commands/application.ts index 0f760d18..2eab5351 100644 --- a/src/commands/application.ts +++ b/src/commands/application.ts @@ -22,6 +22,7 @@ const appCommand = ( export const APPLICATION_COMMANDS = { "file.new": appCommand(file.createUntitledDocument), + "file.newWindow": appCommand(file.openNewWindow), "file.open": appCommand(file.openMarkdownFile), "file.openFolder": appCommand(file.openFolderContext), "file.clearRecentItems": appCommand(file.clearRecentItems, file.getClearRecentItemsState), @@ -34,6 +35,7 @@ export const APPLICATION_COMMANDS = { "file.closeDocument": appCommand(file.closeDocument, file.getCloseDocumentState), "file.closeFolder": appCommand(file.closeFolderContext, file.getCloseFolderState), "file.closeWindow": appCommand(file.closeWindow), + "file.quit": appCommand(file.quit), "edit.lineEnding.crlf": appCommand(edit.setCrlfLineEnding, edit.getCrlfLineEndingState), "edit.lineEnding.lf": appCommand(edit.setLfLineEnding, edit.getLfLineEndingState), diff --git a/src/commands/metadata.ts b/src/commands/metadata.ts index fcf288f4..3efdd68c 100644 --- a/src/commands/metadata.ts +++ b/src/commands/metadata.ts @@ -31,6 +31,7 @@ export const getCommandMenuLabelId = (menuId: CommandMenuId) => `menu.${menuId}` export const COMMAND_DEFINITIONS: Record = { "file.new": commandDef({ key: "n", mod: true }), + "file.newWindow": commandDef({ key: "n", mod: true, shift: true }), "file.open": commandDef({ key: "o", mod: true }), "file.openFolder": commandDef({ key: "o", mod: true, shift: true }), "file.clearRecentItems": commandDef(), @@ -42,7 +43,8 @@ export const COMMAND_DEFINITIONS: Record = { "file.preferences": commandDef({ key: ",", mod: true }), "file.closeDocument": commandDef({ key: "w", mod: true }), "file.closeFolder": commandDef(), - "file.closeWindow": commandDef({ key: "q", mod: true }, { key: "F4", alt: true }), + "file.closeWindow": commandDef({ key: "F4", alt: true }), + "file.quit": commandDef({ key: "q", mod: true }), "edit.undo": commandDef({ key: "z", mod: true }), "edit.redo": commandDef({ key: "y", mod: true }, { key: "z", mod: true, shift: true }), "edit.cut": commandDef({ key: "x", mod: true }), diff --git a/src/commands/shortcutReference.test.ts b/src/commands/shortcutReference.test.ts index fcee6b14..77f2db01 100644 --- a/src/commands/shortcutReference.test.ts +++ b/src/commands/shortcutReference.test.ts @@ -45,7 +45,20 @@ describe("shortcut reference", () => { ]); expect(entries.map(({ id }) => id)).not.toContain("help.about"); expect(entries.map(({ id }) => id)).not.toContain("file.print"); - expect(entries.map(({ id }) => id)).not.toContain("file.newWindow"); + }); + + it("gives Mod+Q to Quit, leaving Close window on Alt+F4", () => { + const entries = getShortcutReferenceGroups().flatMap((group) => group.entries); + + expect(entries.find(({ id }) => id === "file.newWindow")?.shortcuts).toEqual([ + { key: "n", mod: true, shift: true }, + ]); + expect(entries.find(({ id }) => id === "file.quit")?.shortcuts).toEqual([ + { key: "q", mod: true }, + ]); + expect(entries.find(({ id }) => id === "file.closeWindow")?.shortcuts).toEqual([ + { key: "F4", alt: true }, + ]); }); it("reflects shortcut metadata changes without changing the reference", () => { diff --git a/src/components/layout/command-menubar.test.tsx b/src/components/layout/command-menubar.test.tsx index a43c2c5d..5ee0ca31 100644 --- a/src/components/layout/command-menubar.test.tsx +++ b/src/components/layout/command-menubar.test.tsx @@ -109,7 +109,9 @@ describe("CommandMenubar", () => { await user.click(screen.getByRole("menuitem", { name: "File" })); - expect(menuItem(/^New/u)).toBeInTheDocument(); + expect(menuItem(/^New(?! window)/u)).toBeInTheDocument(); + expect(menuItem(/^New window/u)).not.toHaveAttribute("data-disabled"); + expect(menuItem(/^Quit/u)).not.toHaveAttribute("data-disabled"); expect(menuItem(/^Save(?! as)/u)).toHaveAttribute("data-disabled"); expect(menuItem(/^Save as/u)).toHaveAttribute("data-disabled"); expect(menuItem(/^Reveal in sidebar/u)).toHaveAttribute("data-disabled"); diff --git a/src/components/layout/command-menubar.tsx b/src/components/layout/command-menubar.tsx index 3dd41de0..a7bcec73 100644 --- a/src/components/layout/command-menubar.tsx +++ b/src/components/layout/command-menubar.tsx @@ -111,7 +111,7 @@ export function CommandMenubar({ {t(getCommandMenuLabelId("file"))} - + + + diff --git a/src/components/layout/shell.tsx b/src/components/layout/shell.tsx index 61b69791..a6b220f2 100644 --- a/src/components/layout/shell.tsx +++ b/src/components/layout/shell.tsx @@ -39,6 +39,7 @@ import { useFolderContextWatcher, useFolderSearchSession, useSessionStore, + useWindowDocuments, } from "@/features/session"; import { handleUnexpectedError } from "@/lib/errors"; import { useLocalization } from "@/lib/i18n"; @@ -78,6 +79,7 @@ const handleApplyFolderReplacement = () => { export function Shell() { useFolderContextWatcher(); useDocumentWatchers(); + useWindowDocuments(); useFolderSearchSession(); const localization = useLocalization(); diff --git a/src/features/folder-search/services/folderReplaceReport.ts b/src/features/folder-search/services/folderReplaceReport.ts index 9aefbc1e..d9d05749 100644 --- a/src/features/folder-search/services/folderReplaceReport.ts +++ b/src/features/folder-search/services/folderReplaceReport.ts @@ -1,7 +1,11 @@ import type { TextEncodingName } from "@/features/document"; /** Why a file planned for replacement was left as it was. */ -export type FolderReplaceStaleReason = "changedOnDisk" | "missing" | "changedInEditor"; +export type FolderReplaceStaleReason = + | "changedOnDisk" + | "missing" + | "changedInEditor" + | "unsavedInAnotherWindow"; export type FolderReplaceFailureReason = | "notArticle" diff --git a/src/features/folder-search/services/folderSearchEngine.test.ts b/src/features/folder-search/services/folderSearchEngine.test.ts index 1f6b87fd..65687cec 100644 --- a/src/features/folder-search/services/folderSearchEngine.test.ts +++ b/src/features/folder-search/services/folderSearchEngine.test.ts @@ -175,6 +175,7 @@ const createDisk = (files: Record) => { const createEngine = ( disk: ReturnType, activeDocument: () => FolderSearchOpenDocument | null = () => null, + isUnsavedElsewhere?: (path: string) => boolean, ) => { const parser = createParser(); const published: (FolderSearchResults | null)[] = []; @@ -187,6 +188,7 @@ const createEngine = ( return open && isSamePath(open.path, path) ? open : null; }, + isUnsavedElsewhere, readFiles: disk.readFiles, }, (results) => published.push(results), @@ -739,6 +741,27 @@ describe("folder search engine", () => { expect(results.skipped).toEqual([{ path: `${FOLDER}/a.md`, reason: "replacementRefused" }]); }); + it("names and leaves alone a file another window holds with unsaved changes", async () => { + const disk = createDisk({ "a.md": "leaf", "b.md": "leaf", "c.md": "branch" }); + const elsewhere = new Set([`${FOLDER}/a.md`, `${FOLDER}/c.md`]); + const { replace, search } = createEngine( + disk, + () => null, + (path) => elsewhere.has(path), + ); + + const results = await replace("leaf", "tree"); + + expect(results.files.map(({ path }) => path)).toEqual([`${FOLDER}/b.md`]); + expect(results.skipped).toEqual([ + { path: `${FOLDER}/a.md`, reason: "unsavedInAnotherWindow" }, + ]); + expect((await search("leaf")).files.map(({ path }) => path)).toEqual([ + `${FOLDER}/a.md`, + `${FOLDER}/b.md`, + ]); + }); + it("leaves out a file the replacement would not change", async () => { const disk = createDisk({ "a.md": "leaf", "b.md": "Leaf" }); const { replace } = createEngine(disk); diff --git a/src/features/folder-search/services/folderSearchEngine.ts b/src/features/folder-search/services/folderSearchEngine.ts index c8c3d499..14cfa77d 100644 --- a/src/features/folder-search/services/folderSearchEngine.ts +++ b/src/features/folder-search/services/folderSearchEngine.ts @@ -79,7 +79,9 @@ export type FolderSearchSkipReason = | OpenMarkdownFileError["kind"] | "parseFailed" /** The file would not read as its matches replaced. */ - | "replacementRefused"; + | "replacementRefused" + /** Another window holds the file with unsaved changes, which writing it would overwrite. */ + | "unsavedInAnotherWindow"; export interface FolderSearchSkippedFile { path: string; @@ -117,6 +119,8 @@ export interface FolderSearchHost { /** The document on screen, whose source projection a new search settles before reading it. */ getActiveDocument: () => FolderSearchOpenDocument | null; getOpenDocument: (path: string) => FolderSearchOpenDocument | null; + /** Whether another window holds the file with unsaved changes, so a replacement leaves it alone. */ + isUnsavedElsewhere?: (path: string) => boolean; /** Text read by earlier searches, which the host keeps, and clears, for as long as it holds. */ textCache?: FolderSearchTextCache; readFiles?: (args: ReadFolderSearchFilesArgs) => Promise; @@ -668,6 +672,11 @@ export class FolderSearchEngine implements Disposable { this.texts.set(path, { metadata, fingerprint, text: plan.text }); + if (file && this.host.isUnsavedElsewhere?.(path)) { + this.skip(search, path, "unsavedInAnotherWindow", sequence); + return; + } + if (file?.kind === "refused") { this.skip(search, path, "replacementRefused", sequence); return; diff --git a/src/features/preferences/index.ts b/src/features/preferences/index.ts index a5684951..b7c460b9 100644 --- a/src/features/preferences/index.ts +++ b/src/features/preferences/index.ts @@ -10,6 +10,7 @@ export { } from "./stores/recentItems"; export { applyPersistedAlwaysOnTop, + followAlwaysOnTopSetting, restoreDefaultSettings, setAlwaysOnTop, } from "./services/windowPreferences"; diff --git a/src/features/preferences/services/windowPreferences.ts b/src/features/preferences/services/windowPreferences.ts index 68d61cc9..bbe3315c 100644 --- a/src/features/preferences/services/windowPreferences.ts +++ b/src/features/preferences/services/windowPreferences.ts @@ -20,6 +20,17 @@ export const applyPersistedAlwaysOnTop = async () => { } }; +/** + * Applies the global always-on-top setting to this window whenever another window changes it, so + * every window follows it. Returns the function that stops following. + */ +export const followAlwaysOnTopSetting = (onError: (error: unknown) => void) => + useSettingsStore.subscribe((state, previous) => { + if (state.alwaysOnTop !== previous.alwaysOnTop) { + getCurrentWindow().setAlwaysOnTop(state.alwaysOnTop).catch(onError); + } + }); + export const restoreDefaultSettings = async () => { if (useSettingsStore.getState().alwaysOnTop) { try { diff --git a/src/features/session/hooks/useWindowDocuments.test.ts b/src/features/session/hooks/useWindowDocuments.test.ts new file mode 100644 index 00000000..3c2b9b52 --- /dev/null +++ b/src/features/session/hooks/useWindowDocuments.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; + +import { createSavedDocument } from "@/test/factories/document"; +import { TEST_MARKDOWN_FILE_PATH, TEST_NOTES_FOLDER_PATH } from "@/test/fixtures/paths"; +import { setDefaultSession } from "@/test/utils/appStores"; +import { act, renderHook } from "@/test/utils/react"; +import { getWindowListenHandler } from "@/test/utils/tauri"; +import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; + +import { + ACTIVATE_DOCUMENT_EVENT, + DOCUMENTS_MOVED_EVENT, + DOCUMENTS_REMOVED_EVENT, + WINDOW_DOCUMENTS_CHANGED_EVENT, + type DocumentPathPayload, + type DocumentsMovedPayload, + type WindowDocumentsSnapshot, +} from "../services/windowDocumentsApi"; +import { isUnsavedInOtherWindow } from "../stores/otherWindowDocuments"; +import { useSessionStore } from "../stores/session"; +import { useWindowDocuments } from "./useWindowDocuments"; + +const OTHER_MARKDOWN_PATH = `${TEST_NOTES_FOLDER_PATH}/other.md`; + +describe("useWindowDocuments", () => { + it("reports the window's documents while mounted", async () => { + setDefaultSession({ activeDocument: createSavedDocument({ isDirty: true }) }); + + const { unmount } = renderHook(() => useWindowDocuments()); + + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [{ path: TEST_MARKDOWN_FILE_PATH, unsaved: true }], + }), + ); + + unmount(); + useSessionStore.getState().openTab(createSavedDocument({ path: OTHER_MARKDOWN_PATH })); + await Promise.resolve(); + + expect(countTauriApiCalls("setWindowDocuments")).toBe(1); + }); + + it("follows the other windows' unsaved documents, leaving out its own", async () => { + renderHook(() => useWindowDocuments()); + + await act(async () => { + getWindowListenHandler(WINDOW_DOCUMENTS_CHANGED_EVENT)({ + payload: { + revision: 1, + windows: [ + { windowLabel: "main", documents: [{ path: TEST_MARKDOWN_FILE_PATH, unsaved: true }] }, + { + windowLabel: "window-2", + documents: [{ path: OTHER_MARKDOWN_PATH, unsaved: true }], + }, + ], + }, + }); + }); + + expect(isUnsavedInOtherWindow(OTHER_MARKDOWN_PATH)).toBe(true); + expect(isUnsavedInOtherWindow(TEST_MARKDOWN_FILE_PATH)).toBe(false); + }); + + it("shows the tab another window asked to open", async () => { + setDefaultSession({ + documents: [createSavedDocument(), createSavedDocument({ path: OTHER_MARKDOWN_PATH })], + activeIndex: 1, + }); + renderHook(() => useWindowDocuments()); + + await act(async () => { + getWindowListenHandler(ACTIVATE_DOCUMENT_EVENT)({ + payload: { path: TEST_MARKDOWN_FILE_PATH }, + }); + }); + + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + }); + + it("moves its tabs after a rename in another window and checks those a deletion removed", async () => { + const renamedPath = `${TEST_NOTES_FOLDER_PATH}/intro.md`; + setDefaultSession({ + documents: [createSavedDocument(), createSavedDocument({ path: OTHER_MARKDOWN_PATH })], + }); + mockTauriApi({ inspectMarkdownFile: () => ({ kind: "missing" }) }); + renderHook(() => useWindowDocuments()); + + act(() => { + getWindowListenHandler(DOCUMENTS_MOVED_EVENT)({ + payload: { fromPath: TEST_MARKDOWN_FILE_PATH, toPath: renamedPath }, + }); + getWindowListenHandler(DOCUMENTS_REMOVED_EVENT)({ + payload: { path: OTHER_MARKDOWN_PATH }, + }); + }); + + await vi.waitFor(() => + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: renamedPath, externalChange: null }, + { path: OTHER_MARKDOWN_PATH, externalChange: { kind: "missing" } }, + ]), + ); + }); +}); diff --git a/src/features/session/hooks/useWindowDocuments.ts b/src/features/session/hooks/useWindowDocuments.ts new file mode 100644 index 00000000..ab9cbfe7 --- /dev/null +++ b/src/features/session/hooks/useWindowDocuments.ts @@ -0,0 +1,60 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect } from "react"; + +import { useTauriEvent } from "@/lib/tauriEvent"; + +import { + checkDocumentsRemovedElsewhere, + followDocumentsMovedElsewhere, + showDocumentOpenedElsewhere, +} from "../services/crossWindowDocuments"; +import { WindowDocumentsSync } from "../services/windowDocumentClaims"; +import { + ACTIVATE_DOCUMENT_EVENT, + DOCUMENTS_MOVED_EVENT, + DOCUMENTS_REMOVED_EVENT, + WINDOW_DOCUMENTS_CHANGED_EVENT, + type DocumentPathPayload, + type DocumentsMovedPayload, + type WindowDocumentsSnapshot, +} from "../services/windowDocumentsApi"; +import { useOtherWindowDocumentsStore } from "../stores/otherWindowDocuments"; + +/** + * Shares this window's saved documents with the other windows, and follows what they report: a + * document opened elsewhere, a rename, a deletion, and their unsaved changes. + */ +export const useWindowDocuments = () => { + useEffect(() => { + const sync = new WindowDocumentsSync(); + + return () => { + sync.dispose(); + }; + }, []); + + useTauriEvent( + WINDOW_DOCUMENTS_CHANGED_EVENT, + ({ payload }) => { + useOtherWindowDocumentsStore.getState().applySnapshot(payload, getCurrentWindow().label); + }, + "windowDocumentsChanged", + ); + useTauriEvent( + ACTIVATE_DOCUMENT_EVENT, + ({ payload }) => { + showDocumentOpenedElsewhere(payload.path); + }, + "activateDocument", + ); + useTauriEvent( + DOCUMENTS_MOVED_EVENT, + ({ payload }) => followDocumentsMovedElsewhere(payload.fromPath, payload.toPath), + "documentsMoved", + ); + useTauriEvent( + DOCUMENTS_REMOVED_EVENT, + ({ payload }) => checkDocumentsRemovedElsewhere(payload.path), + "documentsRemoved", + ); +}; diff --git a/src/features/session/index.ts b/src/features/session/index.ts index c9d8901b..77b4f6e7 100644 --- a/src/features/session/index.ts +++ b/src/features/session/index.ts @@ -3,7 +3,8 @@ export { useDocumentWatchers } from "./hooks/useDocumentWatchers"; export { useFolderContextWatcher } from "./hooks/useFolderContextWatcher"; export { useFolderSearchSession } from "./hooks/useFolderSearchSession"; export { useDroppedPathListener, type DroppedPathIndicator } from "./hooks/useDroppedPathListener"; -export { confirmDiscardAllDocumentChanges } from "./services/unsavedChanges"; +export { useWindowDocuments } from "./hooks/useWindowDocuments"; +export { confirmWindowClose } from "./services/windowClose"; export { declineWindowClose, WINDOW_CLOSE_REQUESTED_EVENT } from "./services/windowCloseApi"; export { documentEditorBridge } from "./services/documentEditorBridge"; export { diff --git a/src/features/session/services/crossWindowDocuments.test.ts b/src/features/session/services/crossWindowDocuments.test.ts new file mode 100644 index 00000000..3c20d92b --- /dev/null +++ b/src/features/session/services/crossWindowDocuments.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { useArticleNavigatorStore } from "@/features/folder-context"; +import { useRecentItemsStore } from "@/features/preferences"; +import { toastManager } from "@/lib/toast"; +import { createSavedDocument } from "@/test/factories/document"; +import { createFolderContext } from "@/test/factories/folderContext"; +import { + TEST_MARKDOWN_FILE_PATH, + TEST_NESTED_DIRECTORY_PATH, + TEST_NESTED_MARKDOWN_FILE_PATH, + TEST_NOTES_FOLDER_PATH, +} from "@/test/fixtures/paths"; +import { setDefaultRecentItems, setDefaultSession } from "@/test/utils/appStores"; +import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; + +import { useSessionStore } from "../stores/session"; +import { + checkDocumentsRemovedElsewhere, + followDocumentsMovedElsewhere, + showDocumentOpenedElsewhere, +} from "./crossWindowDocuments"; +import { documentEditorBridge } from "./documentEditorBridge"; + +const RENAMED_DIRECTORY_PATH = `${TEST_NOTES_FOLDER_PATH}/guides`; + +describe("documents another window changed", () => { + afterEach(() => { + documentEditorBridge.clear(); + }); + + it("shows the tab another window asked to open, giving it focus", () => { + setDefaultSession({ + documents: [ + createSavedDocument(), + createSavedDocument({ path: TEST_NESTED_MARKDOWN_FILE_PATH }), + ], + activeIndex: 1, + }); + + expect(showDocumentOpenedElsewhere("c:\\notes\\README.md")).toBe(true); + + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + expect(showDocumentOpenedElsewhere(`${TEST_NOTES_FOLDER_PATH}/closed.md`)).toBe(false); + }); + + it("moves the tabs under a path another window renamed, keeping their unsaved text", async () => { + const nestedPath = `${TEST_NESTED_DIRECTORY_PATH}/notes.md`; + setDefaultSession({ + documents: [ + createSavedDocument({ path: TEST_NESTED_MARKDOWN_FILE_PATH, isDirty: true }), + createSavedDocument(), + createSavedDocument({ path: nestedPath }), + ], + activeIndex: 1, + folderContext: createFolderContext(), + }); + setDefaultRecentItems({ + recentFiles: [{ openedAt: 1, path: TEST_NESTED_MARKDOWN_FILE_PATH }], + }); + useArticleNavigatorStore.getState().expandDirectories([TEST_NESTED_DIRECTORY_PATH]); + documentEditorBridge.set(TEST_NESTED_MARKDOWN_FILE_PATH, { getMarkdown: () => "# Unsaved" }); + + await followDocumentsMovedElsewhere(TEST_NESTED_DIRECTORY_PATH, RENAMED_DIRECTORY_PATH); + + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: `${RENAMED_DIRECTORY_PATH}/readme.md`, content: "# Unsaved", isDirty: true }, + { path: TEST_MARKDOWN_FILE_PATH }, + { path: `${RENAMED_DIRECTORY_PATH}/notes.md` }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + expect(useArticleNavigatorStore.getState().expandedDirectoryPaths).toEqual([ + RENAMED_DIRECTORY_PATH, + ]); + // Recent items are shared, so the window that renamed has already moved them. + expect(useRecentItemsStore.getState().recentFiles).toEqual([ + { openedAt: 1, path: TEST_NESTED_MARKDOWN_FILE_PATH }, + ]); + }); + + it("shows the file missing in tabs under a path another window deleted, keeping their text", async () => { + const dirtyDocument = createSavedDocument({ + path: TEST_NESTED_MARKDOWN_FILE_PATH, + content: "# Kept", + isDirty: true, + }); + setDefaultSession({ documents: [dirtyDocument, createSavedDocument()] }); + mockTauriApi({ inspectMarkdownFile: () => ({ kind: "missing" }) }); + + await checkDocumentsRemovedElsewhere(TEST_NESTED_DIRECTORY_PATH); + + expect(countTauriApiCalls("inspectMarkdownFile")).toBe(1); + expect(getLastTauriApiArgs("inspectMarkdownFile")).toMatchObject({ + path: TEST_NESTED_MARKDOWN_FILE_PATH, + }); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { ...dirtyDocument, externalChange: { kind: "missing" } }, + { path: TEST_MARKDOWN_FILE_PATH, externalChange: null }, + ]); + expect(toastManager.add).toHaveBeenCalledWith({ + title: "File missing", + description: '"readme.md" was deleted in another Leafdown window. The document stays open.', + type: "warning", + }); + }); +}); diff --git a/src/features/session/services/crossWindowDocuments.ts b/src/features/session/services/crossWindowDocuments.ts new file mode 100644 index 00000000..22f9476c --- /dev/null +++ b/src/features/session/services/crossWindowDocuments.ts @@ -0,0 +1,41 @@ +import { useArticleNavigatorStore } from "@/features/folder-context"; +import { CancellationToken } from "@/lib/cancellation"; +import { isSameOrParentPath } from "@/lib/path"; + +import { findTabByPath, useSessionStore } from "../stores/session"; +import { runAfterPendingSaves } from "./documentWorkflows"; +import { checkDocumentFile } from "./externalChanges"; +import { moveOpenDocuments } from "./folderEntryWorkflows"; +import { activateDocumentTab } from "./tabWorkflows"; + +/** Shows a document this window holds when another window was asked to open it. */ +export const showDocumentOpenedElsewhere = (path: string) => { + const tab = findTabByPath(useSessionStore.getState(), path); + + return tab ? activateDocumentTab(tab.id) : false; +}; + +/** + * Follows a rename made in another window, moving the tabs under the old path with their unsaved + * changes, as the window that renamed moves its own. + */ +export const followDocumentsMovedElsewhere = (fromPath: string, toPath: string) => + runAfterPendingSaves(async () => { + moveOpenDocuments(fromPath, toPath); + useArticleNavigatorStore.getState().moveDirectoryPaths(fromPath, toPath); + }); + +/** Checks the documents under a path another window deleted, so their tabs show the file missing. */ +export const checkDocumentsRemovedElsewhere = async (path: string) => { + const removedPaths = useSessionStore + .getState() + .tabs.flatMap(({ document }) => + document.status === "saved" && isSameOrParentPath(path, document.path) ? [document.path] : [], + ); + + await Promise.all( + removedPaths.map((removedPath) => + checkDocumentFile(removedPath, CancellationToken.None, "anotherWindow"), + ), + ); +}; diff --git a/src/features/session/services/documentWorkflows.test.ts b/src/features/session/services/documentWorkflows.test.ts index 4833e27f..e33ec13f 100644 --- a/src/features/session/services/documentWorkflows.test.ts +++ b/src/features/session/services/documentWorkflows.test.ts @@ -353,14 +353,18 @@ describe("document workflows", () => { filters: [{ name: "Markdown", extensions: ["md", "markdown", "mdown", "mkd"] }], defaultPath: "C:/Notes/Untitled.markdown", }); - expect(invoke).toHaveBeenNthCalledWith(1, tauriApiCommand("saveMarkdownFile"), { + expect(invoke).toHaveBeenNthCalledWith(1, tauriApiCommand("claimWindowDocument"), { + path: DRAFT_MARKDOWN_PATH, + activate: false, + }); + expect(invoke).toHaveBeenNthCalledWith(2, tauriApiCommand("saveMarkdownFile"), { path: DRAFT_MARKDOWN_PATH, content: "Draft", encoding: { name: "UTF-8", bom: false }, expectedMetadata: null, overwrite: false, }); - expect(invoke).toHaveBeenNthCalledWith(2, tauriApiCommand("scanMarkdownFolder"), { + expect(invoke).toHaveBeenNthCalledWith(3, tauriApiCommand("scanMarkdownFolder"), { path: "C:/Notes", ignoredDirectories: DEFAULT_IGNORED_DIRECTORIES, sortOrder: "name", @@ -392,7 +396,7 @@ describe("document workflows", () => { await expect(saveActiveMarkdownDocumentAs()).resolves.toBe(true); - expect(invoke).toHaveBeenNthCalledWith(1, tauriApiCommand("saveMarkdownFile"), { + expect(invoke).toHaveBeenNthCalledWith(2, tauriApiCommand("saveMarkdownFile"), { path: DRAFT_MD_PATH, content: "# Notes\r\n", encoding: { name: "UTF-16BE", bom: true }, @@ -565,6 +569,30 @@ describe("document workflows", () => { }); }); + it("refuses to save over a file open in another window without showing it there", async () => { + setDefaultSession({ activeDocument: createUntitledDocument() }); + vi.mocked(save).mockResolvedValue(DRAFT_MD_PATH); + mockTauriApi({ claimWindowDocument: () => ({ kind: "openElsewhere" }) }); + + await expect(saveActiveMarkdownDocumentAs()).resolves.toBe(false); + + expect(getLastTauriApiArgs("claimWindowDocument")).toEqual({ + path: DRAFT_MD_PATH, + activate: false, + }); + expect(countTauriApiCalls("saveMarkdownFile")).toBe(0); + expect(toastManager.add).toHaveBeenCalledWith({ + title: "Document is already open", + description: + "draft.md is open in another window. Close it there before saving over it, or choose another name.", + type: "error", + }); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + status: "untitled", + id: TEST_UNTITLED_DOCUMENT_ID, + }); + }); + it("saves a document again at its own path", async () => { setDefaultSession({ activeDocument: createSavedDocument({ isDirty: true }) }); vi.mocked(save).mockResolvedValue(TEST_MARKDOWN_FILE_PATH); diff --git a/src/features/session/services/documentWorkflows.ts b/src/features/session/services/documentWorkflows.ts index a983480b..a7f0f8ca 100644 --- a/src/features/session/services/documentWorkflows.ts +++ b/src/features/session/services/documentWorkflows.ts @@ -26,7 +26,7 @@ import { useSettingsStore } from "@/features/preferences"; import { SequentialTaskQueue } from "@/lib/async"; import { requestConfirmation } from "@/lib/confirmation"; import { t } from "@/lib/i18n"; -import { isSameOrParentPath } from "@/lib/path"; +import { getPathParts, isSameOrParentPath } from "@/lib/path"; import { notifyError } from "@/lib/toast"; import { @@ -47,6 +47,7 @@ import { import { documentEditorBridge } from "./documentEditorBridge"; import { getSessionFolderScanOptions } from "./folderContextWorkflows"; import { confirmDiscardTabChanges } from "./unsavedChanges"; +import { claimDocumentForWindow } from "./windowDocumentClaims"; interface SerializedDocumentForSave { content: string; @@ -188,7 +189,24 @@ const saveDocumentAsNow = async (version: TabVersion) => { return false; } - return saveDocumentToNewPath(version, path); + const claim = await claimDocumentForWindow(path, { activate: false }); + + if (!claim) { + notifyError({ + title: t("session.saveAsOpenInTab.title"), + description: t("session.saveAsOpenInWindow.description", { + name: getPathParts(path).name, + }), + }); + + return false; + } + + try { + return await saveDocumentToNewPath(version, path); + } finally { + claim.dispose(); + } }; const saveDocumentToNewPath = async (version: TabVersion, path: string): Promise => { diff --git a/src/features/session/services/externalChanges.ts b/src/features/session/services/externalChanges.ts index 605a9f40..479de9ad 100644 --- a/src/features/session/services/externalChanges.ts +++ b/src/features/session/services/externalChanges.ts @@ -21,6 +21,9 @@ import { notifyOpenMarkdownFileError } from "./openSession"; type ExternalVersion = Extract; +/** Who removed a file whose document stays open, which the warning names. */ +type MissingFileCause = "outsideLeafdown" | "anotherWindow"; + interface CheckedDocument { tabId: string; generation: number; @@ -35,9 +38,14 @@ interface CheckedDocument { export const checkDocumentFile = ( path: string, cancellationToken: CancellationToken = CancellationToken.None, -) => runAfterPendingSaves(() => checkDocumentFileNow(path, cancellationToken)); + missingFileCause: MissingFileCause = "outsideLeafdown", +) => runAfterPendingSaves(() => checkDocumentFileNow(path, cancellationToken, missingFileCause)); -const checkDocumentFileNow = async (path: string, cancellationToken: CancellationToken) => { +const checkDocumentFileNow = async ( + path: string, + cancellationToken: CancellationToken, + missingFileCause: MissingFileCause, +) => { const tab = findTabByPath(useSessionStore.getState(), path); if (cancellationToken.isCancellationRequested || tab?.document.status !== "saved") { @@ -77,7 +85,7 @@ const checkDocumentFileNow = async (path: string, cancellationToken: Cancellatio setFileState(document, fileState.metadata, null); return; case "missing": - recordMissingFile(document); + recordMissingFile(document, missingFileCause); return; case "contentChanged": { const version: ExternalVersion = { @@ -209,16 +217,22 @@ const recordExternalVersion = (document: SavedDocumentState, version: ExternalVe }); }; -const recordMissingFile = (document: SavedDocumentState) => { +const recordMissingFile = ( + document: SavedDocumentState, + cause: MissingFileCause = "outsideLeafdown", +) => { if (document.externalChange?.kind === "missing") { return; } + const name = getPathParts(document.path).name; + setFileState(document, document.metadata, { kind: "missing" }); notifyWarning({ title: t("session.externalChange.missing.title"), - description: t("session.externalChange.missing.description", { - name: getPathParts(document.path).name, - }), + description: + cause === "anotherWindow" + ? t("session.externalChange.missing.deletedInAnotherWindow", { name }) + : t("session.externalChange.missing.description", { name }), }); }; diff --git a/src/features/session/services/folderEntryWorkflows.test.ts b/src/features/session/services/folderEntryWorkflows.test.ts index 0fda3803..8c3551ad 100644 --- a/src/features/session/services/folderEntryWorkflows.test.ts +++ b/src/features/session/services/folderEntryWorkflows.test.ts @@ -117,6 +117,10 @@ describe("folder entry workflows", () => { expect(useRecentItemsStore.getState().recentFiles).toEqual([ { openedAt: 1, path: renamedPath }, ]); + expect(getLastTauriApiArgs("notifyDocumentsMoved")).toEqual({ + fromPath: TEST_MARKDOWN_FILE_PATH, + toPath: renamedPath, + }); expect(countTauriApiCalls("scanMarkdownFolder")).toBe(1); }); @@ -177,6 +181,7 @@ describe("folder entry workflows", () => { await expect(renameFolderEntry(TEST_MARKDOWN_FILE_PATH, "taken")).rejects.toBe(collision); expect(useSessionStore.getState().activeDocument).toEqual(activeDocument); + expect(countTauriApiCalls("notifyDocumentsMoved")).toBe(0); expect(countTauriApiCalls("scanMarkdownFolder")).toBe(0); }); @@ -186,6 +191,7 @@ describe("folder entry workflows", () => { await expect(deleteFolderEntry(TEST_MARKDOWN_FILE_PATH, "file")).resolves.toBe(false); expect(countTauriApiCalls("trashFolderEntry")).toBe(0); + expect(countTauriApiCalls("notifyDocumentsRemoved")).toBe(0); }); it("keeps a dirty active document when discarding it is declined", async () => { @@ -218,6 +224,9 @@ describe("folder entry workflows", () => { path: TEST_NESTED_DIRECTORY_PATH, }); expect(useSessionStore.getState().activeDocument).toBeNull(); + expect(getLastTauriApiArgs("notifyDocumentsRemoved")).toEqual({ + path: TEST_NESTED_DIRECTORY_PATH, + }); expect(countTauriApiCalls("scanMarkdownFolder")).toBe(1); }); diff --git a/src/features/session/services/folderEntryWorkflows.ts b/src/features/session/services/folderEntryWorkflows.ts index f4ee246e..f297250a 100644 --- a/src/features/session/services/folderEntryWorkflows.ts +++ b/src/features/session/services/folderEntryWorkflows.ts @@ -9,6 +9,7 @@ import { } from "@/features/folder-context"; import { useRecentItemsStore, useSettingsStore } from "@/features/preferences"; import { requestConfirmation } from "@/lib/confirmation"; +import { handleUnexpectedError } from "@/lib/errors"; import { t } from "@/lib/i18n"; import { getPathParts, isSameOrParentPath, rebasePath } from "@/lib/path"; import { notifyError } from "@/lib/toast"; @@ -19,6 +20,7 @@ import { runAfterPendingSaves } from "./documentWorkflows"; import { refreshFolderContext } from "./folderContextWorkflows"; import { notifyOpenMarkdownFileError, openMarkdownFileAtPath } from "./openSession"; import { confirmDiscardTabChanges } from "./unsavedChanges"; +import { notifyDocumentsMoved, notifyDocumentsRemoved } from "./windowDocumentsApi"; export type FolderEntryKind = "directory" | "file"; @@ -75,6 +77,9 @@ export const renameFolderEntry = (path: string, name: string) => moveOpenDocuments(path, nextPath); useRecentItemsStore.getState().moveRecentPaths(path, nextPath); useArticleNavigatorStore.getState().moveDirectoryPaths(path, nextPath); + await notifyDocumentsMoved(path, nextPath).catch((error) => + handleUnexpectedError(error, "notifyDocumentsMoved"), + ); await refreshAfterFolderEntryChange(); } @@ -112,6 +117,9 @@ export const deleteFolderEntry = async (path: string, kind: FolderEntryKind) => useSessionStore.getState().closeTabs(getTabIdsInside(path)); + await notifyDocumentsRemoved(path).catch((error) => + handleUnexpectedError(error, "notifyDocumentsRemoved"), + ); await refreshAfterFolderEntryChange(); return true; @@ -140,7 +148,7 @@ const getTabIdsInside = (path: string) => // The editor is keyed by the document path, so a moved document remounts from the Markdown its // editor holds now rather than from content that may predate the latest edit. -const moveOpenDocuments = (fromPath: string, toPath: string) => { +export const moveOpenDocuments = (fromPath: string, toPath: string) => { const session = useSessionStore.getState(); for (const { document, id } of session.tabs) { diff --git a/src/features/session/services/folderReplaceWorkflows.test.ts b/src/features/session/services/folderReplaceWorkflows.test.ts index ee708512..2c11ab2a 100644 --- a/src/features/session/services/folderReplaceWorkflows.test.ts +++ b/src/features/session/services/folderReplaceWorkflows.test.ts @@ -27,6 +27,7 @@ import { createArticleTree, createFolderContext } from "@/test/factories/folderC import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; +import { useOtherWindowDocumentsStore } from "../stores/otherWindowDocuments"; import { useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; import { applyFolderReplacement } from "./folderReplaceWorkflows"; @@ -45,6 +46,7 @@ let disk: Map; let modified: Map; let session: Disposable | null = null; let writeOrder: string[]; +let beforePreflight: () => void; let beforeWrite: (args: FolderReplacementArgs) => void; let failWrite: (path: string) => FolderReplacementOutcome | null; let preflightFailure: Error | null; @@ -64,6 +66,20 @@ const createFolder = (paths = [ALPHA, BETA]): FolderContextState => }), }); +const setUnsavedInAnotherWindow = (paths: string[]) => { + const { applySnapshot, revision } = useOtherWindowDocumentsStore.getState(); + + applySnapshot( + { + revision: revision + 1, + windows: [ + { windowLabel: "window-2", documents: paths.map((path) => ({ path, unsaved: true })) }, + ], + }, + "main", + ); +}; + const metadataOf = (path: string) => ({ sizeBytes: disk.get(path)?.length ?? 0, modifiedAtUnixMs: modified.get(path) ?? 1, @@ -210,6 +226,7 @@ beforeEach(() => { ]); modified = new Map(); writeOrder = []; + beforePreflight = () => {}; beforeWrite = () => {}; failWrite = () => null; preflightFailure = null; @@ -221,6 +238,8 @@ beforeEach(() => { mockTauriApi({ readFolderSearchFiles: ({ files }) => files.map(({ path }) => readOutcome(path)), preflightFolderReplacement: ({ files }) => { + beforePreflight(); + if (preflightFailure) { throw preflightFailure; } @@ -362,6 +381,39 @@ describe("applying a folder replacement", () => { }); }); + it("plans around a file another window holds with unsaved changes, and again once they go", async () => { + setUnsavedInAnotherWindow([BETA]); + + const results = await plan("leaf", "tree"); + + expect(results.files.map(({ path }) => path)).toEqual([ALPHA]); + expect(results.skipped).toEqual([{ path: BETA, reason: "unsavedInAnotherWindow" }]); + + setUnsavedInAnotherWindow([]); + + await vi.waitFor(() => { + const { results: replanned } = useFolderSearchStore.getState(); + + expect(replanned?.status).toBe("completed"); + expect(replanned?.files.map(({ path }) => path)).toEqual([ALPHA, BETA]); + expect(replanned?.skipped).toEqual([]); + }); + }); + + it("leaves a file that gains unsaved changes in another window while replacing", async () => { + await plan("leaf", "tree"); + beforePreflight = () => setUnsavedInAnotherWindow([BETA]); + + await applyFolderReplacement(); + + expect(disk.get(ALPHA)).toBe("An alpha tree.\n"); + expect(disk.get(BETA)).toBe("Beta leaf and another leaf.\n"); + expect(getReport()).toMatchObject({ + written: [{ path: ALPHA, matchCount: 1 }], + stale: [{ path: BETA, reason: "unsavedInAnotherWindow", unsavedInEditor: false }], + }); + }); + it("catches a file that changes after every file was checked", async () => { await plan("leaf", "tree"); beforeWrite = () => writeDisk(BETA, null); diff --git a/src/features/session/services/folderReplaceWorkflows.ts b/src/features/session/services/folderReplaceWorkflows.ts index 93001d93..cbedc866 100644 --- a/src/features/session/services/folderReplaceWorkflows.ts +++ b/src/features/session/services/folderReplaceWorkflows.ts @@ -24,6 +24,7 @@ import { t } from "@/lib/i18n"; import { getPathParts, getRelativePath, PathMap, PathSet } from "@/lib/path"; import { notifyError, notifySuccess, notifyWarning } from "@/lib/toast"; +import { isUnsavedInOtherWindow } from "../stores/otherWindowDocuments"; import { findTabByPath, useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; import { saveDocumentTab, saveDocumentWithoutPrompts } from "./documentWorkflows"; @@ -244,6 +245,12 @@ const commit = async ( // A file opened since the plan is the editor's to write, and its plan read the file instead. if (isOpenDocumentPath(file.path)) { report.stale.push({ path: file.path, reason: "changedInEditor", unsavedInEditor: false }); + } else if (isUnsavedInOtherWindow(file.path)) { + report.stale.push({ + path: file.path, + reason: "unsavedInAnotherWindow", + unsavedInEditor: false, + }); } else if (checked?.kind !== "ready") { recordOutcome(file, checked, report); } else { diff --git a/src/features/session/services/folderSearchWorkflows.test.ts b/src/features/session/services/folderSearchWorkflows.test.ts index 19f67983..f7f54237 100644 --- a/src/features/session/services/folderSearchWorkflows.test.ts +++ b/src/features/session/services/folderSearchWorkflows.test.ts @@ -39,6 +39,7 @@ const BETA = `${FOLDER}/docs/beta.md`; let disk: Map; let inspectedState: MarkdownFileState; +let claimedElsewhere: boolean; let session: Disposable | null = null; const createFolder = (paths = [ALPHA, BETA]): FolderContextState => @@ -130,11 +131,13 @@ beforeEach(() => { [BETA, "Beta leaf and another leaf.\n"], ]); inspectedState = { kind: "unchanged" }; + claimedElsewhere = false; setDefaultSettings({ sidebarVisible: false }); setDefaultSession({ folderContext: createFolder() }); mockTauriApi({ readFolderSearchFiles: ({ files }) => files.map(({ path }) => readOutcome(path)), inspectMarkdownFile: () => inspectedState, + claimWindowDocument: () => ({ kind: claimedElsewhere ? "openElsewhere" : "claimed" }), openMarkdownFile: ({ path }) => { const content = disk.get(path); @@ -366,6 +369,22 @@ describe("folder search workflows", () => { ]); }); + it("leaves a match in a file another window holds to that window, unmarked", async () => { + await search("leaf"); + claimedElsewhere = true; + + await openFolderSearchMatch({ path: BETA, ordinal: 0 }); + + expect(countTauriApiCalls("openMarkdownFile")).toBe(0); + expect(useSessionStore.getState().tabs).toEqual([]); + expect(useFolderSearchStore.getState().chosenMatch).toBeNull(); + expect(toastManager.add).not.toHaveBeenCalled(); + expect(summarize()).toEqual([ + [ALPHA, 1], + [BETA, 2], + ]); + }); + it("marks a match the opened file no longer holds instead of choosing other text", async () => { await search("leaf"); diff --git a/src/features/session/services/folderSearchWorkflows.ts b/src/features/session/services/folderSearchWorkflows.ts index a361fc43..9b894da5 100644 --- a/src/features/session/services/folderSearchWorkflows.ts +++ b/src/features/session/services/folderSearchWorkflows.ts @@ -22,6 +22,10 @@ import { DisposableStore, toDisposable, type Disposable } from "@/lib/lifecycle" import { getPathParts, isSamePath, PathSet } from "@/lib/path"; import { notifyWarning } from "@/lib/toast"; +import { + isUnsavedInOtherWindow, + useOtherWindowDocumentsStore, +} from "../stores/otherWindowDocuments"; import { findTabByDocumentKey, findTabByPath, @@ -112,6 +116,27 @@ class FolderSearchSession implements Disposable { this.handleContentChange(documentKey), ), ); + // A replacement leaves a file alone while another window holds it with unsaved changes, so a + // file that gains or loses them there is planned again. + this.subscriptions.add( + useOtherWindowDocumentsStore.subscribe((state, previous) => { + if (!this.engine || !useFolderSearchStore.getState().replaceOpen) { + return; + } + + for (const path of state.unsavedPaths) { + if (!previous.unsavedPaths.has(path)) { + this.engine.refreshFile(path); + } + } + + for (const path of previous.unsavedPaths) { + if (!state.unsavedPaths.has(path)) { + this.engine.refreshFile(path); + } + } + }), + ); // A planned replacement is written as Save writes, so it follows the settings Save follows. this.subscriptions.add( useSettingsStore.subscribe((state, previous) => { @@ -241,7 +266,12 @@ class FolderSearchSession implements Disposable { } this.engine ??= new FolderSearchEngine( - { getActiveDocument, getOpenDocument, textCache: this.textCache }, + { + getActiveDocument, + getOpenDocument, + isUnsavedElsewhere: isUnsavedInOtherWindow, + textCache: this.textCache, + }, (results) => useFolderSearchStore.getState().setResults(results), ); this.engine.start({ @@ -397,6 +427,11 @@ export const openFolderSearchMatch = async (match: FolderSearchMatchKey) => { activeSession?.refreshFile(file.path); return; } + + // Another window holding the file shows it instead, and its editor is not this window's. + if (!findTabByPath(useSessionStore.getState(), file.path)) { + return; + } } const ordinal = await documentEditorBridge.chooseSearchMatch(file.path, results.query, target); diff --git a/src/features/session/services/openSession.test.ts b/src/features/session/services/openSession.test.ts index 378f7b61..19459845 100644 --- a/src/features/session/services/openSession.test.ts +++ b/src/features/session/services/openSession.test.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { ENCODING_CHOICES, type OpenedMarkdownDocument } from "@/features/document"; import type { FolderContextState } from "@/features/folder-context"; @@ -31,6 +31,7 @@ import { openMarkdownFileAtPath, reopenMarkdownFileWithChosenEncoding, } from "./openSession"; +import { WindowDocumentsSync } from "./windowDocumentClaims"; vi.mock("@/lib/confirmation", () => ({ requestConfirmation: vi.fn(async () => false) })); @@ -733,3 +734,88 @@ describe("folder context after opening a file", () => { expect(useRecentItemsStore.getState().recentFolders).toEqual([]); }); }); + +describe("opening a file another window holds", () => { + it("shows the file in the window holding it instead of reading it again", async () => { + const activeDocument = createUntitledDocument(); + setDefaultSession({ activeDocument }); + mockTauriApi({ claimWindowDocument: () => ({ kind: "openElsewhere" }) }); + + await expect(openMarkdownFileAtPath(OTHER_MARKDOWN_PATH)).resolves.toBe(true); + + expect(getLastTauriApiArgs("claimWindowDocument")).toEqual({ + path: OTHER_MARKDOWN_PATH, + activate: true, + }); + expect(countTauriApiCalls("openMarkdownFile")).toBe(0); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toEqual([ + activeDocument, + ]); + expect(useRecentItemsStore.getState().recentFiles).toMatchObject([ + { path: OTHER_MARKDOWN_PATH }, + ]); + }); + + it("claims the file before reading it and keeps it claimed once its tab holds it", async () => { + const sync = new WindowDocumentsSync(); + onTestFinished(() => sync.dispose()); + mockTauriApi({ + openMarkdownFile: () => openedOtherMarkdownFile, + scanMarkdownFolder: () => emptyNotesFolderContext, + }); + + await openMarkdownFileAtPath(OTHER_MARKDOWN_PATH); + + const calls = vi.mocked(invoke).mock.calls.map(([command]) => command); + expect(calls.indexOf(tauriApiCommand("claimWindowDocument"))).toBeLessThan( + calls.indexOf(tauriApiCommand("openMarkdownFile")), + ); + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [{ path: OTHER_MARKDOWN_PATH, unsaved: false }], + }), + ); + }); + + it("gives the claim back when the file cannot be opened", async () => { + const sync = new WindowDocumentsSync(); + onTestFinished(() => sync.dispose()); + mockTauriApi({ + openMarkdownFile: () => Promise.reject({ kind: "missingFile", path: OTHER_MARKDOWN_PATH }), + }); + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(1)); + + await expect(openMarkdownFileAtPath(OTHER_MARKDOWN_PATH)).rejects.toMatchObject({ + kind: "missingFile", + }); + + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(2)); + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ documents: [] }); + expect(useSessionStore.getState().tabs).toEqual([]); + }); + + it("opens a folder without the index file another window holds, leaving that window alone", async () => { + const activeDocument = createUntitledDocument(); + setDefaultSession({ activeDocument }); + mockTauriApi({ + openMarkdownFolder: () => ({ + folder: createEmptyFolderContext({ path: OTHER_FOLDER_PATH }), + indexDocument: createOpenedMarkdownDocument({ path: `${OTHER_FOLDER_PATH}/index.md` }), + indexError: null, + }), + claimWindowDocument: () => ({ kind: "openElsewhere" }), + }); + + await expect(openFolderContextAtPath(OTHER_FOLDER_PATH)).resolves.toBe(true); + + expect(getLastTauriApiArgs("claimWindowDocument")).toEqual({ + path: `${OTHER_FOLDER_PATH}/index.md`, + activate: false, + }); + expect(useSessionStore.getState()).toMatchObject({ + folderContext: { path: OTHER_FOLDER_PATH }, + activeDocument, + }); + expect(useSessionStore.getState().tabs).toHaveLength(1); + }); +}); diff --git a/src/features/session/services/openSession.ts b/src/features/session/services/openSession.ts index 2d36a717..b3c12923 100644 --- a/src/features/session/services/openSession.ts +++ b/src/features/session/services/openSession.ts @@ -20,6 +20,7 @@ import { type CancellationToken, isCancellationError, runWithCancellation, + throwIfCancelled, } from "@/lib/cancellation"; import { t } from "@/lib/i18n"; import type { MessageData } from "@/lib/messages"; @@ -35,6 +36,7 @@ import { import { getSessionFolderOpenOptions, getSessionFolderScanOptions } from "./folderContextWorkflows"; import { takeLaunchDocumentPath } from "./launchDocumentApi"; import { confirmDiscardTabChanges } from "./unsavedChanges"; +import { claimDocumentForWindow } from "./windowDocumentClaims"; const openTransitionRunner = new RestartableTaskRunner(); @@ -75,36 +77,56 @@ export const openMarkdownFileAtPath = ( return reloadTab(openTab, { activate, discardConfirmed, encoding }, cancellationToken); } - const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); - const { parentFolderPath, ...documentFields } = openedDocument; - const session = useSessionStore.getState(); - const tabOpenedMeanwhile = findTabByPath(session, documentFields.path); + const claim = await claimDocumentForWindow(path, { activate: true }); - if (tabOpenedMeanwhile) { - session.activateTab(tabOpenedMeanwhile.id); - recordRecentFile(documentFields.path); + if (!claim) { + recordRecentFile(path); return true; } - const savedDocument = toSavedDocument(documentFields); + try { + return await openClaimedMarkdownFile(path, encoding, cancellationToken); + } finally { + claim.dispose(); + } + }); - if (session.folderContext || session.folderContextLoad) { - session.openTab(savedDocument); - recordRecentFile(documentFields.path); +const openClaimedMarkdownFile = async ( + path: string, + encoding: TextEncodingName | undefined, + cancellationToken: CancellationToken, +) => { + const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); + const { parentFolderPath, ...documentFields } = openedDocument; + const session = useSessionStore.getState(); + const tabOpenedMeanwhile = findTabByPath(session, documentFields.path); - return true; - } + if (tabOpenedMeanwhile) { + session.activateTab(tabOpenedMeanwhile.id); + recordRecentFile(documentFields.path); - const { folderContextLoad } = session.openTabWithFolderContextLoad( - parentFolderPath, - savedDocument, - ); + return true; + } + + const savedDocument = toSavedDocument(documentFields); + + if (session.folderContext || session.folderContextLoad) { + session.openTab(savedDocument); recordRecentFile(documentFields.path); - void loadFolderContext(folderContextLoad); return true; - }); + } + + const { folderContextLoad } = session.openTabWithFolderContextLoad( + parentFolderPath, + savedDocument, + ); + recordRecentFile(documentFields.path); + void loadFolderContext(folderContextLoad); + + return true; +}; /** * The editor keeps its state across a same-path update, so a reload must say when the text @@ -233,7 +255,10 @@ export const pickAndOpenMarkdownFile = async () => { return openMarkdownFileAtPath(selectedPath); }; -/** Replaces the folder context and keeps the open tabs, opening the folder's index file as a tab. */ +/** + * Replaces the folder context and keeps the open tabs, opening the folder's index file as a tab. + * An index file open in another window stays there, and the folder opens here without it. + */ export const openFolderContextAtPath = (path: string) => runLatestOpenTransition(async (cancellationToken) => { const { folderContext, indexDocument, indexError } = await openFolderContext( @@ -241,16 +266,27 @@ export const openFolderContextAtPath = (path: string) => getSessionFolderOpenOptions(), cancellationToken, ); - const session = useSessionStore.getState(); - const indexTab = indexDocument && findTabByPath(session, indexDocument.path); - - if (indexTab) { - session.setFolderContext(folderContext); - session.activateTab(indexTab.id); - } else if (indexDocument) { - session.openTab(toSavedDocument(indexDocument), { folderContext }); - } else { - session.setFolderContext(folderContext); + const indexTab = indexDocument && findTabByPath(useSessionStore.getState(), indexDocument.path); + const indexClaim = + indexDocument && !indexTab + ? await claimDocumentForWindow(indexDocument.path, { activate: false }) + : null; + + try { + throwIfCancelled(cancellationToken); + + const session = useSessionStore.getState(); + + if (indexTab) { + session.setFolderContext(folderContext); + session.activateTab(indexTab.id); + } else if (indexDocument && indexClaim) { + session.openTab(toSavedDocument(indexDocument), { folderContext }); + } else { + session.setFolderContext(folderContext); + } + } finally { + indexClaim?.dispose(); } recordRecentFolderSession(folderContext.path); diff --git a/src/features/session/services/windowClose.ts b/src/features/session/services/windowClose.ts new file mode 100644 index 00000000..e215da21 --- /dev/null +++ b/src/features/session/services/windowClose.ts @@ -0,0 +1,29 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + +import { handleUnexpectedError } from "@/lib/errors"; + +import { useSessionStore } from "../stores/session"; +import { confirmDiscardAllDocumentChanges } from "./unsavedChanges"; + +/** + * Asks once about the window's unsaved documents before it closes. `Quit` closes the windows in + * turn, so a window with something to ask comes forward first; the others close unseen. + */ +export const confirmWindowClose = async () => { + if (useSessionStore.getState().tabs.some(({ document }) => document.isDirty)) { + await bringWindowForward(); + } + + return confirmDiscardAllDocumentChanges(); +}; + +const bringWindowForward = async () => { + const appWindow = getCurrentWindow(); + + try { + await appWindow.unminimize(); + await appWindow.setFocus(); + } catch (error) { + handleUnexpectedError(error, "bringWindowForward"); + } +}; diff --git a/src/features/session/services/windowDocumentClaims.test.ts b/src/features/session/services/windowDocumentClaims.test.ts new file mode 100644 index 00000000..7833755d --- /dev/null +++ b/src/features/session/services/windowDocumentClaims.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; +import { TEST_MARKDOWN_FILE_PATH, TEST_NOTES_FOLDER_PATH } from "@/test/fixtures/paths"; +import { setDefaultSession } from "@/test/utils/appStores"; +import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; + +import { useSessionStore } from "../stores/session"; +import { claimDocumentForWindow, WindowDocumentsSync } from "./windowDocumentClaims"; + +const OTHER_MARKDOWN_PATH = `${TEST_NOTES_FOLDER_PATH}/other.md`; + +describe("window document claims", () => { + let sync: WindowDocumentsSync | null = null; + + afterEach(() => { + sync?.dispose(); + sync = null; + }); + + const startSync = () => { + sync = new WindowDocumentsSync(); + }; + + it("reports the window's saved documents and which have unsaved changes", async () => { + setDefaultSession({ + documents: [ + createSavedDocument({ isDirty: true }), + createUntitledDocument(), + createSavedDocument({ path: OTHER_MARKDOWN_PATH }), + ], + }); + + startSync(); + + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [ + { path: TEST_MARKDOWN_FILE_PATH, unsaved: true }, + { path: OTHER_MARKDOWN_PATH, unsaved: false }, + ], + }), + ); + }); + + it("reports again only when a saved document opens, closes, or changes its unsaved state", async () => { + setDefaultSession({ activeDocument: createSavedDocument() }); + startSync(); + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(1)); + + useSessionStore.getState().openTab(createUntitledDocument()); + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); + + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [{ path: TEST_MARKDOWN_FILE_PATH, unsaved: true }], + }), + ); + expect(countTauriApiCalls("setWindowDocuments")).toBe(2); + }); + + it("keeps a claimed document reported until the claim is given back", async () => { + startSync(); + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(1)); + + const claim = await claimDocumentForWindow(OTHER_MARKDOWN_PATH, { activate: true }); + + expect(getLastTauriApiArgs("claimWindowDocument")).toEqual({ + path: OTHER_MARKDOWN_PATH, + activate: true, + }); + expect(claim).not.toBeNull(); + + useSessionStore.getState().openTab(createUntitledDocument()); + useSessionStore.getState().openTab(createSavedDocument()); + + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [ + { path: TEST_MARKDOWN_FILE_PATH, unsaved: false }, + { path: OTHER_MARKDOWN_PATH, unsaved: false }, + ], + }), + ); + + claim?.dispose(); + + await vi.waitFor(() => + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ + documents: [{ path: TEST_MARKDOWN_FILE_PATH, unsaved: false }], + }), + ); + }); + + it("reports a given-back claim even when the window's documents look unchanged", async () => { + startSync(); + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(1)); + + const claim = await claimDocumentForWindow(OTHER_MARKDOWN_PATH, { activate: false }); + claim?.dispose(); + + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(2)); + expect(getLastTauriApiArgs("setWindowDocuments")).toEqual({ documents: [] }); + }); + + it("claims nothing for a document another window holds", async () => { + mockTauriApi({ claimWindowDocument: () => ({ kind: "openElsewhere" }) }); + + await expect( + claimDocumentForWindow(OTHER_MARKDOWN_PATH, { activate: true }), + ).resolves.toBeNull(); + }); + + it("stops reporting once disposed", async () => { + startSync(); + await vi.waitFor(() => expect(countTauriApiCalls("setWindowDocuments")).toBe(1)); + + sync?.dispose(); + useSessionStore.getState().openTab(createSavedDocument()); + await Promise.resolve(); + + expect(countTauriApiCalls("setWindowDocuments")).toBe(1); + }); +}); diff --git a/src/features/session/services/windowDocumentClaims.ts b/src/features/session/services/windowDocumentClaims.ts new file mode 100644 index 00000000..6196edb4 --- /dev/null +++ b/src/features/session/services/windowDocumentClaims.ts @@ -0,0 +1,129 @@ +import { handleUnexpectedError } from "@/lib/errors"; +import { DisposableStore, toDisposable, type Disposable } from "@/lib/lifecycle"; +import { PathSet } from "@/lib/path"; +import { SignalSource } from "@/lib/signal"; + +import { useSessionStore, type SessionState } from "../stores/session"; +import { claimWindowDocument, setWindowDocuments, type WindowDocument } from "./windowDocumentsApi"; + +interface PendingClaim { + path: string; +} + +const pendingClaims = new Set(); +const claimReleased = new SignalSource(); + +/** + * Claims a saved document for this window before it opens or saves to it, so another window cannot + * open the same file meanwhile. Resolves `null` when another window holds the file, which then + * shows it if `activate` is set. The claim lasts until it is disposed, once a tab holds the + * document or the attempt ended without one. + */ +export const claimDocumentForWindow = async ( + path: string, + { activate }: { activate: boolean }, +): Promise => { + const result = await claimWindowDocument(path, activate); + + if (result.kind === "openElsewhere") { + return null; + } + + const claim: PendingClaim = { path }; + + pendingClaims.add(claim); + + return toDisposable(() => { + if (pendingClaims.delete(claim)) { + claimReleased.notify(); + } + }); +}; + +const collectWindowDocuments = (state: Pick) => { + const paths = new PathSet(); + const documents: WindowDocument[] = []; + const add = (path: string, unsaved: boolean) => { + if (!paths.has(path)) { + paths.add(path); + documents.push({ path, unsaved }); + } + }; + + for (const { document } of state.tabs) { + if (document.status === "saved") { + add(document.path, document.isDirty); + } + } + + for (const { path } of pendingClaims) { + add(path, false); + } + + return documents; +}; + +/** + * Keeps the backend's record of this window's saved documents, and of whether each has unsaved + * changes, in step with its tabs and its pending claims. + */ +export class WindowDocumentsSync implements Disposable { + private readonly subscriptions = new DisposableStore(); + private lastSentKey: string | null = null; + // A claim is recorded by the backend without passing through this window's list, so giving it + // back must send the list even when it looks unchanged. + private resendRequested = false; + private isSending = false; + private isDisposed = false; + + constructor() { + this.subscriptions.add( + useSessionStore.subscribe((state, previous) => { + if (state.tabs !== previous.tabs) { + void this.send(); + } + }), + ); + this.subscriptions.add( + claimReleased.signal(() => { + this.resendRequested = true; + void this.send(); + }), + ); + void this.send(); + } + + dispose() { + this.isDisposed = true; + this.subscriptions.dispose(); + } + + // One request at a time, sending again until the list it sent is the list the window holds. + private async send() { + if (this.isSending) { + return; + } + + this.isSending = true; + + try { + while (!this.isDisposed) { + const documents = collectWindowDocuments(useSessionStore.getState()); + const key = JSON.stringify(documents); + + if (key === this.lastSentKey && !this.resendRequested) { + return; + } + + this.resendRequested = false; + await setWindowDocuments(documents); + this.lastSentKey = key; + } + } catch (error) { + this.lastSentKey = null; + handleUnexpectedError(error, "setWindowDocuments"); + } finally { + this.isSending = false; + } + } +} diff --git a/src/features/session/services/windowDocumentsApi.ts b/src/features/session/services/windowDocumentsApi.ts new file mode 100644 index 00000000..bca8b8ce --- /dev/null +++ b/src/features/session/services/windowDocumentsApi.ts @@ -0,0 +1,61 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const SET_WINDOW_DOCUMENTS_COMMAND = "set_window_documents"; +export const CLAIM_WINDOW_DOCUMENT_COMMAND = "claim_window_document"; +export const NOTIFY_DOCUMENTS_MOVED_COMMAND = "notify_documents_moved"; +export const NOTIFY_DOCUMENTS_REMOVED_COMMAND = "notify_documents_removed"; + +export const WINDOW_DOCUMENTS_CHANGED_EVENT = "leafdown://window-documents-changed"; +export const ACTIVATE_DOCUMENT_EVENT = "leafdown://activate-document"; +export const DOCUMENTS_MOVED_EVENT = "leafdown://documents-moved"; +export const DOCUMENTS_REMOVED_EVENT = "leafdown://documents-removed"; + +export interface WindowDocument { + path: string; + unsaved: boolean; +} + +export interface WindowDocumentsSnapshot { + revision: number; + windows: { windowLabel: string; documents: WindowDocument[] }[]; +} + +export type ClaimWindowDocumentResult = { kind: "claimed" } | { kind: "openElsewhere" }; + +export interface SetWindowDocumentsArgs { + documents: WindowDocument[]; +} + +export interface ClaimWindowDocumentArgs { + path: string; + activate: boolean; +} + +export interface DocumentsMovedPayload { + fromPath: string; + toPath: string; +} + +export interface DocumentPathPayload { + path: string; +} + +export const setWindowDocuments = (documents: WindowDocument[]) => + invoke(SET_WINDOW_DOCUMENTS_COMMAND, { + documents, + } satisfies SetWindowDocumentsArgs); + +export const claimWindowDocument = (path: string, activate: boolean) => + invoke(CLAIM_WINDOW_DOCUMENT_COMMAND, { + path, + activate, + } satisfies ClaimWindowDocumentArgs); + +export const notifyDocumentsMoved = (fromPath: string, toPath: string) => + invoke(NOTIFY_DOCUMENTS_MOVED_COMMAND, { + fromPath, + toPath, + } satisfies DocumentsMovedPayload); + +export const notifyDocumentsRemoved = (path: string) => + invoke(NOTIFY_DOCUMENTS_REMOVED_COMMAND, { path } satisfies DocumentPathPayload); diff --git a/src/features/session/stores/otherWindowDocuments.test.ts b/src/features/session/stores/otherWindowDocuments.test.ts new file mode 100644 index 00000000..408d9cc4 --- /dev/null +++ b/src/features/session/stores/otherWindowDocuments.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { isUnsavedInOtherWindow, useOtherWindowDocumentsStore } from "./otherWindowDocuments"; + +describe("other windows' documents", () => { + it("records the unsaved documents of every window but this one", () => { + useOtherWindowDocumentsStore.getState().applySnapshot( + { + revision: 1, + windows: [ + { windowLabel: "main", documents: [{ path: "C:/Notes/mine.md", unsaved: true }] }, + { + windowLabel: "window-2", + documents: [ + { path: "C:/Notes/Draft.md", unsaved: true }, + { path: "C:/Notes/clean.md", unsaved: false }, + ], + }, + ], + }, + "main", + ); + + expect(isUnsavedInOtherWindow("c:\\notes\\draft.md")).toBe(true); + expect(isUnsavedInOtherWindow("C:/Notes/clean.md")).toBe(false); + expect(isUnsavedInOtherWindow("C:/Notes/mine.md")).toBe(false); + }); + + it("ignores a snapshot older than the one it holds", () => { + const { applySnapshot } = useOtherWindowDocumentsStore.getState(); + + applySnapshot( + { + revision: 3, + windows: [{ windowLabel: "window-2", documents: [] }], + }, + "main", + ); + applySnapshot( + { + revision: 2, + windows: [ + { windowLabel: "window-2", documents: [{ path: "C:/Notes/a.md", unsaved: true }] }, + ], + }, + "main", + ); + + expect(isUnsavedInOtherWindow("C:/Notes/a.md")).toBe(false); + expect(useOtherWindowDocumentsStore.getState().revision).toBe(3); + }); +}); diff --git a/src/features/session/stores/otherWindowDocuments.ts b/src/features/session/stores/otherWindowDocuments.ts new file mode 100644 index 00000000..c5e06310 --- /dev/null +++ b/src/features/session/stores/otherWindowDocuments.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; + +import { PathSet } from "@/lib/path"; + +import type { WindowDocumentsSnapshot } from "../services/windowDocumentsApi"; + +export interface OtherWindowDocumentsState { + /** The newest snapshot applied, so an older one arriving late is ignored. */ + revision: number; + /** Saved documents other windows hold with unsaved changes. */ + unsavedPaths: PathSet; + applySnapshot: (snapshot: WindowDocumentsSnapshot, ownWindowLabel: string) => void; +} + +export const useOtherWindowDocumentsStore = create()((set, get) => ({ + revision: 0, + unsavedPaths: new PathSet(), + applySnapshot: ({ revision, windows }, ownWindowLabel) => { + if (revision <= get().revision) { + return; + } + + set({ + revision, + unsavedPaths: new PathSet( + windows.flatMap(({ documents, windowLabel }) => + windowLabel === ownWindowLabel + ? [] + : documents.flatMap(({ path, unsaved }) => (unsaved ? [path] : [])), + ), + ), + }); + }, +})); + +export const isUnsavedInOtherWindow = (path: string) => + useOtherWindowDocumentsStore.getState().unsavedPaths.has(path); diff --git a/src/locales/en.json b/src/locales/en.json index 1801d8cb..d161d87e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -72,6 +72,7 @@ "preferences.lineSpacing.relaxed": "Relaxed", "preferences.typographyPreview.label": "Preview", "app.restoreAlwaysOnTopFailed": "Could not restore always on top.", + "app.applyAlwaysOnTopFailed": "Could not apply always on top to this window.", "app.loadPreferencesFailed": "Could not load preferences.", "menu.file": "File", "menu.edit": "Edit", @@ -80,6 +81,7 @@ "menu.view": "View", "menu.help": "Help", "command.file.new": "New", + "command.file.newWindow": "New window", "command.file.open": "Open...", "command.file.openFolder": "Open folder...", "command.file.clearRecentItems": "Clear recent items", @@ -92,6 +94,7 @@ "command.file.closeDocument": "Close document", "command.file.closeFolder": "Close folder", "command.file.closeWindow": "Close window", + "command.file.quit": "Quit", "command.edit.undo": "Undo", "command.edit.redo": "Redo", "command.edit.cut": "Cut", @@ -268,6 +271,8 @@ "commands.file.closeDocumentFailed": "Could not close document.", "commands.file.closeFolderFailed": "Could not close folder.", "commands.file.closeWindowFailed": "Could not close window.", + "commands.file.newWindowFailed": "Could not open a new window.", + "commands.file.quitFailed": "Could not quit Leafdown.", "commands.help.openDevToolsFailed": "Could not open DevTools.", "commands.help.openLinkFailed": "Could not open the link.", "app.loadReleaseNotesFailed": "Could not load release notes.", @@ -530,7 +535,7 @@ "folderSearch.cancel": "Cancel", "folderSearch.searchFurther": "Search further", "folderSearch.skipped": "{count, plural, one {# file skipped} other {# files skipped}}", - "folderSearch.skippedFile": "{path}: {reason, select, oversizedFile {larger than 5 MB} invalidEncoding {not valid in its encoding} missingFile {not found} permissionDenied {permission denied} parseFailed {could not be read as Markdown} replacementRefused {cannot take the replacement as shown} other {could not be read}}", + "folderSearch.skippedFile": "{path}: {reason, select, oversizedFile {larger than 5 MB} invalidEncoding {not valid in its encoding} missingFile {not found} permissionDenied {permission denied} parseFailed {could not be read as Markdown} replacementRefused {cannot take the replacement as shown} unsavedInAnotherWindow {has unsaved changes in another window} other {could not be read}}", "folderSearch.skippedMore": "{count, plural, one {# more file} other {# more files}}", "folderSearch.results": "Search results", "folderSearch.fileLabel": "{path}, {count, plural, one {# result} other {# results}}", @@ -552,7 +557,7 @@ "folderSearch.applyingAnnouncement": "Replacing", "folderSearch.report.written": "Replaced {matches, plural, one {# match} other {# matches}} in {files, plural, one {# file} other {# files}}.", "folderSearch.report.stale": "{count, plural, one {# file} other {# files}} changed since the preview", - "folderSearch.report.staleFile": "{path}: {reason, select, changedOnDisk {changed on disk} missing {no longer exists} other {changed in the editor}}{unsaved, select, yes {; replaced in the editor, not saved} other {}}", + "folderSearch.report.staleFile": "{path}: {reason, select, changedOnDisk {changed on disk} missing {no longer exists} unsavedInAnotherWindow {has unsaved changes in another window} other {changed in the editor}}{unsaved, select, yes {; replaced in the editor, not saved} other {}}", "folderSearch.report.failed": "{count, plural, one {# file} other {# files}} not replaced", "folderSearch.report.failedFile": "{path}: {reason, select, notArticle {not a Markdown file in this folder} unrepresentableCharacters {{encoding} cannot hold {detail}} permissionDenied {permission denied} readFailed {could not be read} writeFailed {could not be written} other {stopped without a result}}{unsaved, select, yes {; replaced in the editor, not saved} other {}}", "folderSearch.report.notAttempted": "{count, plural, one {# file} other {# files}} not attempted", @@ -566,6 +571,7 @@ "session.unsavedDocuments.cancel": "Cancel", "session.saveAsOpenInTab.title": "Document is already open", "session.saveAsOpenInTab.description": "{name} is open in another tab. Close it before saving over it, or choose another name.", + "session.saveAsOpenInWindow.description": "{name} is open in another window. Close it there before saving over it, or choose another name.", "session.folderSearch.matchUnavailable": "That match is no longer in {name}.", "session.folderReplace.unsavedChanges.title": "Unsaved changes", "session.folderReplace.unsavedChanges.message": "{name} has unsaved changes the replacement would include. Save or discard them first, then review the updated preview before replacing.", @@ -592,6 +598,7 @@ "session.externalChange.modified.description": "\"{name}\" changed outside Leafdown. Your unsaved changes are kept, and saving asks before overwriting the file.", "session.externalChange.missing.title": "File missing", "session.externalChange.missing.description": "\"{name}\" was moved or deleted outside Leafdown. The document stays open.", + "session.externalChange.missing.deletedInAnotherWindow": "\"{name}\" was deleted in another Leafdown window. The document stays open.", "session.untitledFileName": "Untitled", "session.untitledDocumentName": "{number, select, 1 {Untitled} other {Untitled {number}}}", "session.deleteEntry.title": "{kind, select, directory {Delete folder} other {Delete file}}", diff --git a/src/test/mocks/tauri.ts b/src/test/mocks/tauri.ts index 58ca298a..7f8f41ea 100644 --- a/src/test/mocks/tauri.ts +++ b/src/test/mocks/tauri.ts @@ -29,6 +29,7 @@ export const createTauriAppMock = () => ({ }); const currentWindowMock = { + label: "main", close: vi.fn(async () => undefined), destroy: vi.fn(async () => undefined), emit: vi.fn(async () => undefined), @@ -37,10 +38,12 @@ const currentWindowMock = { onDragDropEvent: vi.fn(async () => vi.fn()), onThemeChanged: vi.fn(async () => vi.fn()), setAlwaysOnTop: vi.fn(async () => undefined), + setFocus: vi.fn(async () => undefined), setFullscreen: vi.fn(async () => undefined), setTitle: vi.fn(async () => undefined), show: vi.fn(async () => undefined), theme: vi.fn(async () => "light"), + unminimize: vi.fn(async () => undefined), }; export const createTauriWindowMock = () => ({ diff --git a/src/test/utils/appStores.ts b/src/test/utils/appStores.ts index 3010467e..6aee796c 100644 --- a/src/test/utils/appStores.ts +++ b/src/test/utils/appStores.ts @@ -17,6 +17,7 @@ import { type SettingsPersistedState, } from "@/features/preferences"; import { RELEASE_NOTES_STATE_VERSION, useReleaseNotesStore } from "@/features/release-notes"; +import { useOtherWindowDocumentsStore } from "@/features/session/stores/otherWindowDocuments"; // Deep import by design: `@/features/session` re-exports `documentEditorBridge`, and the // beforeEach in ../setup/common.ts loads this module for every test file. import { @@ -140,4 +141,5 @@ export const resetAppStores = () => { }); useArticleNavigatorStore.getState().reset(); useFolderSearchStore.getState().reset(); + useOtherWindowDocumentsStore.setState(useOtherWindowDocumentsStore.getInitialState()); }; diff --git a/src/test/utils/tauri.ts b/src/test/utils/tauri.ts index 265c0d5b..7fd320fd 100644 --- a/src/test/utils/tauri.ts +++ b/src/test/utils/tauri.ts @@ -7,6 +7,13 @@ import { debug, error, info, trace, warn } from "@tauri-apps/plugin-log"; import { openPath, openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { expect, vi } from "vitest"; +import { + CLAIM_WINDOW_DOCUMENT_COMMAND, + NOTIFY_DOCUMENTS_MOVED_COMMAND, + NOTIFY_DOCUMENTS_REMOVED_COMMAND, + SET_WINDOW_DOCUMENTS_COMMAND, +} from "@/features/session/services/windowDocumentsApi"; + import { getMockPathExtension, joinMockPathSegments } from "./path"; export type InvokeCommandHandler = (args: unknown) => unknown; @@ -57,9 +64,18 @@ export const getWindowDragDropHandler = () => { return dragDropCall?.[0] as (event: { payload: DragDropEvent }) => void; }; +// Every window takes part in the record of which window holds which document, so a test of one +// window finds it the only window unless it replaces these. +const ONLY_WINDOW_HANDLERS: Record = { + [SET_WINDOW_DOCUMENTS_COMMAND]: () => undefined, + [CLAIM_WINDOW_DOCUMENT_COMMAND]: () => ({ kind: "claimed" }), + [NOTIFY_DOCUMENTS_MOVED_COMMAND]: () => undefined, + [NOTIFY_DOCUMENTS_REMOVED_COMMAND]: () => undefined, +}; + export const mockInvokeCommands = (handlers: Record) => { vi.mocked(invoke).mockImplementation(async (commandName, args) => { - const handler = handlers[commandName]; + const handler = handlers[commandName] ?? ONLY_WINDOW_HANDLERS[commandName]; if (!handler) { throw new Error(`Unexpected Tauri invoke command: ${commandName}`); @@ -70,11 +86,8 @@ export const mockInvokeCommands = (handlers: Record { - vi.mocked(invoke) - .mockReset() - .mockImplementation(async (commandName) => { - throw new Error(`Unexpected Tauri invoke command: ${commandName}`); - }); + vi.mocked(invoke).mockReset(); + mockInvokeCommands({}); vi.mocked(convertFileSrc) .mockReset() @@ -97,9 +110,11 @@ export const resetTauriMocks = () => { vi.mocked(appWindow.onDragDropEvent).mockReset().mockResolvedValue(vi.fn()); vi.mocked(appWindow.onThemeChanged).mockReset().mockResolvedValue(vi.fn()); vi.mocked(appWindow.setAlwaysOnTop).mockReset().mockResolvedValue(undefined); + vi.mocked(appWindow.setFocus).mockReset().mockResolvedValue(undefined); vi.mocked(appWindow.setFullscreen).mockReset().mockResolvedValue(undefined); vi.mocked(appWindow.show).mockReset().mockResolvedValue(undefined); vi.mocked(appWindow.theme).mockReset().mockResolvedValue("light"); + vi.mocked(appWindow.unminimize).mockReset().mockResolvedValue(undefined); const webview = getCurrentWebview(); vi.mocked(webview.setZoom).mockReset().mockResolvedValue(undefined); diff --git a/src/test/utils/tauriApi.ts b/src/test/utils/tauriApi.ts index e1125322..887da3cd 100644 --- a/src/test/utils/tauriApi.ts +++ b/src/test/utils/tauriApi.ts @@ -66,6 +66,17 @@ import { } from "@/features/folder-search/services/folderSearchApi"; import { TAKE_LAUNCH_DOCUMENT_PATH_COMMAND } from "@/features/session/services/launchDocumentApi"; import { DECLINE_WINDOW_CLOSE_COMMAND } from "@/features/session/services/windowCloseApi"; +import { + CLAIM_WINDOW_DOCUMENT_COMMAND, + NOTIFY_DOCUMENTS_MOVED_COMMAND, + NOTIFY_DOCUMENTS_REMOVED_COMMAND, + SET_WINDOW_DOCUMENTS_COMMAND, + type ClaimWindowDocumentArgs, + type ClaimWindowDocumentResult, + type DocumentPathPayload, + type DocumentsMovedPayload, + type SetWindowDocumentsArgs, +} from "@/features/session/services/windowDocumentsApi"; import { countInvokeCalls, @@ -100,6 +111,10 @@ interface TauriApiCommandArgs { writeFolderReplacementFiles: FolderReplacementArgs; takeLaunchDocumentPath: undefined; declineWindowClose: undefined; + setWindowDocuments: SetWindowDocumentsArgs; + claimWindowDocument: ClaimWindowDocumentArgs; + notifyDocumentsMoved: DocumentsMovedPayload; + notifyDocumentsRemoved: DocumentPathPayload; openWebviewDevtools: undefined; getDiagnosticsSummary: undefined; } @@ -130,6 +145,10 @@ interface TauriApiCommandResults { writeFolderReplacementFiles: FolderReplacementOutcome[]; takeLaunchDocumentPath: string | null; declineWindowClose: void; + setWindowDocuments: void; + claimWindowDocument: ClaimWindowDocumentResult; + notifyDocumentsMoved: void; + notifyDocumentsRemoved: void; openWebviewDevtools: void; getDiagnosticsSummary: DiagnosticsSummary; } @@ -170,6 +189,10 @@ const TAURI_API_COMMANDS = { writeFolderReplacementFiles: WRITE_FOLDER_REPLACEMENT_FILES_COMMAND, takeLaunchDocumentPath: TAKE_LAUNCH_DOCUMENT_PATH_COMMAND, declineWindowClose: DECLINE_WINDOW_CLOSE_COMMAND, + setWindowDocuments: SET_WINDOW_DOCUMENTS_COMMAND, + claimWindowDocument: CLAIM_WINDOW_DOCUMENT_COMMAND, + notifyDocumentsMoved: NOTIFY_DOCUMENTS_MOVED_COMMAND, + notifyDocumentsRemoved: NOTIFY_DOCUMENTS_REMOVED_COMMAND, openWebviewDevtools: OPEN_WEBVIEW_DEVTOOLS_COMMAND, getDiagnosticsSummary: GET_DIAGNOSTICS_SUMMARY_COMMAND, } satisfies Record;