From 512ac618816fbd045e3e8034b854e0096c36ea98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 11 Oct 2026 11:01:18 -0300 Subject: [PATCH 1/3] Open documents as tabs within a window --- CHANGELOG.md | 1 + docs/architecture.md | 37 +- docs/help/file-and-folder-workflows.md | 22 +- docs/help/getting-started.md | 2 +- docs/reference.md | 5 + docs/specification.md | 59 ++-- e2e/desktop/run.ts | 1 + e2e/desktop/specs/block-selection.spec.ts | 177 +++++++--- e2e/desktop/specs/callouts.spec.ts | 8 +- e2e/desktop/specs/citations.spec.ts | 12 +- e2e/desktop/specs/command-palette.spec.ts | 62 +++- e2e/desktop/specs/document-lifecycle.spec.ts | 60 ++-- e2e/desktop/specs/document-tabs.spec.ts | 103 ++++++ e2e/desktop/specs/document-watcher.spec.ts | 2 +- e2e/desktop/specs/find-and-replace.spec.ts | 40 ++- e2e/desktop/specs/folder-actions.spec.ts | 12 +- e2e/desktop/specs/folder-search.spec.ts | 66 ++-- e2e/desktop/specs/folder-watcher.spec.ts | 6 +- e2e/desktop/specs/frontmatter.spec.ts | 54 ++- e2e/desktop/specs/heading-outline.spec.ts | 67 ++-- e2e/desktop/specs/help-pages.spec.ts | 6 +- e2e/desktop/specs/launch-document.spec.ts | 2 +- e2e/desktop/specs/legacy-encoding.spec.ts | 4 +- .../specs/missing-document-error.spec.ts | 2 +- e2e/desktop/specs/remote-images.spec.ts | 10 +- e2e/desktop/specs/rendered-html.spec.ts | 10 +- e2e/desktop/specs/rendered-images.spec.ts | 4 +- e2e/desktop/specs/rendered-math.spec.ts | 11 +- e2e/desktop/specs/rendered-mermaid.spec.ts | 14 +- .../specs/separator-presentation.spec.ts | 2 +- e2e/desktop/specs/wiki-links.spec.ts | 9 +- e2e/desktop/specs/window-lifecycle.spec.ts | 2 +- e2e/desktop/support/scenarioSelection.test.ts | 1 + e2e/desktop/support/scenarioSelection.ts | 1 + e2e/desktop/support/ui.ts | 33 ++ src-tauri/capabilities/default.json | 1 + src/app.test.tsx | 51 ++- src/app.tsx | 4 +- src/commands/actions/edit.ts | 6 +- src/commands/actions/view.test.ts | 53 ++- src/commands/actions/view.ts | 23 +- src/commands/application.ts | 11 + src/commands/context.ts | 6 + src/commands/hooks/useAppCommands.test.tsx | 31 +- src/commands/hooks/useAppCommands.ts | 5 + src/commands/metadata.ts | 14 + src/components/layout/command-menubar.tsx | 2 + src/components/layout/confirmation-dialog.tsx | 2 +- src/components/layout/document-tabs.test.tsx | 124 +++++++ src/components/layout/document-tabs.tsx | 217 ++++++++++++ src/components/layout/shell.test.tsx | 37 +- src/components/layout/shell.tsx | 30 +- .../screens/document-screen.test.tsx | 37 +- src/components/screens/document-screen.tsx | 48 ++- .../editor/hooks/useMilkdownEditorInstance.ts | 7 + src/features/folder-search/index.ts | 2 +- .../services/folderSearchEngine.test.ts | 12 +- .../services/folderSearchEngine.ts | 28 +- ...er.test.ts => useDocumentWatchers.test.ts} | 77 +++-- ...umentWatcher.ts => useDocumentWatchers.ts} | 77 +++-- src/features/session/index.ts | 9 +- .../services/documentEditorBridge.test.ts | 84 ++++- .../session/services/documentEditorBridge.ts | 181 +++++----- .../services/documentWorkflows.test.ts | 160 ++++++++- .../session/services/documentWorkflows.ts | 296 ++++++++-------- .../session/services/externalChanges.test.ts | 121 ++++--- .../session/services/externalChanges.ts | 68 ++-- .../services/folderContextWorkflows.test.ts | 23 ++ .../services/folderContextWorkflows.ts | 5 +- .../services/folderEntryWorkflows.test.ts | 84 +++-- .../session/services/folderEntryWorkflows.ts | 60 ++-- .../services/folderReplaceWorkflows.test.ts | 97 +++++- .../services/folderReplaceWorkflows.ts | 120 +++---- .../services/folderSearchWorkflows.test.ts | 93 +++++- .../session/services/folderSearchWorkflows.ts | 108 +++--- .../services/htmlExportWorkflow.test.ts | 6 +- .../session/services/htmlExportWorkflow.ts | 20 +- .../session/services/openSession.test.ts | 173 ++++++---- src/features/session/services/openSession.ts | 173 ++++++---- src/features/session/services/tabWorkflows.ts | 37 ++ .../session/services/unsavedChanges.test.ts | 105 +++++- .../session/services/unsavedChanges.ts | 51 ++- src/features/session/stores/session.test.ts | 238 +++++++++---- src/features/session/stores/session.ts | 316 ++++++++++++------ src/features/session/utils/documentNames.ts | 34 ++ src/locales/en.json | 23 +- src/test/factories/commands.ts | 4 + src/test/mocks/tauri.ts | 1 + src/test/utils/appStores.ts | 65 +++- 89 files changed, 3338 insertions(+), 1229 deletions(-) create mode 100644 e2e/desktop/specs/document-tabs.spec.ts create mode 100644 src/components/layout/document-tabs.test.tsx create mode 100644 src/components/layout/document-tabs.tsx rename src/features/session/hooks/{useActiveDocumentWatcher.test.ts => useDocumentWatchers.test.ts} (76%) rename src/features/session/hooks/{useActiveDocumentWatcher.ts => useDocumentWatchers.ts} (72%) create mode 100644 src/features/session/services/tabWorkflows.ts create mode 100644 src/features/session/utils/documentNames.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5002f02..aedbe6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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. - 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/docs/architecture.md b/docs/architecture.md index 0bf26840..6fd0944e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ Application composition lives under `src/components/` in `layout/` and `screens/ The `release-notes` feature owns the changelog bundled into the frontend, current-version selection, and persisted launch and seen-version history. It renders notes with the `help` feature's bundled-page Markdown renderer, so both Help surfaces share one link policy, and it does not access document or session state. -The `session` feature owns the relationship between the active document and folder context, plus workflows spanning multiple features. Dependencies flow left to right: +The `session` feature owns the window's open documents and their relationship to the folder context, plus workflows spanning multiple features. Dependencies flow left to right: `application components -> commands -> session -> domain features -> shared UI/lib` @@ -40,7 +40,7 @@ Arrows define direction, not required intermediate dependencies: a layer may imp Global scope does not make code shared. Domain-owned global behavior stays in its feature; only domain-agnostic reuse belongs in shared UI or `lib`. -Session and editor workflows request yes/no decisions through the shared confirmation service in `src/lib/`. The app shell renders its queued request using the shared dialog UI. Each feature keeps its own prompt wording and verifies the relevant document or link state after the decision; session tracks document activation separately from its path so a reopened file cannot satisfy an older confirmation. +Session and editor workflows request yes/no decisions through the shared confirmation service in `src/lib/`. The app shell renders its queued request using the shared dialog UI. Each feature keeps its own prompt wording and verifies the relevant document or link state after the decision; session versions each tab's document separately from its path, so a document saved, reloaded, or reopened at the same path cannot satisfy an older confirmation. ## Domain Vocabulary @@ -48,7 +48,8 @@ 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. -- **Session:** the active document, when one exists, plus an optional folder context. +- **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. - **Workspace:** not an application domain term; Leafdown creates neither a workspace model nor workspace metadata. @@ -57,7 +58,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. -- **Active document:** The saved or untitled Markdown document currently loaded in the editor. The session keeps the Markdown it was opened or last saved with; the editor holds the current text, which workflows such as Save and rename read through the editor bridge instead of a copy refreshed on each edit. +- **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. - **Active document metadata:** File metadata and a content fingerprint utilized for dirty-state and external modification checks. ## Editor Architecture @@ -147,9 +148,9 @@ The Rust backend manages: - Reading a folder context's articles for a search in bounded batches through the document loader, answering `unchanged` instead of the text for a file whose metadata matches the version already read, and refusing paths outside the folder context or naming symbolic links. - Writing a folder replacement in bounded batches: checking each planned file against the metadata and fingerprint it was planned from, reading the whole file rather than trusting its metadata, and encoding its text strictly, first for every file without writing and again for each file just before its atomic write. A file that changed is left as it is, a failure is reported per file, and the same folder and symbolic-link refusals apply. - Creating Markdown files and folders, renaming entries, and moving entries to the system Recycle Bin or Trash inside the current folder context. Each operation resolves its target's parent through the filesystem and refuses one outside the folder context, so the root itself is never renamed or trashed. It refuses names the platform cannot hold, never replaces an existing entry, and never falls back to permanent deletion. -- Filesystem watching to monitor directory changes: the folder context for the article tree, where an unsupported file matters only when it appears, vanishes, or is renamed, and the active document's directory, non-recursively, for events naming that file. A symlinked document is watched under its own name and its target's. +- Filesystem watching to monitor directory changes: the folder context for the article tree, where an unsupported file matters only when it appears, vanishes, or is renamed, and each open saved document's directory, non-recursively, for events naming that file. A symlinked document is watched under its own name and its target's. - 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 the active document's file against its recorded metadata and fingerprint, reading the file's bytes only when the metadata differs. +- 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. - Blocking webview navigation to remote origins, and granting asset-protocol access only to resolved image paths. - Mapping permission and IO errors. @@ -163,9 +164,9 @@ The React frontend manages: - User interface rendering and application commands. - App-rendered confirmation dialogs for document and local-link decisions. - Milkdown integration and custom editor elements. -- Application state (folder context, active document, settings). +- Application state (folder context, open documents and the active one, settings). - Updating the article navigator in response to backend file events. -- Reconciling the active document with its file after a document watcher event. +- Reconciling an open document with its file after a document watcher event. - Path normalization and local image loading via Tauri's custom asset protocol. - 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. @@ -188,7 +189,7 @@ The frontend owns all translated text. The backend returns typed error `kind`s a ## Data Contracts -- Session owns the active document, folder context, and document metadata used for dirty-state and external-modification checks. +- 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. - 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. @@ -200,7 +201,7 @@ The frontend owns all translated text. The backend returns typed error `kind`s a ### Open Workflow -Backend reads target document -> Session updates active document -> Session bootstraps folder context only when none exists. +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. ### Launch Workflow @@ -212,9 +213,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 about unsaved changes before creating a file or deleting an entry that holds the active document -> Backend validates and changes the filesystem -> Session moves the active document path, recent items, and expanded folders under a renamed path, or closes a trashed active document -> 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 -> Session rescans the folder context -> The watcher's later refresh produces the same tree. -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 active document remounts from the Markdown the editor held, keeping its unsaved changes and its metadata snapshot for the next freshness check. +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. ### Save Workflow @@ -222,7 +223,7 @@ Serialize editor state to Markdown -> Verify metadata freshness via backend -> E ### Save As Workflow -Write document to new path -> Update active 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 -> 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 @@ -230,17 +231,17 @@ Pick the output path -> Read a settled snapshot of the editor state, applying th ### Folder Search Workflow -Session hands the folder-search engine the folder's articles in navigator order, with the open saved document read from the editor -> Backend reads the next bounded batch of files, the next batch requested while one is searched -> The engine parses each changed file to searchable text on the main thread, giving way to the interface between files, and keeps that text, within a budget, until the folder closes or changes -> Results publish as they are found -> A changed query, a closed view or folder, or `Cancel` retires the run, and a folder refresh or an edit to the open document searches again with the results in place; each file keeps the results of the newest read of it. +Session hands the folder-search engine the folder's articles in navigator order, with every open saved document read from its editor -> Backend reads the next bounded batch of files, the next batch requested while one is searched -> The engine parses each changed file to searchable text on the main thread, giving way to the interface between files, and keeps that text, within a budget, until the folder closes or changes -> Results publish as they are found -> A changed query, a closed view or folder, or `Cancel` retires the run, and a folder refresh, an edit to an open document, or a tab closing searches again with the results in place; each file keeps the results of the newest read of it. 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 the open document. Applying a complete plan asks about the open document's unsaved changes when the plan includes it, 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 the 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 the 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. 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. ### External Change Workflow -Backend reports an event naming the active document to the window watching it -> Session debounces events into one check -> The check queues behind pending saves and renames, which replace the file and the document's version together -> Backend compares the file's metadata, then its fingerprint, with the document's version -> Session accepts a touch's metadata, reloads a clean document through the open path, records a newer version for a dirty document, or records a missing file -> A result for a document that was replaced, renamed, or closed in the meantime is discarded. +Backend reports an event naming an open document to the window watching it -> Session debounces each document's events into one check -> The check queues behind pending saves and renames, which replace the file and the document's version together -> Backend compares the file's metadata, then its fingerprint, with the document's version -> Session accepts a touch's metadata, reloads a clean document in its tab through the open path, records a newer version for a dirty document, or records a missing file -> A result for a document that was replaced, renamed, or closed in the meantime is discarded. -The document watch starts whenever the active saved document's path changes and checks the file once it is in place, since the file can change between being read and being watched. A reload remounts the editor, as a reopen does, and carries the caret and focus into the new editor. +A document watch starts for each saved document a tab opens or moves to, and stops when that path leaves every tab; it checks the file once it is in place, since the file can change between being read and being watched. A reload remounts the editor, as a reopen does, and carries the caret and focus into the new editor. ## Packaging @@ -273,7 +274,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 the active document, find and replace from the keyboard and the Edit menu, folder search from the keyboard and the Edit menu with its unsaved-changes prompt, 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, 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 d7ad6b91..eb9fe157 100644 --- a/docs/help/file-and-folder-workflows.md +++ b/docs/help/file-and-folder-workflows.md @@ -1,12 +1,18 @@ # File and folder workflows -Leafdown keeps the open document and the folder context separate. The folder context pins the article navigator to one folder until you open or close another folder. +Leafdown keeps your open documents and the folder context separate. The folder context pins the article navigator to one folder until you open or close another folder. ## Open and revisit -**File > Open** opens one Markdown file: `.md`, `.markdown`, `.mdown`, or `.mkd`. If no folder is open, Leafdown also opens its parent folder as context. If a folder is already pinned, opening a file elsewhere leaves that context in place. **File > Open folder** scans the folder for Markdown articles and opens a matching root-level `readme` or `index` file when present. Ignored directories do not appear in the navigator. +**File > Open** opens one Markdown file: `.md`, `.markdown`, `.mdown`, or `.mkd`. If no folder is open, Leafdown also opens its parent folder as context. If a folder is already pinned, opening a file elsewhere leaves that context in place. **File > Open folder** scans the folder for Markdown articles and opens a matching root-level `readme` or `index` file when present, keeping the documents you already have open. Ignored directories do not appear in the navigator. -Opening another document starts it at the top. Following a heading link or choosing a search result instead brings its target into view. +## Work with tabs + +Every document you open gets a tab above the document, after the one you are on, and **File > New** adds an untitled tab. Opening a file that is already open switches to its tab, so opening never closes or replaces a document. Each tab keeps its own unsaved edits, undo history, place in the document, and search. + +Choose a tab to switch to it. `Mod+Tab` and `Mod+Shift+Tab`, or `Mod+PageDown` and `Mod+PageUp`, move to the next and previous tab, and `Mod+1` to `Mod+8` jump to a tab by position, `Mod+9` to the last one. A dot on a tab marks unsaved changes. **File > Close document** (`Mod+W`), a tab's close button, or a middle click closes it, asking first if it has unsaved changes. Closing the window or the folder asks once about every tab with unsaved changes. + +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. @@ -14,21 +20,21 @@ The welcome screen and **File > Open recent** show previously opened files and f ## 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 after creation. Deleting asks for confirmation and moves an entry to the system Recycle Bin or Trash; Leafdown does not permanently delete it on its own. 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. 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. ## Search the folder -**Edit > Find and replace > Find in folder** (`Mod+Shift+F`) turns the sidebar into a search of the open folder's Markdown files, the same files the navigator opens. Type to search; matching works as **Find** does inside a document, with **Match case** and **Whole word**, and the open document is searched as you have it, unsaved edits included. Matches are grouped by file, with the text around each one. Choose a match, or move to it with the arrow keys and press `Enter`, to open its file and highlight the match while you stay in the results. Files that are too large or not valid UTF-8 or UTF-16 are skipped and listed. `Escape` returns to the navigator and to the document, with the match selected. +**Edit > Find and replace > Find in folder** (`Mod+Shift+F`) turns the sidebar into a search of the open folder's Markdown files, the same files the navigator opens. Type to search; matching works as **Find** does inside a document, with **Match case** and **Whole word**, and every open document is searched as you have it, unsaved edits included. Matches are grouped by file, with the text around each one. Choose a match, or move to it with the arrow keys and press `Enter`, to open its file in a tab and highlight the match while you stay in the results. Files that are too large or not valid UTF-8 or UTF-16 are skipped and listed. `Escape` returns to the navigator and to the document, with the match selected. ## Replace across the folder **Edit > Find and replace > Replace in folder** (`Mod+Shift+H`), or the arrow before the search field, shows a **Replace with** field. While it shows, the view previews every change instead of searching: each match is shown struck through with its replacement beside it, across every file, however many there are. Nothing is written until you choose **Replace all**, which appears once the preview is complete; changing the search, the replacement, or an option first changes nothing. -**Replace all** writes each file in turn and then reports what it did. Only the lines holding a replacement change; a file marked with a warning sign is written as **Save** would write it, which can also change other lines, such as the padding of a table. If the open document has unsaved changes and is part of the preview, you are asked to save or discard them first, and the preview updates before anything is written. A file that changed since the preview is left alone and listed, as is any file that cannot be written, and the other files are still replaced. +**Replace all** writes each file in turn and then reports what it did. Only the lines holding a replacement change; a file marked with a warning sign is written as **Save** would write it, which can also change other lines, such as the padding of a table. If open documents with unsaved changes are part of the preview, you are asked to save or discard each of them first, and the preview updates before anything is written. A file that changed since the preview is left alone and listed, as is any file that cannot be written, and the other files are still replaced. -A replacement cannot be undone across files. In the open document, **Undo** undoes it for that document only; other files keep their new text, so commit or back up your notes first if you may want them back. +A replacement cannot be undone across files. In an open document, **Undo** undoes it for that document only; other files keep their new text, so commit or back up your notes first if you may want them back. ## Drop a file or folder @@ -46,4 +52,4 @@ Local images render when available. A remote `https:` image remains a placeholde ## Save and external changes -**File > Save as** writes the document to a chosen path. Saving outside the current folder does not switch that folder. Leafdown watches the open file: a clean document reloads after another program changes it, while an edited document keeps its changes and warns. Saving over an externally changed file asks first. If the file has disappeared, Save offers Save as. These checks also apply to a file opened outside the pinned folder. +**File > Save as** writes the document to a chosen path. Saving outside the current folder does not switch that folder, and a file open in another tab cannot be saved over. Leafdown watches every open file: a clean document reloads after another program changes it, while an edited document keeps its changes and warns. Saving over an externally changed file asks first. If the file has disappeared, Save offers Save as. These checks also apply to a file opened outside the pinned folder. diff --git a/docs/help/getting-started.md b/docs/help/getting-started.md index 6199c1b1..8429731b 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. -When you close a document, open another, or leave the app with unsaved changes, Leafdown asks whether to save, discard, or cancel. Cancel leaves the current document 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, 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/reference.md b/docs/reference.md index 1da1dba5..ace25dde 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -240,6 +240,8 @@ In the folder search view, `Enter` in the query field searches at once and `Arro - **Command palette...** (`Mod+Shift+P`) - **Toggle sidebar** (`Mod+Shift+E`) - **Toggle status bar** +- **Next tab** (`Mod+Tab` / `Mod+PageDown`) +- **Previous tab** (`Mod+Shift+Tab` / `Mod+PageUp`) - **Outline** (Deferred) - **Zoom in** (`Mod+=`) - **Zoom out** (`Mod+-`) @@ -259,6 +261,8 @@ In the folder search view, `Enter` in the query field searches at once and `Arro - **Collapse all folders** - **Expand all folders** +`Go to tab 1` through `Go to tab 8` (`Mod+1` through `Mod+8`) and `Go to last tab` (`Mod+9`) are not in a menu; they are run from their shortcuts or the command palette. + #### Help Menu - **What's new...** @@ -332,6 +336,7 @@ A submenu trigger carries the same state as the commands behind it: it is disabl - Exception: `Insert final newline on save` remains enabled because it controls a global save setting rather than active editor content. - `Export`, `Print`, and `Outline` are disabled when no document is open. - `Save` is disabled when no document is open, or when the document is clean and already saved. +- `Next tab` and `Previous tab` require a second open document; they move in tab order and wrap at either end. `Go to tab 1` through `Go to tab 8` require a tab at that position, and `Go to last tab` requires an open document. #### Selection And Editor State diff --git a/docs/specification.md b/docs/specification.md index 50807695..38c43507 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -46,10 +46,11 @@ Unsupported Markdown is outside Leafdown's supported editing surface. Leafdown t Primary user interface surfaces: -- **App shell:** wraps the custom titlebar, menu bar, article navigator, document surface with its document outline, status bar, and modal layer. +- **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. -- **Document surface:** contains the active hybrid Markdown editor. In a folder-only session, it displays a centered placeholder illustration and text prompting the user to select an article or create a new file. +- **Document surface:** contains the active document's hybrid Markdown editor below the document tabs. In a folder-only session, it displays a centered placeholder illustration and text prompting the user to select an article or create a new file. +- **Document tabs:** a strip above the document surface, beside the article navigator, while at least one document is open. Each open document has a tab in the order it was opened, a new tab going after the active one; tabs cannot be reordered. A tab names its file, followed by its folder's name while another tab has the same name, or `Untitled`, `Untitled 2`, and so on for untitled documents, and hovering it shows the file's full path. A tab with unsaved changes shows a dot that becomes a close mark on hover; other tabs show the close mark while active or hovered. Choosing a tab makes it active and puts focus in its editor; the close mark or a middle click closes it as `Close document` does, without making it active first. When the tabs do not fit, they narrow to a minimum width and then the strip scrolls sideways, with the mouse wheel too, keeping the active tab in view. A `+` button after the tabs creates a new document. Assistive technology reads the strip as a tab list named `Open documents`: arrow keys move between tabs, `Enter` or `Space` chooses one, and each tab's name says when its document has unsaved changes. The close marks are for the pointer; from the keyboard, `Close document` closes the active tab. - **Article navigator:** appears only while a folder context is open. Without one, the document surface spans the workspace and `Toggle sidebar` is unavailable, leaving the sidebar visibility setting untouched for the next folder. It shows the current folder context's directories and files in a nested tree, leaving out ignored directories and everything in them. Articles (supported Markdown files) open in the editor. Every other file is listed among them in a muted style; hovering it, or reading it with assistive technology, says that Leafdown can only open Markdown files, and choosing it opens nothing, in Leafdown or in another application. Non-ignored directories appear even when they have no articles, so a directory holding only other files expands to show them while an empty directory has nothing to expand. Directories are listed before files within each folder, then the selected article sort order is applied within each group, with articles and other files sorted together. The navigator's article count, its article filter, and its empty-folder message concern articles only: filtering never matches another file, and a folder without articles says that no supported Markdown files were found while still listing its other files. Folder scans skip symlinked entries rather than following them, and skip the staging files Leafdown writes while saving. When the active saved document is outside the current folder context, the article navigator keeps showing the current folder context and displays a compact detached-document message instead of selecting an article. Context menus on its rows and on its empty space create, rename, delete, locate, and copy the paths of files and folders inside the current folder context, as described in [Article Navigator Actions](#article-navigator-actions). Moving, copying, and duplicating entries is left to the system file manager. - **Document outline:** floats at the right edge of the document surface while the active document has at least one heading, including untitled documents and files opened without a folder; a document without headings shows no outline. At rest it is a column of dots, one per listed heading in document order and sized by heading level, with the current heading's dot in the accent color. Hovering the column opens it in place after a short delay: the dots spread apart and each heading's title appears beside its dot, indented by level from the shallowest listed level. The heading under the pointer stays level with it as the outline opens, so pointing low on the column opens the lower part of the outline. When the open list is taller than the surface, its top and bottom edges fade while headings lie beyond them, and moving the pointer up and down over the dots scrolls it, while over the titles it holds still for choosing. The outline closes shortly after the pointer leaves it, when focus leaves it, or with Escape, which returns focus to where it came from. Tab reaches the current heading's row and opens the outline there; Up, Down, Home, and End move between rows, and Enter or Space chooses one. A press on the closed column, such as a tap, opens it without choosing a heading. The H1 to H6 control in the panel header lists headings down to the chosen level, H1–H3 by default, while the open outline keeps its place under the pointer, moving only as far as it must to stay on the surface, with the current heading in view, and keeps the choice as the [Outline levels](./reference.md#general) setting. A document whose shallowest heading is deeper than that level still lists its shallowest level. A heading that is not listed marks the nearest listed heading before it as current. Headings inside a folded section stay listed, and choosing one unfolds the sections around it; a heading hidden by a fold is never current. A folded heading's dot is hollow, at the size its level gives it. The outline offers no fold control of its own, and its levels and the editor's folds do not change each other. Choosing a heading places the caret at its start without changing the document and scrolls the heading to where the document's first line rests before any scrolling, so it keeps the editor's top padding at every width and stays clear of an open search panel; a heading in the last screen stops wherever scrolling ends. A heading that opens the document, with no content before it in the document or in its container, returns the view to the start. Duplicate labels navigate by position rather than text. Headings inside lists, blockquotes, and callouts name their container in their accessible name and hover title. The current heading is the last one to reach a line just below the top of the view. Over the final screen of scrolling that line moves down to the bottom of the view, so the last headings become current in turn, and a heading chosen from the outline stays current while the view stays where navigation left it. Before the first heading none is current. Editing, paste, heading-level changes, Undo and Redo, scrolling, resizing, and document switches refresh the rows and the current heading. - **Status bar:** appears along the bottom of the workspace, beneath the article navigator and the document surface, while a document is open and `Status bar visibility` is on. Like the menu bar, it has no surface of its own. Assistive technology reads it as one labeled region that does not announce its changes, and only its controls take focus. It shows, as the document and selection change: @@ -90,7 +91,7 @@ These state axes compose. A document session, for example, can have a folder con - **Welcome:** no document or folder context is open. - **Folder-only session:** a folder context is open, but no document is active. -- **Document session:** a document is active. A folder context may also be active. +- **Document session:** one or more documents are open as tabs, one of them active. A folder context may also be active. ### Folder Context State @@ -100,6 +101,8 @@ These state axes compose. A document session, for example, can have a folder con ### Document State +Every open document has its own state, kept while its tab is inactive: its editor with its `Undo` history, caret, scroll position, and search surface, and the states below. The status bar, document outline, window title, and commands act on the active document. + - **No active document:** no document is open in the document surface. - **Saved document:** the active document has a file path. - **Untitled document:** the active document has no file path yet and requires Save as. @@ -111,11 +114,11 @@ These state axes compose. A document session, for example, can have a folder con ### Session Lifecycle -- `Close document` closes only the active document. When a folder context exists, it remains active. -- `Close folder` closes the active folder context and active document after dirty-state handling, returning to the welcome screen. -- `Close window` closes the current app window after dirty-state handling. -- Closing, switching files, or exiting with a dirty document prompts the user. -- 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 for a document no longer active cannot discard, save, or overwrite the newly active document, even when it has the same path. +- `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. +- 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. ### Marker Visibility State @@ -310,12 +313,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. -- The open document is searched as the editor holds it, 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 first, as opening the search surface does. Its results follow edits without saving, marking unsaved changes, or adding history. +- 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. - 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, first asking about unsaved changes in the open document; declining leaves the editor and the results as they were. A match in the open document 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. 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 @@ -323,9 +326,9 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - `Replace in folder...` opens the folder search view with its replace field showing, focusing the replacement when the query already holds text; the toggle before the query field shows and hides it. Matching is exactly that of [Find In Folder](#find-in-folder), over the same files with the same options, and the replacement is literal text, as in [Find And Replace](#find-and-replace). - While the replace field shows, the search plans replacing every match rather than stopping at the result limit: it reads every file again whose text holds a match, replaces its matches as `Replace all` does in an open document, and keeps the text it would write. Each match shows the text around it with the matched text struck through and the replacement beside it, and the view gives the number of replacements and files, or `Nothing to replace`. A file the replacement would not change is left out, and a file that would not read as its matches replaced, such as a footnote label the replacement would duplicate, is listed as skipped with its reason. Planning writes nothing, and closing the view, cancelling, or changing the query, the replacement, or an option before `Replace all` changes no file. - 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 the 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 the open document has unsaved changes and the plan includes it, `Replace all` first asks to `Save`, `Discard`, or `Cancel`. `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`, a declined or failed save, or a failed read leaves every file and the document as they were. Unsaved changes in an open document the plan does not include are neither saved nor discarded. -- `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. The open document 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. +- `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. +- `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. @@ -478,11 +481,11 @@ 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). -- Read the selected Markdown file. -- Open the selected file in the document surface without waiting for a folder scan. +- 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. +- 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. -- When another document replaces the active document, start its viewport at the top. Explicit heading or search navigation instead reveals its target. Reloading the same document after an external change retains its caret-restoration behavior. +- A newly opened document starts its viewport at the top, and a tab made active again keeps the caret and scroll position it had. Explicit heading or search navigation instead reveals its target. Reloading a document after an external change retains its caret-restoration behavior. - Select the opened file in the article navigator only when it exists in the current article navigator. - Add the file to recents when `Record recent files and folders` is enabled. Add the file's parent folder to recent folders only when the file open bootstraps a folder context and its scan succeeds. @@ -490,10 +493,11 @@ 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. -- Open a root-level configured index file when one exists. Only a supported Markdown file can be an index file. +- 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. - Match index file names case-insensitively in configured order. - For the same configured index name, prefer `.md`, then `.markdown`, `.mdown`, and `.mkd`. -- Otherwise leave no document open. +- Otherwise leave the tabs as they are, showing the folder-only state when none is open. - Show an empty-folder state only when no supported Markdown files exist, whatever other files the folder holds. - Add the folder to recents when `Record recent files and folders` is enabled. @@ -501,7 +505,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c - A drop is accepted anywhere in the application window when it contains exactly one existing folder or supported Markdown file. While an item is over the window, a non-interactive overlay states the action Leafdown will take or why the drop is unavailable. A drop containing several items or an unsupported item shows a non-disruptive warning and changes nothing. - `When dropping a folder` and `When dropping a Markdown file` independently choose Open or Insert link. Both default to Open. -- Open follows the corresponding Open Folder or Open File workflow, including dirty-state handling, pinned folder context behavior, index-file opening, and recent-item recording. +- Open follows the corresponding Open Folder or Open File workflow, including tab opening, pinned folder context behavior, index-file opening, and recent-item recording. - Insert link requires an active document and inserts at its current editor selection. An inline text selection becomes the link label; otherwise the dropped item's name is used. A selection that cannot hold one inline link shows a non-disruptive warning and changes nothing. - A link inserted into a saved document is relative to that document when the dropped item shares its filesystem root; otherwise it uses the absolute path. A link inserted into an untitled document uses the absolute path. - Insert link with no active document shows a non-disruptive warning and changes nothing. @@ -517,15 +521,16 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c ### New Document -- Create an untitled Markdown document. +- Create an untitled Markdown document in a new tab after the active one, which becomes active. +- Name it `Untitled`, or `Untitled 2` and so on, after the lowest number no open untitled document has. - If a folder context exists, associate the untitled document with it. -- Show the untitled document in the document surface. - Do not create a file until `Save as`. ### Save As - 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. - 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. @@ -554,11 +559,11 @@ The article navigator's context menus act on the row or empty space they were op - A Markdown file row offers `Open`, `New file`, `New folder`, `Rename`, `Delete`, `Open file location`, `Copy path`, and `Copy relative path`. A directory row offers the same without `Open`, with `Open folder location` in place of `Open file location`. A row for a file Leafdown cannot open offers `New file`, `New folder`, `Open file location`, `Copy path`, and `Copy relative path`: Leafdown does not open, rename, or delete such a file. The empty space offers `New file`, `New folder`, `Open folder location`, `Copy path`, and `Copy relative path`. - `Open` follows the Open File workflow, as activating the row does. - `New file` and `New folder` create the entry inside a directory row, beside a file row in its parent, or in the folder context root from the empty space. A temporary row in that folder, expanded if needed, takes the name. `Enter` or moving focus elsewhere in Leafdown commits it, and `Escape` or an empty name cancels it. Switching to another window leaves the name open. -- 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 asks about unsaved changes to the active document before creating the file, so declining leaves no file behind, then opens and selects the new, empty document. A new folder takes the focused row without changing the active document. +- 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 the active saved document, or a folder containing it, moves the document to its new path 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 the active document, or a folder containing it, asks about unsaved changes first and closes the document 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 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. - `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. @@ -568,9 +573,9 @@ The article navigator's context menus act on the row or empty space they were op - Leafdown watches the active folder context for filesystem changes. - When files or directories are created, renamed, or deleted externally, the article navigator automatically updates to reflect the changes. Writing to a file Leafdown cannot open does not update it, so under the modified-date sort order such a file moves to its new place at the next update. - The watcher's refresh after a change made through the article navigator arrives at the same tree the navigator already shows. -- Leafdown also watches the active saved document's file on its own, so a file outside the current folder context, or opened without one, is watched too. Untitled documents have no file to watch. +- Leafdown also watches each open saved document's file on its own, whether or not its tab is active, so a file outside the current folder context, or opened without one, is watched too. Untitled documents have no file to watch. - A filesystem event only prompts a check of the file. The check compares the file's size and modification time with those Leafdown recorded for the document, and when they differ it reads the file to tell a new version from a touch. A touch that leaves the file's bytes unchanged updates the recorded metadata without reloading or warning. -- A clean document reloads a changed file as Open File reads it, in the encoding it was read in, without changing the folder context or recent files. The reloaded text becomes the new clean document with the file's line ending and encoding. `Undo` history starts over, source projection ends, and the caret stays where it was or at the nearest position the new text allows, keeping editor focus. A notification says the file was reloaded. A file that cannot be reloaded, such as one no longer valid in its encoding, shows the open error and leaves the document as it was. +- A clean document, in the active tab or another one, reloads a changed file in its tab as Open File reads it, in the encoding it was read in, without changing the folder context or recent files. The reloaded text becomes the new clean document with the file's line ending and encoding. `Undo` history starts over, source projection ends, and the caret stays where it was or at the nearest position the new text allows, keeping editor focus. A notification says the file was reloaded. A file that cannot be reloaded, such as one no longer valid in its encoding, shows the open error and leaves the document as it was. - A dirty document keeps its content. Leafdown records the external change and warns once for each new version of the file. The recorded metadata stays as it was, so Save still reaches the external-change prompt before overwriting. - A document whose file is removed stays open with its content, clean or dirty, and Leafdown warns once. Save then reaches the missing-file prompt, which offers `Save as`. Leafdown does not follow an external rename to another path. A file that returns to the same path is checked like any change: one holding the recorded version ends the external change, and any other reloads or is recorded as for an edit. - Saving, `Save as`, and renaming the document or a folder containing it from the article navigator are Leafdown's own changes and never report an external change. @@ -646,7 +651,7 @@ File operations govern how Leafdown writes to disk and resolves conflicts or err - Empty files open as empty editable documents. - Files larger than 5 MB do not load. - A file that starts with a UTF-8, UTF-16 LE, or UTF-16 BE byte order mark (BOM) opens in the encoding the BOM names. Any other file opens as UTF-8 unless the user chooses another encoding. Leafdown does not guess an encoding. -- `Reopen with encoding` is a menu of encodings; choosing one reopens a file without a BOM in that encoding: UTF-8, Windows-1250 to Windows-1258, ISO-8859-2, ISO-8859-15, KOI8-R, KOI8-U, Shift_JIS, EUC-JP, GBK, GB18030, Big5, EUC-KR, or UTF-16 LE or UTF-16 BE without a BOM. ISO-8859-1 is offered as part of the Windows-1252 choice, because the Encoding Standard Leafdown follows decodes the two identically. The invalid encoding error offers the menu from its notification, which stays until the user chooses or dismisses it. The Edit menu and the status bar offer it as a submenu for an open saved document whose file has no BOM, marking the encoding the file was reopened in. A long menu scrolls. Reopening an edited document asks before discarding its changes. A BOM outranks a choice, so a file with one always opens in the encoding its BOM names. The choice applies to that document only and is not remembered, so reopening the file later, including from recent files, reads it as UTF-8 again; the user can choose again whenever the text looks wrong. +- `Reopen with encoding` is a menu of encodings; choosing one reopens a file without a BOM in that encoding: UTF-8, Windows-1250 to Windows-1258, ISO-8859-2, ISO-8859-15, KOI8-R, KOI8-U, Shift_JIS, EUC-JP, GBK, GB18030, Big5, EUC-KR, or UTF-16 LE or UTF-16 BE without a BOM. ISO-8859-1 is offered as part of the Windows-1252 choice, because the Encoding Standard Leafdown follows decodes the two identically. The invalid encoding error offers the menu from its notification, which stays until the user chooses or dismisses it. The Edit menu and the status bar offer it as a submenu for an open saved document whose file has no BOM, marking the encoding the file was reopened in. A long menu scrolls. Reopening reads the file again into the tab already holding it, and reopening an edited document asks before discarding its changes. A BOM outranks a choice, so a file with one always opens in the encoding its BOM names. The choice applies to that document only and is not remembered, so reopening the file later, including from recent files, reads it as UTF-8 again; the user can choose again whenever the text looks wrong. - A file opens in a chosen encoding only when encoding its decoded text again reproduces its bytes exactly. Several multibyte encodings read more than one byte sequence as the same character, so a save of such a file would rewrite bytes the user never edited; Leafdown instead shows an error saying the file cannot be preserved in that encoding, and offers choosing again. - A file whose bytes after any BOM, or in the chosen encoding, are not valid in that encoding, or whose text holds the NUL character U+0000, shows an invalid encoding error and does not open. Undecodable bytes are never replaced with U+FFFD. Refusing NUL keeps a UTF-16 file without a BOM, which can also be valid UTF-8, from opening as UTF-8 with a NUL between its characters. - Folder contexts of up to 10,000 articles across about 2,000 directories are supported. Larger folder contexts still open, but opening, refreshing after external changes, and sorting slow down as the number of directories grows. diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 217b4755..432087fa 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -249,6 +249,7 @@ const createWorkerContext = async (workerIndex: number): Promise }, { name: "diagnostics" }, { name: "document-lifecycle", recentFiles: [documentPath, documentScrollPath] }, + { name: "document-tabs", recentFiles: [calloutsPath, citationsPath, separatorPath] }, { name: "document-watcher", recentFiles: [documentWatcherPath] }, { name: "find-and-replace", recentFiles: [searchPath] }, { name: "keyboard-shortcuts" }, diff --git a/e2e/desktop/specs/block-selection.spec.ts b/e2e/desktop/specs/block-selection.spec.ts index 880a4344..6c43ed34 100644 --- a/e2e/desktop/specs/block-selection.spec.ts +++ b/e2e/desktop/specs/block-selection.spec.ts @@ -7,6 +7,7 @@ import { ARTIFACTS_DIR } from "../support/artifacts.js"; import { waitForDiagnosticRecord } from "../support/diagnostics.js"; import { getDesktopE2ERunContext } from "../support/runContext.js"; import { + closeAllDocuments, ctrlClickAt, dismissToasts, findMenuItem, @@ -15,7 +16,7 @@ import { } from "../support/ui.js"; const SURFACE_SELECTOR = - "[data-testid='document-surface-scroll-area'] [data-slot='scroll-area-viewport']"; + "[data-testid=active-document-host] [data-testid='document-surface-scroll-area'] [data-slot='scroll-area-viewport']"; interface GutterGeometry { barHeight: number; @@ -47,7 +48,11 @@ const handleGeometry = () => Array.from(document.querySelectorAll("[data-leafdown-block-handle]")).map( (handle) => { const pos = handle.dataset.leafdownBlockPos ?? ""; - const block = Array.from(document.querySelectorAll(".ProseMirror *")).find( + const block = Array.from( + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror *", + ), + ).find( (element) => element.pmViewDesc?.dom === element && element.pmViewDesc.node !== undefined && @@ -92,7 +97,11 @@ const getGeometryForText = async (text: string) => { if (!geometry) { await browser.execute((blockText) => { - Array.from(document.querySelectorAll(".ProseMirror *")) + Array.from( + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror *", + ), + ) .find( (element) => element.pmViewDesc?.dom === element && element.textContent?.trim() === blockText, @@ -112,7 +121,9 @@ const getGeometryForText = async (text: string) => { const moveToBlock = async (pos: string) => { const point = await browser.execute((blockPos) => { - const block = Array.from(document.querySelectorAll(".ProseMirror *")).find( + const block = Array.from( + document.querySelectorAll("[data-testid=active-document-host] .ProseMirror *"), + ).find( (element) => element.pmViewDesc?.dom === element && element.pmViewDesc.node !== undefined && @@ -133,7 +144,9 @@ const moveToBlock = async (pos: string) => { const listItemGap = (before: string, after: string) => browser.execute( (beforeText, afterText) => { - const items = Array.from(document.querySelectorAll(".ProseMirror li")); + const items = Array.from( + document.querySelectorAll("[data-testid=active-document-host] .ProseMirror li"), + ); const first = items.find((item) => item.textContent?.trim() === beforeText); const second = items.find((item) => item.textContent?.trim() === afterText); if (!first || !second) { @@ -149,7 +162,9 @@ const listItemsInView = () => browser.execute((surfaceSelector) => { const surface = document.querySelector(surfaceSelector)?.getBoundingClientRect(); if (!surface) throw new Error("Document view was not found."); - return Array.from(document.querySelectorAll(".ProseMirror li")).filter((item) => { + return Array.from( + document.querySelectorAll("[data-testid=active-document-host] .ProseMirror li"), + ).filter((item) => { const rect = item.getBoundingClientRect(); return rect.bottom > surface.top && rect.top < surface.bottom; }).length; @@ -159,7 +174,7 @@ const listItemsInView = () => const listItemTexts = () => browser.execute(() => Array.from( - document.querySelectorAll(".ProseMirror li"), + document.querySelectorAll("[data-testid=active-document-host] .ProseMirror li"), (item) => item.textContent?.trim() ?? "", ), ); @@ -249,7 +264,9 @@ const getGutterPointsAroundMenu = () => const dispatchEditorKey = (key: string, init: KeyboardEventInit = {}) => browser.execute( (eventKey, eventInit) => { - const editor = document.querySelector(".ProseMirror"); + const editor = document.querySelector( + "[data-testid=active-document-host] .ProseMirror", + ); if (!editor) throw new Error("Editor was not found."); @@ -270,7 +287,7 @@ const cutSelectedBlocks = () => browser.execute(() => { const clipboardData = new DataTransfer(); const event = new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData }); - document.querySelector(".ProseMirror")?.dispatchEvent(event); + document.querySelector("[data-testid=active-document-host] .ProseMirror")?.dispatchEvent(event); return { text: clipboardData.getData("text/plain"), html: clipboardData.getData("text/html"), @@ -307,10 +324,13 @@ describe("desktop block selection", () => { it("keeps Ctrl-click text as a caret and activates rendered links", async () => { const { modifierClick } = await getDesktopE2ERunContext(); await openRecentPath(modifierClick.path); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await dismissToasts(); - for (const selector of [".ProseMirror h1", ".ProseMirror > p"]) { + for (const selector of [ + "[data-testid=active-document-host] .ProseMirror h1", + "[data-testid=active-document-host] .ProseMirror > p", + ]) { const target = $(selector); await target.scrollIntoView(); const point = (await target.execute((node) => { @@ -326,7 +346,9 @@ describe("desktop block selection", () => { }; })) as { x: number; y: number }; await ctrlClickAt(point); - await expect($$(".ProseMirror-selectednode")).toBeElementsArrayOfSize(0); + await expect( + $$("[data-testid=active-document-host] .ProseMirror-selectednode"), + ).toBeElementsArrayOfSize(0); await expect($$(".leafdown-selected-block")).toBeElementsArrayOfSize(0); const caret = (await target.execute((node) => { const selection = window.getSelection(); @@ -346,7 +368,7 @@ describe("desktop block selection", () => { ["External guide", "https://example.com/leafdown-modifier-click"], ["Missing guide", "missing-modifier-click.md"], ]) { - const link = $(`.ProseMirror a[href="${href}"]`); + const link = $(`[data-testid=active-document-host] .ProseMirror a[href="${href}"]`); await link.scrollIntoView(); await ctrlClickAt(await getHandlePoint(link)); if (label === "External guide") { @@ -361,27 +383,36 @@ describe("desktop block selection", () => { await dismissToasts(); } await expect(link).toBeDisplayed(); - await expect($$(".ProseMirror-selectednode")).toBeElementsArrayOfSize(0); + await expect( + $$("[data-testid=active-document-host] .ProseMirror-selectednode"), + ).toBeElementsArrayOfSize(0); } - const atom = $(".ProseMirror hr"); + const atom = $("[data-testid=active-document-host] .ProseMirror hr"); await atom.scrollIntoView(); await ctrlClickAt(await getHandlePoint(atom)); await expect(atom).toHaveElementClass("ProseMirror-selectednode"); - const localLink = $('.ProseMirror a[href="block-selection.md"]'); + const localLink = $( + '[data-testid=active-document-host] .ProseMirror a[href="block-selection.md"]', + ); await localLink.scrollIntoView(); await ctrlClickAt(await getHandlePoint(localLink)); - await expect($(".ProseMirror h1")).toHaveText("Block selection fixture"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveText( + "Block selection fixture", + ); }); it("renders a definition list with a selectable gutter", async () => { const { definitionList } = await getDesktopE2ERunContext(); + await closeAllDocuments(); await openRecentPath(definitionList.path); await expect($('[data-slot="toast"]')).toBeDisplayed(); await dismissToasts(); - const list = $(".ProseMirror dl[data-type='definition-list']"); + const list = $( + "[data-testid=active-document-host] .ProseMirror dl[data-type='definition-list']", + ); await expect(list).toBeDisplayed(); await expect(list.$("dt")).toHaveText("Definition term"); await expect(list.$("dd")).toHaveText("Definition body"); @@ -407,8 +438,9 @@ describe("desktop block selection", () => { it("inserts a nested sibling from the moving gutter control and cancels without editing", async () => { const { blocks } = await getDesktopE2ERunContext(); const originalWindowSize = await browser.getWindowSize(); + await closeAllDocuments(); await openRecentPath(blocks.path); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await expect($('[data-slot="toast"]')).toBeDisplayed(); await dismissToasts(); const target = await getGeometryForText("Nested first"); @@ -465,7 +497,9 @@ describe("desktop block selection", () => { await browser.setWindowSize(640, 720); await browser.execute(() => { - const editor = document.querySelector(".ProseMirror"); + const editor = document.querySelector( + "[data-testid=active-document-host] .ProseMirror", + ); if (!editor) throw new Error("Editor was not found."); editor.dir = "rtl"; window.dispatchEvent(new Event("resize")); @@ -490,7 +524,11 @@ describe("desktop block selection", () => { .perform(); await expect(button).toBeDisplayed(); const rtlPlacement = await browser.execute((pos) => { - const block = Array.from(document.querySelectorAll(".ProseMirror *")).find( + const block = Array.from( + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror *", + ), + ).find( (element) => element.pmViewDesc?.dom === element && element.pmViewDesc.node !== undefined && @@ -507,7 +545,9 @@ describe("desktop block selection", () => { }, target.pos); expect(rtlPlacement.gutterLeft).toBeGreaterThanOrEqual(rtlPlacement.blockRight - 0.5); await browser.execute(() => { - document.querySelector(".ProseMirror")?.removeAttribute("dir"); + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.removeAttribute("dir"); window.dispatchEvent(new Event("resize")); }); await browser.waitUntil(() => @@ -548,13 +588,14 @@ describe("desktop block selection", () => { it("leaves the gutter under an open menu to the menu and restores it after dismissal", async () => { const { blocks } = await getDesktopE2ERunContext(); + await closeAllDocuments(); await openRecentPath(blocks.path); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await dismissToasts(); const button = $("[data-leafdown-block-insert]"); const indicator = $(".leafdown-block-insertion-indicator"); const textBefore = await browser.execute( - () => document.querySelector(".ProseMirror")?.textContent, + () => document.querySelector("[data-testid=active-document-host] .ProseMirror")?.textContent, ); const sidebarHidden = await setSidebarVisible(false); @@ -595,9 +636,12 @@ describe("desktop block selection", () => { await expect($("[data-testid='editor-block-insertion-menu']")).toBeDisplayed(); await browser.keys(Key.Escape); await expect($("[data-testid='editor-block-insertion-menu']")).not.toExist(); - expect(await browser.execute(() => document.querySelector(".ProseMirror")?.textContent)).toBe( - textBefore, - ); + expect( + await browser.execute( + () => + document.querySelector("[data-testid=active-document-host] .ProseMirror")?.textContent, + ), + ).toBe(textBefore); } finally { if (await $('[data-slot="menubar-content"]').isExisting()) { await browser.keys(Key.Escape); @@ -608,10 +652,9 @@ describe("desktop block selection", () => { it("renders and operates hierarchical gutters in the assembled app", async () => { const { blocks } = await getDesktopE2ERunContext(); + await closeAllDocuments(); await openRecentPath(blocks.path); - await expect($("aria/Unsaved changes")).toBeDisplayed(); - await $("aria/Discard changes").click(); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await expect($('[data-slot="toast"]')).toBeDisplayed(); await dismissToasts(); @@ -628,9 +671,11 @@ describe("desktop block selection", () => { expect(Math.abs((await listItemGap(before, after)) - 8)).toBeLessThan(0.5); } const paragraphToListGap = await browser.execute(() => { - const paragraph = Array.from(document.querySelectorAll(".ProseMirror > p")).find( - (node) => node.textContent?.startsWith("Root paragraph with"), - ); + const paragraph = Array.from( + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror > p", + ), + ).find((node) => node.textContent?.startsWith("Root paragraph with")); const list = paragraph?.nextElementSibling; if (!paragraph || !list || list.tagName !== "UL") { throw new Error("Root paragraph and adjacent list were not found."); @@ -855,17 +900,26 @@ describe("desktop block selection", () => { expect(cutPayload.html).toContain("Nested first"); await browser.waitUntil(async () => browser.execute( - () => !document.querySelector(".ProseMirror")?.textContent?.includes("Nested first"), + () => + !document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.textContent?.includes("Nested first"), ), ); - await expect($(".ProseMirror")).toHaveText(expect.stringContaining("Parent item")); - await expect($(".ProseMirror")).not.toHaveText(expect.stringContaining("Nested second")); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining("Parent item"), + ); + await expect($("[data-testid=active-document-host] .ProseMirror")).not.toHaveText( + expect.stringContaining("Nested second"), + ); await browser.saveScreenshot(path.join(ARTIFACTS_DIR, "block-selection-cut.png")); await browser.keys([Key.Ctrl, "z", Key.NULL]); await browser.waitUntil(async () => browser.execute(() => - document.querySelector(".ProseMirror")?.textContent?.includes("Nested second"), + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.textContent?.includes("Nested second"), ), ); await expect($$(".leafdown-selected-block")).toBeElementsArrayOfSize(2); @@ -874,11 +928,15 @@ describe("desktop block selection", () => { clipboardData.setData("text/plain", text); clipboardData.setData("text/html", html); const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData }); - document.querySelector(".ProseMirror")?.dispatchEvent(event); + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.dispatchEvent(event); }, cutPayload); await browser.waitUntil(async () => browser.execute(() => - document.querySelector(".ProseMirror")?.textContent?.includes("Nested first"), + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.textContent?.includes("Nested first"), ), ); const pastedItems = await listItemTexts(); @@ -890,13 +948,20 @@ describe("desktop block selection", () => { expect(await dispatchEditorKey("Delete")).toBe(true); await browser.waitUntil(async () => browser.execute( - () => !document.querySelector(".ProseMirror")?.textContent?.includes("Nested first"), + () => + !document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.textContent?.includes("Nested first"), ), ); - await expect($(".ProseMirror")).toHaveText(expect.stringContaining("Nested second")); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining("Nested second"), + ); await browser.saveScreenshot(path.join(ARTIFACTS_DIR, "block-selection-deleted.png")); await browser.keys([Key.Ctrl, "z", Key.NULL]); - await expect($(".ProseMirror")).toHaveText(expect.stringContaining("Nested first")); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining("Nested first"), + ); const crossParentStart = $(await getHandleSelectorForText("Second hyphen item")); const crossParentEnd = $(await getHandleSelectorForText("Plus item starts another list")); @@ -908,16 +973,20 @@ describe("desktop block selection", () => { expect(crossParentPayload.text).toContain("Plus item starts another list"); expect(crossParentPayload.text).not.toContain("Hyphen item\n"); expect(crossParentPayload.text).not.toContain("Asterisk item starts another list"); - await expect($(".ProseMirror")).toHaveText(expect.stringContaining("Hyphen item")); - await expect($(".ProseMirror")).toHaveText( + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining("Hyphen item"), + ); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( expect.stringContaining("Asterisk item starts another list"), ); - await expect($(".ProseMirror")).not.toHaveText(expect.stringContaining("Second hyphen item")); - await expect($(".ProseMirror")).not.toHaveText( + await expect($("[data-testid=active-document-host] .ProseMirror")).not.toHaveText( + expect.stringContaining("Second hyphen item"), + ); + await expect($("[data-testid=active-document-host] .ProseMirror")).not.toHaveText( expect.stringContaining("Plus item starts another list"), ); await browser.keys([Key.Ctrl, "z", Key.NULL]); - await expect($(".ProseMirror")).toHaveText( + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( expect.stringContaining("Plus item starts another list"), ); }); @@ -927,7 +996,9 @@ describe("desktop block selection", () => { await browser.setWindowSize(640, 400); await browser.keys(Key.Escape); await expect($("[data-testid='editor-context-popup']")).not.toExist(); - await expect($(".ProseMirror")).toHaveText(expect.stringContaining("Quote second")); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining("Quote second"), + ); for (const [text, edge] of [ ["Parent itemNested firstNested second", "top"], @@ -937,7 +1008,11 @@ describe("desktop block selection", () => { await browser.execute( (blockPos, side, surfaceSelector) => { const viewport = document.querySelector(surfaceSelector); - const block = Array.from(document.querySelectorAll(".ProseMirror *")).find( + const block = Array.from( + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror *", + ), + ).find( (element) => element.pmViewDesc?.dom === element && element.pmViewDesc.node !== undefined && @@ -960,7 +1035,9 @@ describe("desktop block selection", () => { (blockPos, side, surfaceSelector) => { const viewport = document.querySelector(surfaceSelector); const block = Array.from( - document.querySelectorAll(".ProseMirror *"), + document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror *", + ), ).find( (element) => element.pmViewDesc?.dom === element && diff --git a/e2e/desktop/specs/callouts.spec.ts b/e2e/desktop/specs/callouts.spec.ts index d5c665bf..1488aa2a 100644 --- a/e2e/desktop/specs/callouts.spec.ts +++ b/e2e/desktop/specs/callouts.spec.ts @@ -60,7 +60,9 @@ describe("desktop callouts", () => { const paragraph = document.querySelector( '.leafdown-callout[data-dialect="docusaurus"] .leafdown-callout-body p', )!; - const editor = document.querySelector(".ProseMirror")!; + const editor = document.querySelector( + "[data-testid=active-document-host] .ProseMirror", + )!; const range = document.createRange(); range.selectNodeContents(paragraph); range.collapse(false); @@ -69,7 +71,9 @@ describe("desktop callouts", () => { selection.addRange(range); editor.focus(); }); - await $('[contenteditable="true"]').addValue(" Edited body."); + await $('[data-testid=active-document-host] [contenteditable="true"]').addValue( + " Edited body.", + ); await expect($(`${docusaurus} .leafdown-callout-body`)).toHaveText( "Docusaurus body. Edited body.", ); diff --git a/e2e/desktop/specs/citations.spec.ts b/e2e/desktop/specs/citations.spec.ts index 1954d73b..fddfe040 100644 --- a/e2e/desktop/specs/citations.spec.ts +++ b/e2e/desktop/specs/citations.spec.ts @@ -51,7 +51,7 @@ const placeCaretBeside = (index: number, side: "before" | "after") => const range = document.createRange(); range.setStart(neighbour, edge === "before" ? neighbour.textContent!.length : 0); range.collapse(true); - node.closest(".ProseMirror")!.focus(); + node.closest("[data-testid=active-document-host] .ProseMirror")!.focus(); const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); @@ -63,7 +63,9 @@ const placeCaretBeside = (index: number, side: "before" | "after") => const leaveProjection = () => browser.execute(() => { - const paragraph = document.querySelectorAll(".ProseMirror > p")[1]; + const paragraph = document.querySelectorAll( + "[data-testid=active-document-host] .ProseMirror > p", + )[1]; const range = document.createRange(); range.selectNodeContents(paragraph); range.collapse(false); @@ -107,7 +109,9 @@ describe("desktop citations", () => { whiteSpace: "pre-wrap", }, ]); - expect(await $(".ProseMirror").getText()).toContain("Literal [@doe2026] stays text."); + expect(await $("[data-testid=active-document-host] .ProseMirror").getText()).toContain( + "Literal [@doe2026] stays text.", + ); }); it("enters a group from either side and from a press, and restores it untouched", async () => { @@ -134,7 +138,7 @@ describe("desktop citations", () => { await pressCitation(0); await waitForProjection(GROUP); - await $(".ProseMirror").addValue("z"); + await $("[data-testid=active-document-host] .ProseMirror").addValue("z"); await waitForProjection("[zsee @doe2026, pp. 4-6]"); await save(); const edited = original.replace(GROUP, "[zsee @doe2026, pp. 4-6]"); diff --git a/e2e/desktop/specs/command-palette.spec.ts b/e2e/desktop/specs/command-palette.spec.ts index 85ba7738..b9eb0683 100644 --- a/e2e/desktop/specs/command-palette.spec.ts +++ b/e2e/desktop/specs/command-palette.spec.ts @@ -26,7 +26,12 @@ const readSelection = () => browser.execute(() => document.getSelection()?.toStr const waitForEditorFocus = (afterCommand: string) => browser.waitUntil( - () => browser.execute(() => document.activeElement?.matches(".ProseMirror") ?? false), + () => + browser.execute( + () => + document.activeElement?.matches("[data-testid=active-document-host] .ProseMirror") ?? + false, + ), { timeoutMsg: `Editor focus was not kept after ${afterCommand}.` }, ); @@ -34,13 +39,17 @@ describe("desktop command palette", () => { it("preserves editor state, exposes unavailable commands, and transfers focus to a chosen dialog", async () => { const { search } = await getDesktopE2ERunContext(); await openRecentPath(search.path); - await expect($(".ProseMirror")).toBeDisplayed(); - await $(".ProseMirror p").click(); - await browser.execute(() => document.querySelector(".ProseMirror")?.focus()); - await expect($(".ProseMirror")).toBeFocused(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); + await $("[data-testid=active-document-host] .ProseMirror p").click(); + await browser.execute(() => + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.focus(), + ); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeFocused(); const before = await browser.execute(() => ({ - content: document.querySelector(".ProseMirror")?.innerHTML, + content: document.querySelector("[data-testid=active-document-host] .ProseMirror")?.innerHTML, selection: document.getSelection()?.anchorOffset, })); @@ -59,13 +68,18 @@ describe("desktop command palette", () => { await pressKey("Escape"); await expect(query()).not.toExist(); await browser.waitUntil( - () => browser.execute(() => document.activeElement?.matches(".ProseMirror") ?? false), + () => + browser.execute( + () => + document.activeElement?.matches("[data-testid=active-document-host] .ProseMirror") ?? + false, + ), { timeoutMsg: `Editor focus was not restored after dismissing the palette: ${await browser.execute(() => document.activeElement?.outerHTML.slice(0, 200))}`, }, ); const after = await browser.execute(() => ({ - content: document.querySelector(".ProseMirror")?.innerHTML, + content: document.querySelector("[data-testid=active-document-host] .ProseMirror")?.innerHTML, selection: document.getSelection()?.anchorOffset, })); expect(after).toEqual(before); @@ -90,8 +104,12 @@ describe("desktop command palette", () => { const originalSize = await browser.getWindowSize(); try { await browser.setWindowSize(640, 600); - await $(".ProseMirror p").click(); - await browser.execute(() => document.querySelector(".ProseMirror")?.focus()); + await $("[data-testid=active-document-host] .ProseMirror p").click(); + await browser.execute(() => + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.focus(), + ); expect(await pressKey("p", { ctrlKey: true, shiftKey: true })).toBe(true); await expect(query()).toBeFocused(); const resultsViewport = $('[data-slot="dialog-content"] [data-slot="scroll-area-viewport"]'); @@ -169,7 +187,11 @@ describe("desktop command palette", () => { }); it("dispatches an editor command through the palette", async () => { - await browser.execute(() => document.querySelector(".ProseMirror")?.focus()); + await browser.execute(() => + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.focus(), + ); expect(await pressKey("p", { ctrlKey: true, shiftKey: true })).toBe(true); await expect(query()).toBeFocused(); await query().setValue("select all"); @@ -184,12 +206,14 @@ describe("desktop command palette", () => { const sentence = "The first lantern hangs by the door."; const placeCaretInSentence = () => browser.execute((text) => { - const paragraph = [...document.querySelectorAll(".ProseMirror p")].find( - (element) => element.textContent === text, - ); + const paragraph = [ + ...document.querySelectorAll("[data-testid=active-document-host] .ProseMirror p"), + ].find((element) => element.textContent === text); const node = paragraph?.firstChild; if (!node) throw new Error("Sentence paragraph was not found."); - document.querySelector(".ProseMirror")?.focus(); + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.focus(); document.getSelection()?.collapse(node, "The fir".length); }, sentence); @@ -214,7 +238,9 @@ describe("desktop command palette", () => { () => browser.execute( (text) => - ![...document.querySelectorAll(".ProseMirror p")].some((p) => p.textContent === text), + ![ + ...document.querySelectorAll("[data-testid=active-document-host] .ProseMirror p"), + ].some((p) => p.textContent === text), sentence, ), { timeoutMsg: "Delete sentence left the sentence in place." }, @@ -226,7 +252,9 @@ describe("desktop command palette", () => { () => browser.execute( (text) => - [...document.querySelectorAll(".ProseMirror p")].some((p) => p.textContent === text), + [ + ...document.querySelectorAll("[data-testid=active-document-host] .ProseMirror p"), + ].some((p) => p.textContent === text), sentence, ), { timeoutMsg: "Undo did not restore the deleted sentence." }, diff --git a/e2e/desktop/specs/document-lifecycle.spec.ts b/e2e/desktop/specs/document-lifecycle.spec.ts index 05a29bac..a7b192f5 100644 --- a/e2e/desktop/specs/document-lifecycle.spec.ts +++ b/e2e/desktop/specs/document-lifecycle.spec.ts @@ -5,18 +5,24 @@ import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; import { + closeAllDocuments, + countDocumentTabs, + findDocumentTab, findTreeItem, getSaveMenuItem, openRecentPath, selectFileMenuItem, } from "../support/ui.js"; -const VIEWPORT = '[data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]'; +const VIEWPORT = + '[data-testid=active-document-host] [data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]'; const viewportGeometry = () => browser.execute((selector) => { const viewport = document.querySelector(selector)!; - const firstBlock = document.querySelector(".ProseMirror > :first-child")!; + const firstBlock = document.querySelector( + "[data-testid=active-document-host] .ProseMirror > :first-child", + )!; const viewportRect = viewport.getBoundingClientRect(); const blockRect = firstBlock.getBoundingClientRect(); return { @@ -38,13 +44,24 @@ const scrollDocument = async () => { }; const expectDocumentAtTop = async (marker: string) => { - await expect($(".ProseMirror")).toHaveText(expect.stringContaining(marker)); + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining(marker), + ); await browser.waitUntil(async () => (await viewportGeometry()).firstBlockVisible, { - timeoutMsg: "The replacement document's first block was outside its viewport.", + timeoutMsg: "The newly opened document's first block was outside its viewport.", }); expect((await viewportGeometry()).scrollTop).toBe(0); }; +const expectDocumentScrolled = async (marker: string) => { + await expect($("[data-testid=active-document-host] .ProseMirror")).toHaveText( + expect.stringContaining(marker), + ); + await browser.waitUntil(async () => (await viewportGeometry()).scrollTop === 800, { + timeoutMsg: "The tab did not keep the place it was scrolled to.", + }); +}; + const selectedHeadingGeometry = () => browser.execute((selector) => { const viewport = document.querySelector(selector)!; @@ -60,7 +77,7 @@ const selectedHeadingGeometry = () => }, VIEWPORT); describe("desktop document lifecycle", () => { - it("starts replacement documents at the top and preserves explicit navigation and reloads", async () => { + it("starts newly opened documents at the top and keeps each tab where it was left", async () => { const { document: fixture } = await getDesktopE2ERunContext(); const replacementPath = path.join(path.dirname(fixture.path), "viewport-replacement.md"); const filler = Array.from( @@ -81,27 +98,25 @@ describe("desktop document lifecycle", () => { await (await findTreeItem("viewport-replacement.md")).click(); await expectDocumentAtTop("Second viewport document"); expect((await viewportGeometry()).overflow).toBeGreaterThan(800); - await scrollDocument(); + await openRecentPath(fixture.scrollPath); - await expectDocumentAtTop("First viewport document"); - await scrollDocument(); + await expectDocumentScrolled("First viewport document"); await (await findTreeItem("document-lifecycle.md")).click(); await expectDocumentAtTop(fixture.initialMarker); + expect(await countDocumentTabs()).toBe(3); - await openRecentPath(replacementPath); - await expectDocumentAtTop("Second viewport document"); - await scrollDocument(); - await openRecentPath(fixture.path); - await expectDocumentAtTop(fixture.initialMarker); - - await openRecentPath(replacementPath); - await expectDocumentAtTop("Second viewport document"); - await $('[data-type="wiki-link"]').execute((target) => { + await (await findDocumentTab("viewport-replacement.md")).click(); + await expectDocumentScrolled("Second viewport document"); + await $("[data-testid=active-document-host] [data-type='wiki-link']").execute((target) => { target.dispatchEvent( new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ctrlKey: true }), ); }); + await expect(await findDocumentTab("document-scroll.md")).toHaveAttribute( + "aria-selected", + "true", + ); await browser.waitUntil( async () => { const heading = await selectedHeadingGeometry(); @@ -111,6 +126,7 @@ describe("desktop document lifecycle", () => { ); expect(await selectedHeadingGeometry()).toMatchObject({ text: "Scroll target", visible: true }); expect((await selectedHeadingGeometry())!.scrollTop).toBeGreaterThan(800); + expect(await countDocumentTabs()).toBe(3); await writeFile(fixture.scrollPath, `${original}\nReloaded viewport marker.\n`); await browser.waitUntil( @@ -118,7 +134,7 @@ describe("desktop document lifecycle", () => { browser.execute( () => document - .querySelector(".ProseMirror") + .querySelector("[data-testid=active-document-host] .ProseMirror") ?.textContent?.includes("Reloaded viewport marker.") ?? false, ), { timeoutMsg: "The external reload did not replace the editor's text." }, @@ -128,7 +144,7 @@ describe("desktop document lifecycle", () => { }); expect(await selectedHeadingGeometry()).toMatchObject({ text: "Scroll target", visible: true }); expect((await selectedHeadingGeometry())!.scrollTop).toBeGreaterThan(800); - await selectFileMenuItem("Close document"); + await closeAllDocuments(); }); it("opens, edits, saves, and reopens a fixture through real IPC", async () => { @@ -136,7 +152,7 @@ describe("desktop document lifecycle", () => { await openRecentPath(document.path); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(editor).toBeDisplayed(); await expect(editor).toHaveText(expect.stringContaining(document.initialMarker)); @@ -168,10 +184,10 @@ describe("desktop document lifecycle", () => { await browser.keys("Escape"); await selectFileMenuItem("Close document"); - await expect($('[contenteditable="true"]')).not.toExist(); + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).not.toExist(); await openRecentPath(document.path); - const reopenedEditor = $('[contenteditable="true"]'); + const reopenedEditor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(reopenedEditor).toHaveText(expect.stringContaining(document.savedMarker)); await reopenedEditor.click(); diff --git a/e2e/desktop/specs/document-tabs.spec.ts b/e2e/desktop/specs/document-tabs.spec.ts new file mode 100644 index 00000000..710dfd86 --- /dev/null +++ b/e2e/desktop/specs/document-tabs.spec.ts @@ -0,0 +1,103 @@ +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, +} from "../support/ui.js"; + +const ACTIVE_EDITOR = "[data-testid=active-document-host] .ProseMirror"; + +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 Close window.`); +}; + +const appendToActiveEditor = async (text: string) => { + const editor = $(ACTIVE_EDITOR); + + await editor.click(); + await editor.addValue(text); + await expect(editor).toHaveText(expect.stringContaining(text)); +}; + +describe("desktop document tabs", () => { + it("keeps each tab's unsaved edits and closes the window through one unsaved-documents dialog", async () => { + const diagnostics = await getDiagnosticsSummary(); + const processId = Number(diagnostics.runId.slice(diagnostics.runId.lastIndexOf("-") + 1)); + const { callouts, citations, separator } = await getDesktopE2ERunContext(); + const calloutsName = path.basename(callouts.path); + const citationsName = path.basename(citations.path); + const separatorName = path.basename(separator.path); + + // A newer open cancels one still reading its file, so each tab appears before the next open. + for (const filePath of [callouts.path, citations.path, separator.path]) { + await openRecentPath(filePath); + await findDocumentTab(path.basename(filePath)); + } + expect(await countDocumentTabs()).toBe(3); + await dismissToasts(); + + await expect(await findDocumentTab(separatorName)).toHaveAttribute("aria-selected", "true"); + await appendToActiveEditor("Separator tab edit"); + + await (await findDocumentTab(calloutsName)).click(); + await expect(await findDocumentTab(calloutsName)).toHaveAttribute("aria-selected", "true"); + await expect($(ACTIVE_EDITOR)).not.toHaveText(expect.stringContaining("Separator tab edit")); + await appendToActiveEditor("Callouts tab edit"); + + await (await findDocumentTab(separatorName)).click(); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Separator tab edit")); + await expect($(ACTIVE_EDITOR)).not.toHaveText(expect.stringContaining("Callouts tab edit")); + await expect(await findDocumentTab(calloutsName)).toHaveAttribute( + "aria-label", + `${calloutsName}, unsaved`, + ); + await expect(await findDocumentTab(citationsName)).toHaveAttribute("aria-label", citationsName); + + await (await findDocumentTab(calloutsName)).click(); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Callouts tab edit")); + + const closeControl = $("aria/Close window"); + await closeControl.click(); + const prompt = $('[data-slot="dialog-content"][data-open]'); + await expect(prompt).toBeDisplayed(); + await expect(prompt).toHaveText(expect.stringContaining("2 documents have unsaved changes")); + await expect(prompt).toHaveText(expect.stringContaining(calloutsName)); + await expect(prompt).toHaveText(expect.stringContaining(separatorName)); + await browser.keys(Key.Escape); + await expect(prompt).not.toExist(); + expect(await countDocumentTabs()).toBe(3); + expect(isProcessRunning(processId)).toBe(true); + + await closeControl.click(); + await expect($('[data-slot="dialog-content"][data-open]')).toBeDisplayed(); + await $("aria/Discard all").click(); + + await waitForProcessExit(processId); + }); +}); diff --git a/e2e/desktop/specs/document-watcher.spec.ts b/e2e/desktop/specs/document-watcher.spec.ts index 232d9a24..f94807ce 100644 --- a/e2e/desktop/specs/document-watcher.spec.ts +++ b/e2e/desktop/specs/document-watcher.spec.ts @@ -8,7 +8,7 @@ import { dismissToasts, getSaveMenuItem, openRecentPath } from "../support/ui.js const WATCHER_SETTLE_MS = 1_000; -const editor = () => $('[contenteditable="true"]'); +const editor = () => $('[data-testid=active-document-host] [contenteditable="true"]'); // A reload remounts the editor, and an element matched before it stays detached, so each poll // queries the editor again. diff --git a/e2e/desktop/specs/find-and-replace.spec.ts b/e2e/desktop/specs/find-and-replace.spec.ts index 8bd02d0c..1c7fbb50 100644 --- a/e2e/desktop/specs/find-and-replace.spec.ts +++ b/e2e/desktop/specs/find-and-replace.spec.ts @@ -35,7 +35,7 @@ const pressKey = (key: string, init: KeyboardEventInit = {}) => ); const focusEditorAtStart = async () => { - await $(".ProseMirror p").click(); + await $("[data-testid=active-document-host] .ProseMirror p").click(); await pressKey("Home", { ctrlKey: true }); }; @@ -44,7 +44,10 @@ const isFocused = (selector: string) => const firstParagraphTop = () => browser.execute( - () => document.querySelector(".ProseMirror p")?.getBoundingClientRect().top ?? Number.NaN, + () => + document + .querySelector("[data-testid=active-document-host] .ProseMirror p") + ?.getBoundingClientRect().top ?? Number.NaN, ); const getSelectionText = () => browser.execute(() => document.getSelection()?.toString() ?? ""); @@ -62,9 +65,9 @@ const getOccurrenceTexts = () => const selectWordInParagraph = (paragraphStart: string, word: string) => browser.execute( (start, target) => { - const paragraph = Array.from(document.querySelectorAll(".ProseMirror p")).find((element) => - element.textContent?.startsWith(start), - ); + const paragraph = Array.from( + document.querySelectorAll("[data-testid=active-document-host] .ProseMirror p"), + ).find((element) => element.textContent?.startsWith(start)); if (!paragraph) { return false; @@ -102,7 +105,7 @@ const getCurrentMatchPlacement = () => browser.execute(() => { const current = document.querySelector(".leafdown-search-match--current"); const viewport = document.querySelector( - '[data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', + '[data-testid=active-document-host] [data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', ); const searchPanel = document.querySelector('[data-testid="editor-search-panel"]'); @@ -141,7 +144,7 @@ describe("desktop find and replace", () => { const { search } = await getDesktopE2ERunContext(); await openRecentPath(search.path); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await dismissToasts(); await focusEditorAtStart(); @@ -182,7 +185,7 @@ describe("desktop find and replace", () => { await pressKey("Escape"); await expect(panel()).not.toExist(); - expect(await isFocused(".ProseMirror")).toBe(true); + expect(await isFocused("[data-testid=active-document-host] .ProseMirror")).toBe(true); expect(await getSelectionText()).toBe("lantern"); expect(await getSelectedParagraphText()).toContain("**lantern**"); await expect(documentState()).not.toExist(); @@ -265,7 +268,7 @@ describe("desktop find and replace", () => { expect( await browser.execute( () => - Array.from(document.querySelectorAll(".ProseMirror p")) + Array.from(document.querySelectorAll("[data-testid=active-document-host] .ProseMirror p")) .find((element) => element.textContent?.startsWith("The first")) ?.querySelectorAll(".leafdown-selection-occurrence").length, ), @@ -300,20 +303,29 @@ describe("desktop find and replace", () => { await replacementField().setValue("beacon"); await pressKey("Enter"); await expect(results()).toHaveText("1 of 4"); - await expect($(".ProseMirror h1")).toHaveText("beacon notes"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveText( + "beacon notes", + ); await expect(documentState()).toHaveText("Unsaved"); await $('[data-testid="editor-search-panel"] button[aria-label="Replace all"]').click(); await expect(results()).toHaveText("No results"); - expect((await $(".ProseMirror").getText()).toLowerCase()).not.toContain("lantern"); - await expect($(".ProseMirror a")).toHaveText("beacon guide"); + expect( + (await $("[data-testid=active-document-host] .ProseMirror").getText()).toLowerCase(), + ).not.toContain("lantern"); + await expect($("[data-testid=active-document-host] .ProseMirror a")).toHaveText("beacon guide"); await pressKey("Escape"); await pressKey("z", { ctrlKey: true }); await browser.waitUntil( - async () => (await $(".ProseMirror").getText()).includes("last lantern stands"), + async () => + (await $("[data-testid=active-document-host] .ProseMirror").getText()).includes( + "last lantern stands", + ), { timeoutMsg: "Undo did not restore every replaced match in one step." }, ); - await expect($(".ProseMirror h1")).toHaveText("beacon notes"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveText( + "beacon notes", + ); }); }); diff --git a/e2e/desktop/specs/folder-actions.spec.ts b/e2e/desktop/specs/folder-actions.spec.ts index 26d00038..3cf19c41 100644 --- a/e2e/desktop/specs/folder-actions.spec.ts +++ b/e2e/desktop/specs/folder-actions.spec.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { findMenuItem, findTreeItem, openRecentPath } from "../support/ui.js"; +import { countDocumentTabs, findMenuItem, findTreeItem, openRecentPath } from "../support/ui.js"; const execFileAsync = promisify(execFile); const WATCHER_SETTLE_MS = 1_000; @@ -135,7 +135,7 @@ describe("desktop article navigator actions", () => { const roadmapPath = path.join(folder, "roadmap.md"); await openRecentPath(folder); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining("Actions fixture marker."), ); @@ -205,7 +205,13 @@ describe("desktop article navigator actions", () => { await $("aria/Move to Recycle Bin").click(); await waitForPath(roadmapPath, false); - await expect($('[contenteditable="true"]')).not.toExist(); + // The trashed file's tab closes, leaving the folder's index file open in its own tab. + await browser.waitUntil(async () => (await countDocumentTabs()) === 1, { + timeoutMsg: "Trashing the open file did not close its tab.", + }); + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( + expect.stringContaining("Actions fixture marker."), + ); await browser.pause(WATCHER_SETTLE_MS); expect(await treeItemNames()).toEqual(["notes", "archive", "idea.md", "readme.md"]); diff --git a/e2e/desktop/specs/folder-search.spec.ts b/e2e/desktop/specs/folder-search.spec.ts index 75a39e8a..ddd27f16 100644 --- a/e2e/desktop/specs/folder-search.spec.ts +++ b/e2e/desktop/specs/folder-search.spec.ts @@ -4,7 +4,15 @@ import path from "node:path"; import { ARTIFACTS_DIR } from "../support/artifacts.js"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { dismissToasts, findMenuItem, openMenu, openRecentPath } from "../support/ui.js"; +import { + countDocumentTabs, + dismissToasts, + findDocumentTab, + findMenuItem, + openMenu, + openRecentPath, + selectFileMenuItem, +} from "../support/ui.js"; const panel = () => $('[data-testid="folder-search-panel"]'); const queryField = () => @@ -60,7 +68,7 @@ const getChosenMatch = () => browser.execute(() => { const chosen = Array.from(document.querySelectorAll(".leafdown-search-match--chosen")); const viewport = document.querySelector( - '[data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', + '[data-testid=active-document-host] [data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', ); if (chosen.length === 0 || !viewport) { @@ -151,7 +159,7 @@ describe("desktop folder search", () => { await pressKey("ArrowDown"); await pressKey("Enter"); - await expect($('[contenteditable="true"]')).toBeDisplayed(); + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toBeDisplayed(); const far = await waitForChosenMatch("The last lantern stands at the end."); @@ -165,55 +173,59 @@ describe("desktop folder search", () => { await pressKey("Escape"); await expect(panel()).not.toExist(); await expect($('[role="tree"][aria-label="Articles"]')).toBeDisplayed(); - expect(await isFocused(".ProseMirror")).toBe(true); + expect(await isFocused("[data-testid=active-document-host] .ProseMirror")).toBe(true); expect(await browser.execute(() => document.getSelection()?.toString() ?? "")).toBe("lantern"); }); - it("keeps unsaved edits and the results when switching documents is declined", async () => { + it("opens a match in a new tab, keeping and searching another tab's unsaved edits", async () => { const { folderSearch } = await getDesktopE2ERunContext(); const farMarkdown = await readFile(folderSearch.farPath, "utf8"); + const farName = path.basename(folderSearch.farPath); // Where the typed text lands depends on the harness, so the editor's own text decides how many // matches the unsaved document holds. - await $('[contenteditable="true"]').addValue("Unsaved "); + await $('[data-testid=active-document-host] [contenteditable="true"]').addValue("Unsaved "); await expect(documentState()).toHaveText("Unsaved"); - const editorText = await $('[contenteditable="true"]').getText(); + const editorText = await $( + '[data-testid=active-document-host] [contenteditable="true"]', + ).getText(); const unsavedMatches = editorText.match(/lantern/giu)?.length ?? 0; + const unsavedOutcome = `${unsavedMatches + 2} results in 2 files`; await findInFolder(); await expect(panel()).toBeDisplayed(); await expect(queryField()).toHaveValue("lantern"); - await browser.waitUntil( - async () => (await status().getText()) === `${unsavedMatches + 2} results in 2 files`, - { timeoutMsg: "The folder search did not count the unsaved document's text." }, - ); + await browser.waitUntil(async () => (await status().getText()) === unsavedOutcome, { + timeoutMsg: "The folder search did not count the unsaved document's text.", + }); await pressKey("ArrowDown"); await pressKey("End"); expect(await focusedRowLabel()).toContain("in two pieces."); - - const rowCount = await resultRows().length; - await pressKey("Enter"); - await expect(unsavedPrompt()).toBeDisplayed(); - await browser.keys("Escape"); - await expect(unsavedPrompt()).not.toExist(); - - await expect(documentState()).toHaveText("Unsaved"); - expect(await $('[contenteditable="true"]').getText()).toBe(editorText); - expect(await resultRows().length).toBe(rowCount); - - await (await resultRows().getElements())[rowCount - 1].click(); - await expect(unsavedPrompt()).toBeDisplayed(); - await $("aria/Discard changes").click(); const pieces = await waitForChosenMatch("in two pieces."); expect(pieces.text).toBe("lantern"); + await expect(unsavedPrompt()).not.toExist(); await expect(documentState()).not.toExist(); + expect(await countDocumentTabs()).toBe(2); + await expect(await findDocumentTab(farName)).toHaveAttribute( + "aria-label", + `${farName}, unsaved`, + ); + expect(await status().getText()).toBe(unsavedOutcome); expect(await readFile(folderSearch.farPath, "utf8")).toBe(farMarkdown); + await (await findDocumentTab(farName)).click(); + await selectFileMenuItem("Close document"); + await expect(unsavedPrompt()).toBeDisplayed(); + await $("aria/Discard changes").click(); + await browser.waitUntil(async () => (await countDocumentTabs()) === 1, { + timeoutMsg: "Discarding did not close the edited tab.", + }); + // The discarded document is read from its file again, which adds back the match the edit took. await browser.waitUntil(async () => (await status().getText()) === "4 results in 2 files", { timeoutMsg: "The folder search did not read the discarded document from its file again.", @@ -308,7 +320,9 @@ describe("desktop folder search", () => { farMarkdown.replaceAll("lantern", "lamp"), ); await expect(documentState()).not.toExist(); - expect(await $('[contenteditable="true"]').getText()).toContain("The lamp hangs by the door."); + expect( + await $('[data-testid=active-document-host] [contenteditable="true"]').getText(), + ).toContain("The lamp hangs by the door."); await browser.waitUntil(async () => (await status().getText()) === "Nothing to replace", { timeoutMsg: "The folder was not planned again after the replacement.", }); diff --git a/e2e/desktop/specs/folder-watcher.spec.ts b/e2e/desktop/specs/folder-watcher.spec.ts index 2f911dde..b373f91f 100644 --- a/e2e/desktop/specs/folder-watcher.spec.ts +++ b/e2e/desktop/specs/folder-watcher.spec.ts @@ -17,7 +17,7 @@ describe("desktop folder context watcher", () => { const initialArticle = await findTreeItem(folder.initialFileName); await expect(initialArticle).toBeDisplayed(); await expect(initialArticle).toHaveAttribute("aria-selected", "true"); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining(folder.initialMarker), ); @@ -35,7 +35,7 @@ describe("desktop folder context watcher", () => { await expect(addedArticle).toBeDisplayed(); await addedArticle.click(); await expect(addedArticle).toHaveAttribute("aria-selected", "true"); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining(folder.addedMarker), ); }); @@ -100,7 +100,7 @@ describe("desktop folder context watcher", () => { await expect(unsupportedRow).toBeFocused(); await expect(unsupportedRow).toHaveAttribute("aria-selected", "false"); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining(folder.addedMarker), ); expect(await $$('[data-slot="toast"][data-type="error"]').length).toBe(0); diff --git a/e2e/desktop/specs/frontmatter.spec.ts b/e2e/desktop/specs/frontmatter.spec.ts index 5aa9c6d3..39bbe7ee 100644 --- a/e2e/desktop/specs/frontmatter.spec.ts +++ b/e2e/desktop/specs/frontmatter.spec.ts @@ -6,7 +6,7 @@ import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; import { findMenuItem, openMenu, openRecentPath, selectFileMenuItem } from "../support/ui.js"; -const block = () => $(".leafdown-frontmatter"); +const block = () => $("[data-testid=active-document-host] .leafdown-frontmatter"); describe("desktop frontmatter", () => { it("edits and validates metadata, inserts one block, and keeps all formats across documents", async () => { @@ -15,8 +15,10 @@ describe("desktop frontmatter", () => { await expect(block()).toHaveAttribute("data-format", "yaml"); await expect(block()).toHaveAttribute("data-validation", "valid"); - await expect($(".leafdown-frontmatter-delimiter")).toHaveText("---"); - await expect($(".leafdown-frontmatter code")).toHaveText( + await expect( + $("[data-testid=active-document-host] .leafdown-frontmatter-delimiter"), + ).toHaveText("---"); + await expect($("[data-testid=active-document-host] .leafdown-frontmatter code")).toHaveText( expect.stringContaining("title: Garden report"), ); @@ -47,8 +49,12 @@ describe("desktop frontmatter", () => { await browser.setWindowSize(originalWindowSize.width, originalWindowSize.height); await browser.execute(() => { - document.querySelector(".ProseMirror")!.focus(); - const code = document.querySelector(".leafdown-frontmatter code")!; + document + .querySelector("[data-testid=active-document-host] .ProseMirror")! + .focus(); + const code = document.querySelector( + "[data-testid=active-document-host] .leafdown-frontmatter code", + )!; const range = document.createRange(); range.selectNodeContents(code); range.collapse(false); @@ -57,10 +63,14 @@ describe("desktop frontmatter", () => { selection.addRange(range); }); await browser.keys(Key.Enter); - await $(".leafdown-frontmatter code").addValue("broken: ["); - await expect($(".leafdown-frontmatter code")).toHaveText(expect.stringContaining("broken: [")); + await $("[data-testid=active-document-host] .leafdown-frontmatter code").addValue("broken: ["); + await expect($("[data-testid=active-document-host] .leafdown-frontmatter code")).toHaveText( + expect.stringContaining("broken: ["), + ); await expect(block()).toHaveAttribute("data-validation", "invalid"); - await expect($(".leafdown-frontmatter-status")).toBeDisplayed(); + await expect( + $("[data-testid=active-document-host] .leafdown-frontmatter-status"), + ).toBeDisplayed(); await browser.keys([Key.Ctrl, "s", Key.NULL]); await browser.waitUntil(async () => (await readFile(frontmatter.yamlPath, "utf8")).includes("broken: ["), @@ -68,14 +78,20 @@ describe("desktop frontmatter", () => { await openRecentPath(frontmatter.tomlPath); await expect(block()).toHaveAttribute("data-format", "toml"); - await expect($(".leafdown-frontmatter code")).toHaveText(expect.stringContaining("tags =")); + await expect($("[data-testid=active-document-host] .leafdown-frontmatter code")).toHaveText( + expect.stringContaining("tags ="), + ); await openRecentPath(frontmatter.jsonPath); await expect(block()).toHaveAttribute("data-format", "json"); - await expect($(".leafdown-frontmatter code")).toHaveText(expect.stringContaining('"title"')); + await expect($("[data-testid=active-document-host] .leafdown-frontmatter code")).toHaveText( + expect.stringContaining('"title"'), + ); await openRecentPath(frontmatter.yamlPath); await expect(block()).toHaveAttribute("data-validation", "invalid"); - await expect($(".leafdown-frontmatter code")).toHaveText(expect.stringContaining("broken: [")); + await expect($("[data-testid=active-document-host] .leafdown-frontmatter code")).toHaveText( + expect.stringContaining("broken: ["), + ); await selectFileMenuItem("New"); await openMenu("Insert"); @@ -84,7 +100,9 @@ describe("desktop frontmatter", () => { await expect(block()).toHaveAttribute("data-format", "json"); expect( await browser.execute(() => { - const code = document.querySelector(".leafdown-frontmatter code")!; + const code = document.querySelector( + "[data-testid=active-document-host] .leafdown-frontmatter code", + )!; return code.contains(window.getSelection()?.anchorNode ?? null); }), ).toBe(true); @@ -92,17 +110,19 @@ describe("desktop frontmatter", () => { await (await findMenuItem((text) => text.startsWith("Undo"))).click(); await expect(block()).not.toExist(); - await $(".ProseMirror").addValue("+++\ntitle = 'Typed'\n+++"); + await $("[data-testid=active-document-host] .ProseMirror").addValue( + "+++\ntitle = 'Typed'\n+++", + ); await expect(block()).toHaveAttribute("data-format", "toml"); await selectFileMenuItem("New"); - await $("aria/Discard changes").click(); - await $(".ProseMirror").addValue("---\ntitle: Typed\n---"); + await $("[data-testid=active-document-host] .ProseMirror").addValue("---\ntitle: Typed\n---"); await expect(block()).toHaveAttribute("data-format", "yaml"); await selectFileMenuItem("New"); - await $("aria/Discard changes").click(); - await $(".ProseMirror").addValue(';;;\n{"title":"Typed"}\n;;;'); + await $("[data-testid=active-document-host] .ProseMirror").addValue( + ';;;\n{"title":"Typed"}\n;;;', + ); await expect(block()).toHaveAttribute("data-format", "json"); }); }); diff --git a/e2e/desktop/specs/heading-outline.spec.ts b/e2e/desktop/specs/heading-outline.spec.ts index adf0c7de..fc20bd5d 100644 --- a/e2e/desktop/specs/heading-outline.spec.ts +++ b/e2e/desktop/specs/heading-outline.spec.ts @@ -2,9 +2,9 @@ import { $, $$, browser, expect } from "@wdio/globals"; import { readFile, writeFile } from "node:fs/promises"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { openRecentPath, selectFileMenuItem } from "../support/ui.js"; +import { closeAllDocuments, openRecentPath, selectFileMenuItem } from "../support/ui.js"; -const OUTLINE = '[data-testid="heading-outline"]'; +const OUTLINE = '[data-testid=active-document-host] [data-testid="heading-outline"]'; const outlineRows = () => $$(`${OUTLINE} button[data-outline-position]`); const isOutlineOpen = () => browser.execute( @@ -19,7 +19,7 @@ const selectedHeading = () => ); const markerState = () => browser.execute(() => { - const editor = document.querySelector(".ProseMirror"); + const editor = document.querySelector("[data-testid=active-document-host] .ProseMirror"); const heading = editor?.querySelector("h1"); const selection = document.getSelection(); return { @@ -40,7 +40,9 @@ const markerState = () => }; }); const focusOpeningHeading = () => - browser.execute(() => document.querySelector(".ProseMirror")?.focus()); + browser.execute(() => + document.querySelector("[data-testid=active-document-host] .ProseMirror")?.focus(), + ); const blurEditor = () => browser.execute(() => document.querySelector('[data-slot="menubar-trigger"]')!.focus(), @@ -50,7 +52,9 @@ const waitForHeadingText = (text: string) => browser.waitUntil( () => browser.execute( - (value) => document.querySelector(".ProseMirror h1")?.textContent === value, + (value) => + document.querySelector("[data-testid=active-document-host] .ProseMirror h1") + ?.textContent === value, text, ), { timeoutMsg: `The editor did not show the heading "${text}".` }, @@ -79,7 +83,7 @@ const leaveOutline = () => bubbles: true, composed: true, pointerType: "mouse", - relatedTarget: document.querySelector(".ProseMirror"), + relatedTarget: document.querySelector("[data-testid=active-document-host] .ProseMirror"), }), ); }, OUTLINE); @@ -88,9 +92,9 @@ const getPlacement = () => const outline = document.querySelector(`${selector} nav`); const surface = document.querySelector('[data-testid="document-surface-host"]'); const viewport = document.querySelector( - '[data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', + '[data-testid=active-document-host] [data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]', ); - const editor = document.querySelector(".ProseMirror"); + const editor = document.querySelector("[data-testid=active-document-host] .ProseMirror"); const heading = document .getSelection() ?.anchorNode?.parentElement?.closest("h1,h2,h3,h4,h5,h6"); @@ -150,7 +154,7 @@ describe("desktop heading outline", () => { const original = await readFile(outline.path, "utf8"); try { await openRecentPath(outline.path); - await expect($(".ProseMirror h1")).toHaveText("Same"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveText("Same"); await expect($('[data-slot="menubar-content"]')).not.toExist(); await movePointerAway(); await browser.waitUntil(async () => !(await markerState()).hover); @@ -176,19 +180,25 @@ describe("desktop heading outline", () => { await focusOpeningHeading(); await writeFile(outline.path, original.replace("# Same", "# Focused reload")); await browser.waitUntil( - async () => (await $(".ProseMirror h1").getText()) === "Focused reload", + async () => + (await $("[data-testid=active-document-host] .ProseMirror h1").getText()) === + "Focused reload", ); expect(await markerState()).toMatchObject({ focused: true, marker: "H1", anchorOffset: 0 }); await blurEditor(); await writeFile(outline.path, original.replace("# Same", "# Unfocused reload")); await browser.waitUntil( - async () => (await $(".ProseMirror h1").getText()) === "Unfocused reload", + async () => + (await $("[data-testid=active-document-host] .ProseMirror h1").getText()) === + "Unfocused reload", ); expect(await markerState()).toMatchObject({ focused: false, marker: null, content: "none" }); await browser.execute(() => { - const heading = document.querySelector(".ProseMirror h1")!; + const heading = document.querySelector( + "[data-testid=active-document-host] .ProseMirror h1", + )!; const rect = heading.getBoundingClientRect(); heading.dispatchEvent( new MouseEvent("mousemove", { @@ -202,7 +212,9 @@ describe("desktop heading outline", () => { expect(await markerState()).toMatchObject({ focused: false, marker: null, content: '"H1"' }); await browser.execute(() => { - const heading = document.querySelector(".ProseMirror h1")!; + const heading = document.querySelector( + "[data-testid=active-document-host] .ProseMirror h1", + )!; const rect = heading.getBoundingClientRect(); heading.dispatchEvent( new MouseEvent("mousedown", { @@ -214,7 +226,10 @@ describe("desktop heading outline", () => { }), ); }); - await expect($(".ProseMirror h1")).toHaveAttribute("data-leafdown-fold", "folded"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveAttribute( + "data-leafdown-fold", + "folded", + ); await blurEditor(); await movePointerAway(); await browser.waitUntil(async () => !(await markerState()).hover); @@ -226,7 +241,10 @@ describe("desktop heading outline", () => { new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }), ), ); - await expect($(".ProseMirror h1")).toHaveAttribute("data-leafdown-fold", "open"); + await expect($("[data-testid=active-document-host] .ProseMirror h1")).toHaveAttribute( + "data-leafdown-fold", + "open", + ); await expect($('[data-testid="status-bar-document-state"]')).not.toExist(); } finally { @@ -240,8 +258,9 @@ describe("desktop heading outline", () => { const original = await readFile(outline.path, "utf8"); await browser.keys("Escape"); await expect($('[data-slot="menubar-content"]')).not.toExist(); + await closeAllDocuments(); await selectFileMenuItem("New"); - await expect($(".ProseMirror")).toBeDisplayed(); + await expect($("[data-testid=active-document-host] .ProseMirror")).toBeDisplayed(); await expect($(OUTLINE)).not.toExist(); await openRecentPath(outline.path); @@ -249,9 +268,11 @@ describe("desktop heading outline", () => { () => browser.execute( () => - document.querySelector(".ProseMirror")?.textContent?.includes("Paragraph 20") ?? false, + document + .querySelector("[data-testid=active-document-host] .ProseMirror") + ?.textContent?.includes("Paragraph 20") ?? false, ), - { timeout: 15_000, timeoutMsg: "The saved document did not replace the untitled editor." }, + { timeout: 15_000, timeoutMsg: "The saved document did not open in a tab of its own." }, ); await browser.setWindowSize(640, 600); @@ -297,7 +318,10 @@ describe("desktop heading outline", () => { }); const quotedPosition = await rows[1].getAttribute("data-outline-position"); await chooseRow(quotedPosition, "Quoted"); - await expect($(".ProseMirror h3")).toHaveAttribute("data-leafdown-marker", "H3"); + await expect($("[data-testid=active-document-host] .ProseMirror h3")).toHaveAttribute( + "data-leafdown-marker", + "H3", + ); await browser.waitUntil( async () => (await rows[1].getAttribute("aria-current")) === "location", { timeoutMsg: "The quoted heading did not become current." }, @@ -305,7 +329,10 @@ describe("desktop heading outline", () => { await expectRestingClearance(); await chooseRow(await rows[2].getAttribute("data-outline-position"), "Nested"); - await expect($(".ProseMirror li h2")).toHaveAttribute("data-leafdown-marker", "H2"); + await expect($("[data-testid=active-document-host] .ProseMirror li h2")).toHaveAttribute( + "data-leafdown-marker", + "H2", + ); await expectRestingClearance(); // The opening heading returns the view to the start rather than scrolling past the padding. diff --git a/e2e/desktop/specs/help-pages.spec.ts b/e2e/desktop/specs/help-pages.spec.ts index fc49001c..2f9e3437 100644 --- a/e2e/desktop/specs/help-pages.spec.ts +++ b/e2e/desktop/specs/help-pages.spec.ts @@ -9,7 +9,7 @@ describe("desktop Help pages", () => { it("reads bundled pages in a narrow window and restores focus", async () => { const { document: fixtureDocument } = await getDesktopE2ERunContext(); await openRecentPath(fixtureDocument.path); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await editor.click(); await browser.keys([Key.Ctrl, Key.End, Key.NULL]); await editor.addValue(" Help draft"); @@ -98,7 +98,9 @@ describe("desktop Help pages", () => { await expect(await getSaveMenuItem()).not.toHaveAttribute("data-disabled"); await browser.keys("Escape"); const selectionAfterHelp = await browser.execute(() => { - document.querySelector('[contenteditable="true"]')?.focus(); + document + .querySelector('[data-testid=active-document-host] [contenteditable="true"]') + ?.focus(); return { offset: document.getSelection()?.anchorOffset, text: document.getSelection()?.anchorNode?.textContent, diff --git a/e2e/desktop/specs/launch-document.spec.ts b/e2e/desktop/specs/launch-document.spec.ts index 04bb8b14..3c2aa504 100644 --- a/e2e/desktop/specs/launch-document.spec.ts +++ b/e2e/desktop/specs/launch-document.spec.ts @@ -8,7 +8,7 @@ describe("desktop launch document", () => { it("opens the document named on the command line through the open workflow", async () => { const { launchDocument } = await getDesktopE2ERunContext(); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(editor).toHaveText(expect.stringContaining(launchDocument.marker)); await expect(await findTreeItem(path.basename(launchDocument.path))).toHaveAttribute( diff --git a/e2e/desktop/specs/legacy-encoding.spec.ts b/e2e/desktop/specs/legacy-encoding.spec.ts index 04cea3f7..7bb5d393 100644 --- a/e2e/desktop/specs/legacy-encoding.spec.ts +++ b/e2e/desktop/specs/legacy-encoding.spec.ts @@ -8,7 +8,7 @@ import { findMenuItem, openRecentPath } from "../support/ui.js"; const windows1252 = (text: string) => Buffer.from(text, "latin1"); const replaceDocumentText = async (text: string) => { - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await editor.click(); await browser.keys([Key.Ctrl, "a"]); @@ -30,7 +30,7 @@ describe("desktop legacy encoding", () => { await errorToast.$("button=Reopen with encoding").click(); await (await findMenuItem((text) => text === "Western (Windows-1252, ISO-8859-1)")).click(); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(editor).toHaveText(expect.stringContaining("Café")); await expect($('[data-testid="status-bar-encoding"]')).toHaveText("Windows-1252"); diff --git a/e2e/desktop/specs/missing-document-error.spec.ts b/e2e/desktop/specs/missing-document-error.spec.ts index 23311a61..5779ceae 100644 --- a/e2e/desktop/specs/missing-document-error.spec.ts +++ b/e2e/desktop/specs/missing-document-error.spec.ts @@ -23,6 +23,6 @@ describe("desktop backend error propagation", () => { record.path === missingDocumentPath, ); - await expect($('[contenteditable="true"]')).not.toExist(); + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).not.toExist(); }); }); diff --git a/e2e/desktop/specs/remote-images.spec.ts b/e2e/desktop/specs/remote-images.spec.ts index d4de0e2d..f55333e2 100644 --- a/e2e/desktop/specs/remote-images.spec.ts +++ b/e2e/desktop/specs/remote-images.spec.ts @@ -4,7 +4,7 @@ import { createServer, type Server } from "node:https"; import type { AddressInfo } from "node:net"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { openRecentPath } from "../support/ui.js"; +import { closeAllDocuments, openRecentPath } from "../support/ui.js"; const PLACEHOLDER_SELECTOR = ".leafdown-image-placeholder"; const LOADABLE_PLACEHOLDER_SELECTOR = `${PLACEHOLDER_SELECTOR}[data-image-resolution="remoteBlocked"]:has(button)`; @@ -58,8 +58,10 @@ const findPlaceholder = async (text: string) => { const reopenRemoteImages = async () => { const { images, remoteImages } = await getDesktopE2ERunContext(); + await closeAllDocuments(); await openRecentPath(images.path); await expect($('img[alt="Visible SVG"]')).toBeDisplayed(); + await closeAllDocuments(); await openRecentPath(remoteImages.path); await waitForRemotePlaceholders(); }; @@ -164,7 +166,9 @@ describe("desktop remote images", () => { const connectionsBeforePaste = connections; await $("p=Paste target.").execute(async (paragraph) => { - const editor = paragraph.closest(".ProseMirror"); + const editor = paragraph.closest( + "[data-testid=active-document-host] .ProseMirror", + ); const range = document.createRange(); editor?.focus(); @@ -194,7 +198,7 @@ describe("desktop remote images", () => { } document - .querySelector(".ProseMirror") + .querySelector("[data-testid=active-document-host] .ProseMirror") ?.dispatchEvent( new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData }), ); diff --git a/e2e/desktop/specs/rendered-html.spec.ts b/e2e/desktop/specs/rendered-html.spec.ts index c9a4ad3f..1be9de42 100644 --- a/e2e/desktop/specs/rendered-html.spec.ts +++ b/e2e/desktop/specs/rendered-html.spec.ts @@ -98,7 +98,9 @@ describe("desktop raw HTML", () => { await expect(liveSection()).toHaveText("Multiline safe HTML."); expect(await $$('[data-html-rendered="false"]').length).toBe(4); await expect( - $('.ProseMirror img[src], .ProseMirror svg, .ProseMirror [style*="fixed"]'), + $( + '[data-testid=active-document-host] .ProseMirror img[src], [data-testid=active-document-host] .ProseMirror svg, [data-testid=active-document-host] .ProseMirror [style*="fixed"]', + ), ).not.toExist(); const geometry = (await liveSection().execute((node) => { const block = node.getBoundingClientRect(); @@ -123,7 +125,7 @@ describe("desktop raw HTML", () => { for (const key of ["height", "left", "nextTop", "top", "width"] as const) { expect(Math.abs(unsupportedAfter[key] - unsupportedBefore[key])).toBeLessThanOrEqual(0.5); } - await $(".ProseMirror > p:last-child").execute((node) => { + await $("[data-testid=active-document-host] .ProseMirror > p:last-child").execute((node) => { const range = document.createRange(); range.selectNodeContents(node); range.collapse(false); @@ -142,8 +144,8 @@ describe("desktop raw HTML", () => { })), ).toEqual({ collapsed: true, text: "" }); expect(await getSelectionOffset(source())).toBe(sectionClickOffset); - await expect($(".ProseMirror input")).not.toExist(); - await $(".ProseMirror > p:last-child").execute((node) => { + await expect($("[data-testid=active-document-host] .ProseMirror input")).not.toExist(); + await $("[data-testid=active-document-host] .ProseMirror > p:last-child").execute((node) => { const range = document.createRange(); range.selectNodeContents(node); range.collapse(false); diff --git a/e2e/desktop/specs/rendered-images.spec.ts b/e2e/desktop/specs/rendered-images.spec.ts index 82ce8d16..dadb3dee 100644 --- a/e2e/desktop/specs/rendered-images.spec.ts +++ b/e2e/desktop/specs/rendered-images.spec.ts @@ -180,7 +180,9 @@ describe("desktop rendered images", () => { const getRows = () => missingPlaceholder.execute((node) => { const paragraph = node.closest("p")!; - const editorTop = node.closest(".ProseMirror")!.getBoundingClientRect().top; + const editorTop = node + .closest("[data-testid=active-document-host] .ProseMirror")! + .getBoundingClientRect().top; const next = paragraph.nextElementSibling!.querySelector(".leafdown-image-view")!; const { height, top } = paragraph.getBoundingClientRect(); diff --git a/e2e/desktop/specs/rendered-math.spec.ts b/e2e/desktop/specs/rendered-math.spec.ts index a5bed844..217a9fc6 100644 --- a/e2e/desktop/specs/rendered-math.spec.ts +++ b/e2e/desktop/specs/rendered-math.spec.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { openRecentPath, selectFileMenuItem } from "../support/ui.js"; +import { closeAllDocuments, openRecentPath, selectFileMenuItem } from "../support/ui.js"; const math = (value: string) => $(`[data-type="math"][data-value="${value.replaceAll("\\", "\\\\")}"]`); @@ -69,7 +69,7 @@ describe("desktop rendered math", () => { await pressMath(math("$E = mc^2$")); await waitForProjection("$E = mc^2$"); - await $(".ProseMirror").addValue("z"); + await $("[data-testid=active-document-host] .ProseMirror").addValue("z"); await waitForProjection("$zE = mc^2$"); await browser.keys([Key.Ctrl, "s", Key.NULL]); const edited = original.replace("$E = mc^2$", "$zE = mc^2$"); @@ -103,6 +103,7 @@ describe("desktop rendered math", () => { violations.push(`${event.violatedDirective} ${event.blockedURI}`); }); }); + await closeAllDocuments(); await openRecentPath(fixture.path); const inline = math("$x^2$"); @@ -218,7 +219,7 @@ describe("desktop rendered math", () => { expect( await browser.execute(() => document.activeElement?.classList.contains("ProseMirror")), ).toBe(true); - await $(".ProseMirror").addValue("z"); + await $("[data-testid=active-document-host] .ProseMirror").addValue("z"); expect((await readProjection()).text).toBe("$zx^2$"); expect(await preview().execute((node) => node.querySelector("annotation")?.textContent)).toBe( "zx^2", @@ -258,7 +259,7 @@ describe("desktop rendered math", () => { ); await expect(fenced).toHaveAttribute("class", /\bleafdown-code-block--active\b/u); expect(await fenced.$("code").execute((node) => getComputedStyle(node).display)).toBe("block"); - await $(".ProseMirror").addValue("z"); + await $("[data-testid=active-document-host] .ProseMirror").addValue("z"); expect(await fenced.$("code").getText()).toBe("zx^2 + y^2"); expect(await fenced.execute((node) => node.querySelector("annotation")?.textContent)).toBe( "zx^2 + y^2", @@ -303,7 +304,7 @@ describe("desktop rendered math", () => { const range = document.createRange(); range.setStart(before, before.textContent!.length); range.collapse(true); - node.closest(".ProseMirror")!.focus(); + node.closest("[data-testid=active-document-host] .ProseMirror")!.focus(); const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); diff --git a/e2e/desktop/specs/rendered-mermaid.spec.ts b/e2e/desktop/specs/rendered-mermaid.spec.ts index d0f4cda3..4a89b380 100644 --- a/e2e/desktop/specs/rendered-mermaid.spec.ts +++ b/e2e/desktop/specs/rendered-mermaid.spec.ts @@ -178,13 +178,19 @@ describe("desktop Mermaid diagrams", () => { await expect(first.$("code")).toBeDisplayed(); await expect(image).toBeDisplayed(); expect( - await browser.execute(() => document.activeElement?.closest(".ProseMirror") !== null), + await browser.execute( + () => + document.activeElement?.closest("[data-testid=active-document-host] .ProseMirror") !== + null, + ), ).toBe(true); await browser.execute(() => { - const heading = document.querySelector(".ProseMirror h1")!; + const heading = document.querySelector( + "[data-testid=active-document-host] .ProseMirror h1", + )!; const text = document.createTreeWalker(heading, NodeFilter.SHOW_TEXT).nextNode()!; - heading.closest(".ProseMirror")!.focus(); + heading.closest("[data-testid=active-document-host] .ProseMirror")!.focus(); window.getSelection()!.collapse(text, text.textContent!.length); document.dispatchEvent(new Event("selectionchange")); }); @@ -206,7 +212,7 @@ describe("desktop Mermaid diagrams", () => { ); expect(await failed.$("[role='alert']").getComputedRole()).toBe("alert"); - await $(".ProseMirror p").click(); + await $("[data-testid=active-document-host] .ProseMirror p").click(); await browser.execute(() => document.querySelectorAll("pre[data-mermaid-mode]")[2]?.scrollIntoView(), ); diff --git a/e2e/desktop/specs/separator-presentation.spec.ts b/e2e/desktop/specs/separator-presentation.spec.ts index 011fff3e..515bb9f2 100644 --- a/e2e/desktop/specs/separator-presentation.spec.ts +++ b/e2e/desktop/specs/separator-presentation.spec.ts @@ -7,7 +7,7 @@ describe("desktop separator presentation", () => { it("fills a paragraph-height row and uses the block selection wash", async () => { const { separator: fixture } = await getDesktopE2ERunContext(); await openRecentPath(fixture.path); - const separator = $(".ProseMirror hr"); + const separator = $("[data-testid=active-document-host] .ProseMirror hr"); await expect(separator).toBeDisplayed(); const geometry = (await separator.execute((node) => { diff --git a/e2e/desktop/specs/wiki-links.spec.ts b/e2e/desktop/specs/wiki-links.spec.ts index 95ee45b6..e3b7dc78 100644 --- a/e2e/desktop/specs/wiki-links.spec.ts +++ b/e2e/desktop/specs/wiki-links.spec.ts @@ -5,7 +5,8 @@ import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; import { ctrlClickElement, findTreeItem, openRecentPath } from "../support/ui.js"; -const link = (source: string) => $(`[data-type="wiki-link"][data-source="${source}"]`); +const link = (source: string) => + $(`[data-testid=active-document-host] [data-type="wiki-link"][data-source="${source}"]`); const caretIsInHeading = () => browser.execute(() => @@ -18,7 +19,7 @@ describe("desktop wiki links", () => { await openRecentPath(wikiLinks.folderPath); await (await findTreeItem("index.md")).click(); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(editor).toBeDisplayed(); await expect(link("[[target|Target alias]]")).toHaveAttribute("data-wiki-status", "resolved"); await expect(link("[[missing|Missing]]")).toHaveAttribute("data-wiki-status", "unresolved"); @@ -44,7 +45,7 @@ describe("desktop wiki links", () => { ); await ctrlClickElement(await link("[[target#Target heading|Target section]]").getElement()); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining("Target heading"), ); await browser.waitUntil(caretIsInHeading, { @@ -55,7 +56,7 @@ describe("desktop wiki links", () => { await ctrlClickElement(await link("[[../outside.md|Outside]]").getElement()); await expect($("aria/Open outside folder?")).toBeDisplayed(); await $("aria/Open file").click(); - await expect($('[contenteditable="true"]')).toHaveText( + await expect($('[data-testid=active-document-host] [contenteditable="true"]')).toHaveText( expect.stringContaining("Outside heading"), ); await expect(await findTreeItem("index.md")).toBeDisplayed(); diff --git a/e2e/desktop/specs/window-lifecycle.spec.ts b/e2e/desktop/specs/window-lifecycle.spec.ts index a5643513..99ef11c8 100644 --- a/e2e/desktop/specs/window-lifecycle.spec.ts +++ b/e2e/desktop/specs/window-lifecycle.spec.ts @@ -53,7 +53,7 @@ describe("desktop window lifecycle", () => { } await $("aria/New document").click(); - const editor = $('[contenteditable="true"]'); + const editor = $('[data-testid=active-document-host] [contenteditable="true"]'); await expect(editor).toBeDisplayed(); await editor.click(); await editor.addValue(" Unsaved before exit"); diff --git a/e2e/desktop/support/scenarioSelection.test.ts b/e2e/desktop/support/scenarioSelection.test.ts index 3c21d7c4..50326911 100644 --- a/e2e/desktop/support/scenarioSelection.test.ts +++ b/e2e/desktop/support/scenarioSelection.test.ts @@ -13,6 +13,7 @@ void test("runs every scenario when no focused target is requested", () => { ["frontmatter"], ["diagnostics"], ["document-lifecycle"], + ["document-tabs"], ["document-watcher"], ["find-and-replace"], ["keyboard-shortcuts"], diff --git a/e2e/desktop/support/scenarioSelection.ts b/e2e/desktop/support/scenarioSelection.ts index f0ca4ee3..c8c3065e 100644 --- a/e2e/desktop/support/scenarioSelection.ts +++ b/e2e/desktop/support/scenarioSelection.ts @@ -6,6 +6,7 @@ const scenarioTargets = { frontmatter: ["frontmatter"], diagnostics: ["diagnostics"], "document-lifecycle": ["document-lifecycle"], + "document-tabs": ["document-tabs"], "document-watcher": ["document-watcher"], "find-and-replace": ["find-and-replace"], "keyboard-shortcuts": ["keyboard-shortcuts"], diff --git a/e2e/desktop/support/ui.ts b/e2e/desktop/support/ui.ts index 3936ab02..13b13c90 100644 --- a/e2e/desktop/support/ui.ts +++ b/e2e/desktop/support/ui.ts @@ -43,6 +43,39 @@ export const openRecentPath = async (path: string) => { await (await findMenuItem((text) => text === path)).click(); }; +const DOCUMENT_TAB_SELECTOR = '[data-testid="document-tab"]'; + +export const countDocumentTabs = async () => $$(DOCUMENT_TAB_SELECTOR).length; + +/** + * Closes every open document, discarding unsaved changes, so a scenario that moves to another + * fixture has only that fixture's editor in the window. + */ +export const closeAllDocuments = async () => { + for (let count = await countDocumentTabs(); count > 0; count = await countDocumentTabs()) { + await selectFileMenuItem("Close document"); + await browser.waitUntil( + async () => + (await countDocumentTabs()) < count || (await $("aria/Discard changes").isExisting()), + { timeoutMsg: "Close document neither closed the tab nor asked about its changes." }, + ); + + if (await $("aria/Discard changes").isExisting()) { + await $("aria/Discard changes").click(); + await browser.waitUntil(async () => (await countDocumentTabs()) < count, { + timeoutMsg: "Discarding did not close the tab.", + }); + } + } +}; + +export const findDocumentTab = (name: string) => + findByText( + DOCUMENT_TAB_SELECTOR, + (text) => text === name, + `Document tab ${name} did not appear.`, + ); + export const dismissToasts = async () => { for (const close of await $$('[data-slot="toast-close"]').getElements()) { await close.click(); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 25e6eb95..d4937992 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -29,6 +29,7 @@ "core:window:allow-start-dragging", "core:window:allow-set-always-on-top", "core:window:allow-set-fullscreen", + "core:window:allow-set-title", "core:window:allow-toggle-maximize", "core:webview:allow-set-webview-zoom", "frame:default", diff --git a/src/app.test.tsx b/src/app.test.tsx index 943a849a..cc831ec7 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -17,13 +17,17 @@ import { } from "./features/preferences"; import { releaseNotesStoreTauriHandler } from "./features/release-notes"; import { useSessionStore } from "./features/session"; -import { createOpenedMarkdownDocument, createUntitledDocument } from "./test/factories/document"; +import { + createOpenedMarkdownDocument, + createSavedDocument, + createUntitledDocument, +} from "./test/factories/document"; import { createEmptyFolderContext } from "./test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH } from "./test/fixtures/paths"; import { setDefaultSession, setDefaultSettings } from "./test/utils/appStores"; import { getLastDiagnosticPayload } from "./test/utils/diagnostics"; import { dispatchDOMEvent } from "./test/utils/events"; -import { render, renderWithUser, screen, waitFor } from "./test/utils/react"; +import { render, renderWithUser, screen, waitFor, within } from "./test/utils/react"; import { getWindowListenHandler, getWindowThemeChangedHandler } from "./test/utils/tauri"; import { countTauriApiCalls, mockTauriApi, tauriApiCommand } from "./test/utils/tauriApi"; @@ -303,7 +307,7 @@ describe("App", () => { ); const closeRequest = handleCloseRequested({ payload: undefined }); const prompt = await screen.findByRole("dialog", { name: "Unsaved changes" }); - expect(prompt).toHaveTextContent("The active document has unsaved changes."); + expect(prompt).toHaveTextContent("Untitled has unsaved changes."); await user.click(screen.getByRole("button", { name: "Keep editing" })); await closeRequest; expect(appWindow.destroy).not.toHaveBeenCalled(); @@ -312,6 +316,47 @@ describe("App", () => { }); }); + it("asks once about every unsaved tab before closing the window", async () => { + const appWindow = getCurrentWindow(); + mockTauriApi({ takeLaunchDocumentPath: () => null, declineWindowClose: () => undefined }); + setDefaultSession({ + documents: [ + createUntitledDocument({ isDirty: true }), + createSavedDocument(), + createSavedDocument({ path: "C:/Notes/plan.md", isDirty: true }), + ], + }); + + const { user } = renderWithUser(); + + await waitFor(() => { + expect(appWindow.listen).toHaveBeenCalledWith( + "leafdown://window-close-requested", + expect.any(Function), + ); + }); + + const handleCloseRequested = getWindowListenHandler>( + "leafdown://window-close-requested", + ); + const cancelledRequest = handleCloseRequested({ payload: undefined }); + const prompt = await screen.findByRole("dialog", { name: "Unsaved changes" }); + expect(prompt).toHaveTextContent("2 documents have unsaved changes."); + expect(prompt).toHaveTextContent("Untitled"); + expect(prompt).toHaveTextContent("plan.md"); + await user.click(within(prompt).getByRole("button", { name: "Cancel" })); + await cancelledRequest; + + expect(appWindow.destroy).not.toHaveBeenCalled(); + expect(useSessionStore.getState().tabs).toHaveLength(3); + + const confirmedRequest = handleCloseRequested({ payload: undefined }); + await user.click(await screen.findByRole("button", { name: "Discard all" })); + await confirmedRequest; + + expect(appWindow.destroy).toHaveBeenCalledOnce(); + }); + it("leaves a failed close request unanswered so the backend fallback can close the window", async () => { const appWindow = getCurrentWindow(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); diff --git a/src/app.tsx b/src/app.tsx index 9dc50c0f..622647a3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -16,7 +16,7 @@ import { } from "@/features/preferences"; import { initializeReleaseNotes } from "@/features/release-notes"; import { - confirmDiscardActiveDocumentChanges, + confirmDiscardAllDocumentChanges, declineWindowClose, DroppedPathOverlay, openLaunchDocument, @@ -100,7 +100,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 confirmDiscardActiveDocumentChanges())) { + if (!(await confirmDiscardAllDocumentChanges())) { await declineWindowClose(); return; } diff --git a/src/commands/actions/edit.ts b/src/commands/actions/edit.ts index 6d668517..76a5042e 100644 --- a/src/commands/actions/edit.ts +++ b/src/commands/actions/edit.ts @@ -26,9 +26,7 @@ const setActiveDocumentEncoding = ( return; } - useSessionStore - .getState() - .setActiveDocumentEncoding(getActiveDocumentKey(activeDocument), encoding); + useSessionStore.getState().setDocumentEncoding(getActiveDocumentKey(activeDocument), encoding); }; const getConvertibleFileEncoding = (activeDocument: ActiveDocumentState | null) => @@ -109,7 +107,7 @@ const setActiveDocumentLineEnding = ( } const activeDocumentKey = getActiveDocumentKey(activeDocument); - useSessionStore.getState().setActiveDocumentLineEnding(activeDocumentKey, lineEnding); + useSessionStore.getState().setDocumentLineEnding(activeDocumentKey, lineEnding); }; export const setCrlfLineEnding = (context: AppCommandContext) => { diff --git a/src/commands/actions/view.test.ts b/src/commands/actions/view.test.ts index 5af69b25..6003eb8d 100644 --- a/src/commands/actions/view.test.ts +++ b/src/commands/actions/view.test.ts @@ -4,20 +4,27 @@ import { describe, expect, it, vi } from "vitest"; import { useArticleNavigatorStore } from "@/features/folder-context"; import { useSettingsStore } from "@/features/preferences"; +import { useSessionStore } from "@/features/session"; import { toastManager } from "@/lib/toast"; import { createAppCommandContext } from "@/test/factories/commands"; +import { createSavedDocument } from "@/test/factories/document"; import { createFolderContextWithNestedReadme } from "@/test/factories/folderContext"; import { TEST_NESTED_DIRECTORY_PATH } from "@/test/fixtures/paths"; -import { setDefaultSettings, setDefaultUI } from "@/test/utils/appStores"; +import { setDefaultSession, setDefaultSettings, setDefaultUI } from "@/test/utils/appStores"; import { useCommandUIStore } from "../stores/commandUi"; import { collapseAllFolders, expandAllFolders, + getShowTabAtState, + getSwitchTabState, resetZoom, setDarkTheme, setLightTheme, setSystemTheme, + showNextTab, + showPreviousTab, + showTabAt, sortByType, toggleAlwaysOnTop, toggleFullscreen, @@ -205,4 +212,48 @@ describe("view actions", () => { expect(useSettingsStore.getState().articleSortOrder).toBe("name"); }); + + describe("tabs", () => { + const getActivePath = () => { + const document = useSessionStore.getState().activeDocument; + + return document?.status === "saved" ? document.path : null; + }; + + it("moves through the tabs in strip order, wrapping at either end", () => { + setDefaultSession({ + documents: [ + createSavedDocument({ path: "C:/Notes/a.md" }), + createSavedDocument({ path: "C:/Notes/b.md" }), + createSavedDocument({ path: "C:/Notes/c.md" }), + ], + activeIndex: 2, + }); + + showNextTab(); + expect(getActivePath()).toBe("C:/Notes/a.md"); + + showPreviousTab(); + showPreviousTab(); + expect(getActivePath()).toBe("C:/Notes/b.md"); + + showTabAt(-1)(); + expect(getActivePath()).toBe("C:/Notes/c.md"); + + showTabAt(0)(); + expect(getActivePath()).toBe("C:/Notes/a.md"); + }); + + it("offers switching only while another tab is open, and each position only once filled", () => { + const oneTab = createAppCommandContext({ tabs: { count: 1, activeIndex: 0 } }); + const twoTabs = createAppCommandContext({ tabs: { count: 2, activeIndex: 0 } }); + + expect(getSwitchTabState(oneTab).enabled).toBe(false); + expect(getSwitchTabState(twoTabs).enabled).toBe(true); + expect(getShowTabAtState(1)(oneTab).enabled).toBe(false); + expect(getShowTabAtState(1)(twoTabs).enabled).toBe(true); + expect(getShowTabAtState(-1)(oneTab).enabled).toBe(true); + expect(getShowTabAtState(-1)(createAppCommandContext()).enabled).toBe(false); + }); + }); }); diff --git a/src/commands/actions/view.ts b/src/commands/actions/view.ts index c189d1c5..37e84ea3 100644 --- a/src/commands/actions/view.ts +++ b/src/commands/actions/view.ts @@ -8,7 +8,7 @@ import { type ArticleSortOrder, } from "@/features/folder-context"; import { setAlwaysOnTop, useSettingsStore } from "@/features/preferences"; -import { changeArticleSortOrder } from "@/features/session"; +import { activateAdjacentTab, activateTabAt, changeArticleSortOrder } from "@/features/session"; import { notifyOperationFailure } from "@/lib/errors"; import { t } from "@/lib/i18n"; import { notifyError } from "@/lib/toast"; @@ -194,3 +194,24 @@ export const getCollapseAllFoldersState = (context: AppCommandContext) => export const getExpandAllFoldersState = (context: AppCommandContext) => context.folderContext ? enabled() : disabled("No folder context is open."); + +export const showNextTab = () => { + activateAdjacentTab(1); +}; + +export const showPreviousTab = () => { + activateAdjacentTab(-1); +}; + +/** Shows the tab at a position in the strip, or the last tab for a negative position. */ +export const showTabAt = (index: number) => () => { + activateTabAt(index); +}; + +export const getSwitchTabState = (context: AppCommandContext) => + context.tabs.count > 1 ? enabled() : disabled("No other document is open."); + +export const getShowTabAtState = (index: number) => (context: AppCommandContext) => + (index < 0 ? context.tabs.count > 0 : context.tabs.count > index) + ? enabled() + : disabled("No document is open at that position."); diff --git a/src/commands/application.ts b/src/commands/application.ts index 8b80e935..0f760d18 100644 --- a/src/commands/application.ts +++ b/src/commands/application.ts @@ -47,6 +47,17 @@ export const APPLICATION_COMMANDS = { "edit.findInFolder": appCommand(edit.findInFolder, edit.getFindInFolderState), "edit.replaceInFolder": appCommand(edit.replaceInFolder, edit.getFindInFolderState), + "view.nextTab": appCommand(view.showNextTab, view.getSwitchTabState), + "view.previousTab": appCommand(view.showPreviousTab, view.getSwitchTabState), + "view.tab1": appCommand(view.showTabAt(0), view.getShowTabAtState(0)), + "view.tab2": appCommand(view.showTabAt(1), view.getShowTabAtState(1)), + "view.tab3": appCommand(view.showTabAt(2), view.getShowTabAtState(2)), + "view.tab4": appCommand(view.showTabAt(3), view.getShowTabAtState(3)), + "view.tab5": appCommand(view.showTabAt(4), view.getShowTabAtState(4)), + "view.tab6": appCommand(view.showTabAt(5), view.getShowTabAtState(5)), + "view.tab7": appCommand(view.showTabAt(6), view.getShowTabAtState(6)), + "view.tab8": appCommand(view.showTabAt(7), view.getShowTabAtState(7)), + "view.lastTab": appCommand(view.showTabAt(-1), view.getShowTabAtState(-1)), "view.toggleSidebar": appCommand(view.toggleSidebar, view.getToggleSidebarState), "view.commandPalette": appCommand(view.openCommandPalette), "view.toggleStatusBar": appCommand(view.toggleStatusBar, view.getToggleStatusBarState), diff --git a/src/commands/context.ts b/src/commands/context.ts index 1ffe061d..d654555c 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,8 +27,14 @@ export type AppCommandSettingsContext = Pick< >; export type AppCommandUIContext = Pick; +export interface AppCommandTabsContext { + count: number; + activeIndex: number; +} + export interface AppCommandContext { activeDocument: ActiveDocumentState | null; + tabs: AppCommandTabsContext; editor: EditorCommandState; folderContext: FolderContextState | null; folderContextLoading: boolean; diff --git a/src/commands/hooks/useAppCommands.test.tsx b/src/commands/hooks/useAppCommands.test.tsx index e0b54e23..c65fe486 100644 --- a/src/commands/hooks/useAppCommands.test.tsx +++ b/src/commands/hooks/useAppCommands.test.tsx @@ -325,7 +325,7 @@ describe("useAppCommands shortcut routing", () => { expect(useFolderSearchStore.getState().open).toBe(false); - act(() => useSessionStore.getState().setFolderOnlySession(createFolderContext())); + act(() => useSessionStore.getState().setFolderContext(createFolderContext())); const event = dispatchKeyDown(window, "F", { ctrl: true, shift: true }); @@ -333,6 +333,35 @@ describe("useAppCommands shortcut routing", () => { expect(useFolderSearchStore.getState().open).toBe(true); }); + it("switches tabs with Mod+Tab, Mod+PageUp, and Mod+9", () => { + setDefaultSession({ + documents: [ + createSavedDocument({ path: "C:/Notes/a.md" }), + createSavedDocument({ path: "C:/Notes/b.md" }), + createSavedDocument({ path: "C:/Notes/c.md" }), + ], + }); + render(); + const press = (key: string) => { + let event: KeyboardEvent | undefined; + + act(() => { + event = dispatchKeyDown(window, key, { ctrl: true }); + }); + + return event!; + }; + + expect(press("Tab").defaultPrevented).toBe(true); + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: "C:/Notes/b.md" }); + + press("PageUp"); + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: "C:/Notes/a.md" }); + + press("9"); + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: "C:/Notes/c.md" }); + }); + it("reports rejected application command executions", async () => { const originalRun = APPLICATION_COMMANDS["file.new"].run; const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); diff --git a/src/commands/hooks/useAppCommands.ts b/src/commands/hooks/useAppCommands.ts index f4fe7fda..282cc0d1 100644 --- a/src/commands/hooks/useAppCommands.ts +++ b/src/commands/hooks/useAppCommands.ts @@ -71,6 +71,10 @@ export const useAppCommands = () => { const setFullscreen = useCommandUIStore((state) => state.setFullscreen); const activeDocument = useSessionStore((state) => state.activeDocument); + const tabCount = useSessionStore((state) => state.tabs.length); + const activeTabIndex = useSessionStore((state) => + state.tabs.findIndex((tab) => tab.id === state.activeTabId), + ); const folderContext = useSessionStore((state) => state.folderContext); const folderContextLoading = useSessionStore((state) => state.folderContextLoad !== null); const alwaysOnTop = useSettingsStore((state) => state.alwaysOnTop); @@ -91,6 +95,7 @@ export const useAppCommands = () => { const context: AppCommandContext = { activeDocument, + tabs: { count: tabCount, activeIndex: activeTabIndex }, editor, folderContext, folderContextLoading, diff --git a/src/commands/metadata.ts b/src/commands/metadata.ts index 14d40c80..fcf288f4 100644 --- a/src/commands/metadata.ts +++ b/src/commands/metadata.ts @@ -180,6 +180,20 @@ export const COMMAND_DEFINITIONS: Record = { "format.table.deleteColumn": commandDef(), "format.clearBlock": commandDef(), "view.commandPalette": commandDef({ key: "p", mod: true, shift: true }), + "view.nextTab": commandDef({ key: "Tab", mod: true }, { key: "PageDown", mod: true }), + "view.previousTab": commandDef( + { key: "Tab", mod: true, shift: true }, + { key: "PageUp", mod: true }, + ), + "view.tab1": commandDef({ key: "1", mod: true }), + "view.tab2": commandDef({ key: "2", mod: true }), + "view.tab3": commandDef({ key: "3", mod: true }), + "view.tab4": commandDef({ key: "4", mod: true }), + "view.tab5": commandDef({ key: "5", mod: true }), + "view.tab6": commandDef({ key: "6", mod: true }), + "view.tab7": commandDef({ key: "7", mod: true }), + "view.tab8": commandDef({ key: "8", mod: true }), + "view.lastTab": commandDef({ key: "9", mod: true }), "view.toggleSidebar": commandDef({ key: "e", mod: true, shift: true }), "view.toggleStatusBar": commandDef(), "view.zoomIn": commandDef({ key: "=", mod: true }), diff --git a/src/components/layout/command-menubar.tsx b/src/components/layout/command-menubar.tsx index d1030e89..3dd41de0 100644 --- a/src/components/layout/command-menubar.tsx +++ b/src/components/layout/command-menubar.tsx @@ -254,6 +254,8 @@ export function CommandMenubar({ + + diff --git a/src/components/layout/confirmation-dialog.tsx b/src/components/layout/confirmation-dialog.tsx index d9048e49..7d0f6e05 100644 --- a/src/components/layout/confirmation-dialog.tsx +++ b/src/components/layout/confirmation-dialog.tsx @@ -37,7 +37,7 @@ export function ConfirmationDialog() { {request.detail && (

{request.detail} diff --git a/src/components/layout/document-tabs.test.tsx b/src/components/layout/document-tabs.test.tsx new file mode 100644 index 00000000..47badb3c --- /dev/null +++ b/src/components/layout/document-tabs.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; + +import { useSessionStore } from "@/features/session"; +import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; +import { setDefaultSession } from "@/test/utils/appStores"; +import { act, renderWithUser, screen, waitFor, within } from "@/test/utils/react"; + +import { DocumentTabs } from "./document-tabs"; + +vi.mock("@/components/screens/document-screen", () => ({ + DocumentScreen: ({ + active, + tab, + }: { + active: boolean; + tab: { document: { content: string } }; + }) => ( +

+ {tab.document.content} +
+ ), +})); + +const renderTabs = (onNewDocument = vi.fn()) => + renderWithUser(); + +const getTabNames = () => + within(screen.getByRole("tablist", { name: "Open documents" })) + .getAllByRole("tab") + .map((tab) => tab.getAttribute("aria-label")); + +describe("DocumentTabs", () => { + it("shows a tab for each open document in order, with the active one selected", () => { + setDefaultSession({ + documents: [ + createSavedDocument({ content: "# Readme" }), + createUntitledDocument({ id: "untitled:2", content: "Draft", isDirty: true }), + ], + activeIndex: 1, + }); + + renderTabs(); + + expect(getTabNames()).toEqual(["readme.md", "Untitled 2, unsaved"]); + expect(screen.getByRole("tab", { name: "Untitled 2, unsaved" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByTestId("active-document-host")).toHaveTextContent("Draft"); + expect(screen.getByTestId("inactive-document-host")).toHaveTextContent("# Readme"); + expect(screen.getByTestId("inactive-document-host").closest("[hidden]")).not.toBeNull(); + }); + + it("names the folder of documents that share a file name", () => { + setDefaultSession({ + documents: [ + createSavedDocument({ path: "C:/Notes/guides/README.md" }), + createSavedDocument({ path: "C:/Notes/api/readme.md" }), + createSavedDocument({ path: "C:/Notes/plan.md" }), + ], + }); + + renderTabs(); + + expect(getTabNames()).toEqual(["README.md, guides", "readme.md, api", "plan.md"]); + expect(screen.getByRole("tab", { name: "plan.md" })).toHaveAttribute( + "title", + "C:/Notes/plan.md", + ); + }); + + it("switches to a tab when it is chosen", async () => { + setDefaultSession({ + documents: [createSavedDocument(), createSavedDocument({ path: "C:/Notes/plan.md" })], + }); + const { user } = renderTabs(); + + await user.click(screen.getByRole("tab", { name: "plan.md" })); + + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: "C:/Notes/plan.md", + }); + }); + + it("closes a tab from its close mark or a middle click without switching to it", async () => { + setDefaultSession({ + documents: [ + createSavedDocument(), + createSavedDocument({ path: "C:/Notes/plan.md" }), + createSavedDocument({ path: "C:/Notes/todo.md" }), + ], + }); + const { user } = renderTabs(); + + await user.click( + within(screen.getByRole("tab", { name: "plan.md" })).getByTestId("document-tab-close"), + ); + + expect(getTabNames()).toEqual(["readme.md", "todo.md"]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: "C:/Notes/readme.md", + }); + + act(() => { + screen + .getByRole("tab", { name: "todo.md" }) + .dispatchEvent(new MouseEvent("auxclick", { bubbles: true, button: 1 })); + }); + + await waitFor(() => expect(getTabNames()).toEqual(["readme.md"])); + }); + + it("opens a new document from the button after the tabs", async () => { + setDefaultSession({ activeDocument: createSavedDocument() }); + const onNewDocument = vi.fn(); + const { user } = renderTabs(onNewDocument); + + await user.click(screen.getByRole("button", { name: "New" })); + + expect(onNewDocument).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/layout/document-tabs.tsx b/src/components/layout/document-tabs.tsx new file mode 100644 index 00000000..08f96245 --- /dev/null +++ b/src/components/layout/document-tabs.tsx @@ -0,0 +1,217 @@ +import { Tabs } from "@base-ui/react/tabs"; +import { PlusIcon, XIcon } from "lucide-react"; +import { useLayoutEffect, useRef, type MouseEvent } from "react"; + +import { DocumentScreen } from "@/components/screens/document-screen"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getArticlePaths } from "@/features/folder-context"; +import { + activateDocumentTab, + closeDocumentTab, + getDocumentName, + useSessionStore, + type DocumentTab, +} from "@/features/session"; +import { handleUnexpectedError } from "@/lib/errors"; +import { useLocalization, type Translate } from "@/lib/i18n"; +import { getPathParts } from "@/lib/path"; +import { cn } from "@/lib/utils"; + +interface DocumentTabsProps { + newDocumentLabel: string; + onNewDocument: () => void; +} + +interface TabLabel { + name: string; + /** The parent folder's name, shown only while another tab has the same name. */ + folder: string | null; +} + +const MIDDLE_MOUSE_BUTTON = 1; + +const getTabLabels = (tabs: readonly DocumentTab[], t: Translate): TabLabel[] => { + const names = tabs.map((tab) => getDocumentName(tab.document, t)); + const nameCounts = new Map(); + + for (const name of names) { + const key = name.toLocaleLowerCase(); + nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1); + } + + return tabs.map(({ document }, index) => { + const name = names[index]; + const isShared = (nameCounts.get(name.toLocaleLowerCase()) ?? 0) > 1; + + return { + name, + folder: + isShared && document.status === "saved" + ? getPathParts(getPathParts(document.path).parent).name || null + : null, + }; + }); +}; + +// The close mark is for the pointer; keyboard users close the active tab with `Close document`. +const handleClosePress = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + +const closeTab = (tabId: string) => { + void closeDocumentTab(tabId).catch((error) => handleUnexpectedError(error, "closeDocumentTab")); +}; + +/** The open documents of the window: a strip of tabs above the active document's editor. */ +export function DocumentTabs({ newDocumentLabel, onNewDocument }: DocumentTabsProps) { + const { t } = useLocalization(); + const tabs = useSessionStore((state) => state.tabs); + const activeTabId = useSessionStore((state) => state.activeTabId); + const folderContext = useSessionStore((state) => state.folderContext); + const wikiCompletionPaths = folderContext ? getArticlePaths(folderContext.tree) : []; + const listRef = useRef(null); + const labels = getTabLabels(tabs, t); + + useLayoutEffect(() => { + const activeTab = listRef.current?.querySelector("[data-active]"); + + activeTab?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + }, [activeTabId, tabs.length]); + + return ( + activateDocumentTab(tabId)} + value={activeTabId} + > +
+ { + if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) { + event.currentTarget.scrollLeft += event.deltaY; + } + }} + ref={listRef} + > + {tabs.map((tab, index) => ( + closeTab(tab.id)} + tab={tab} + /> + ))} + + + + } + > + + + {newDocumentLabel} + +
+ {tabs.map((tab) => ( + + + + ))} +
+ ); +} + +interface DocumentTabItemProps { + label: TabLabel; + onClose: () => void; + tab: DocumentTab; +} + +function DocumentTabItem({ label, onClose, tab }: DocumentTabItemProps) { + const { t } = useLocalization(); + const { document } = tab; + const closeLabel = t("shell.documentTabs.close", { name: label.name }); + + return ( + { + if (event.button === MIDDLE_MOUSE_BUTTON) { + event.preventDefault(); + onClose(); + } + }} + onMouseDown={(event) => { + // A middle press would otherwise start the webview's autoscroll. + if (event.button === MIDDLE_MOUSE_BUTTON) { + event.preventDefault(); + } + }} + title={document.status === "saved" ? document.path : undefined} + value={tab.id} + > + + {label.name} + {label.folder && {label.folder}} + + { + handleClosePress(event); + onClose(); + }} + onMouseDown={handleClosePress} + onPointerDown={(event) => event.stopPropagation()} + title={closeLabel} + > + {document.isDirty && ( + + )} + + + + ); +} diff --git a/src/components/layout/shell.test.tsx b/src/components/layout/shell.test.tsx index e00adcf1..513370eb 100644 --- a/src/components/layout/shell.test.tsx +++ b/src/components/layout/shell.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { getCurrentWindow } from "@tauri-apps/api/window"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useRecentItemsStore, useSettingsStore } from "@/features/preferences"; @@ -30,14 +31,22 @@ import { mockTauriApiCommand } from "@/test/utils/tauriApi"; import { Shell } from "./shell"; vi.mock("@/components/screens/document-screen", () => ({ - DocumentScreen: ({ activeDocument }: { activeDocument: { content: string } }) => ( -
{activeDocument.content}
+ DocumentScreen: ({ + active, + tab, + }: { + active: boolean; + tab: { document: { content: string } }; + }) => ( +
+ {tab.document.content} +
), })); vi.mock("@/features/session", async (importOriginal) => ({ ...(await importOriginal()), - useActiveDocumentWatcher: vi.fn(), + useDocumentWatchers: vi.fn(), useFolderContextWatcher: vi.fn(), })); @@ -395,6 +404,28 @@ describe("Shell", () => { expect(screen.getByTestId("document-workspace-host")).toHaveClass("pb-0"); }); + it("titles the window after the active document", async () => { + setDefaultSession({ + documents: [createSavedDocument(), createSavedDocument({ path: SPEC_MARKDOWN_PATH })], + }); + + render(); + + await waitFor(() => + expect(getCurrentWindow().setTitle).toHaveBeenLastCalledWith("readme.md - Leafdown"), + ); + + act(() => useSessionStore.getState().activateTab(useSessionStore.getState().tabs[1].id)); + + await waitFor(() => + expect(getCurrentWindow().setTitle).toHaveBeenLastCalledWith("spec.md - Leafdown"), + ); + + act(() => useSessionStore.getState().reset()); + + await waitFor(() => expect(getCurrentWindow().setTitle).toHaveBeenLastCalledWith("Leafdown")); + }); + it("has no status bar without an open document", () => { setDefaultSession({ folderContext: nestedFolderContext }); diff --git a/src/components/layout/shell.tsx b/src/components/layout/shell.tsx index eff39a08..61b69791 100644 --- a/src/components/layout/shell.tsx +++ b/src/components/layout/shell.tsx @@ -1,14 +1,15 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; -import { lazy, Suspense, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; -import { ARTICLE_NAVIGATOR_ENTRY_ACTIONS, useAppCommands } from "@/commands"; +import { ARTICLE_NAVIGATOR_ENTRY_ACTIONS, getCommandLabelId, useAppCommands } from "@/commands"; import { AboutDialog } from "@/components/layout/about-dialog"; import { CommandMenubar } from "@/components/layout/command-menubar"; import { CommandPalette } from "@/components/layout/command-palette"; import { ConfirmationDialog } from "@/components/layout/confirmation-dialog"; +import { DocumentTabs } from "@/components/layout/document-tabs"; import { KeyboardShortcutsDialog } from "@/components/layout/keyboard-shortcuts-dialog"; import { UnexpectedErrorBoundary } from "@/components/layout/unexpected-error-boundary"; -import { DocumentScreen } from "@/components/screens/document-screen"; import { EmptyFolderScreen } from "@/components/screens/empty-folder-screen"; import { FolderOnlyScreen } from "@/components/screens/folder-only-screen"; import { WelcomeScreen } from "@/components/screens/welcome-screen"; @@ -27,13 +28,14 @@ import { applyFolderReplacement, cancelFolderSearch, closeFolderSearch, + getDocumentName, getSessionMode, notifyOpenMarkdownFileError, openFolderSearchMatch, openMarkdownFileAtPath, searchFolderFurther, submitFolderSearch, - useActiveDocumentWatcher, + useDocumentWatchers, useFolderContextWatcher, useFolderSearchSession, useSessionStore, @@ -53,6 +55,8 @@ const DeveloperTools = import.meta.env.DEV }) : null; +const DEFAULT_WINDOW_TITLE = "Leafdown"; + const handleOpenArticle = (path: string) => { void openMarkdownFileAtPath(path).catch((error) => { notifyOpenMarkdownFileError(error); @@ -73,7 +77,7 @@ const handleApplyFolderReplacement = () => { export function Shell() { useFolderContextWatcher(); - useActiveDocumentWatcher(); + useDocumentWatchers(); useFolderSearchSession(); const localization = useLocalization(); @@ -91,6 +95,15 @@ export function Shell() { const sidebarAvailable = commands.commandState("view.toggleSidebar").enabled; const sidebarShown = sidebarAvailable && sidebarVisible; const statusBarShown = activeDocument !== null && statusBarVisible; + const windowTitle = activeDocument + ? t("shell.windowTitle", { name: getDocumentName(activeDocument, t) }) + : DEFAULT_WINDOW_TITLE; + + useEffect(() => { + void getCurrentWindow() + .setTitle(windowTitle) + .catch((error) => handleUnexpectedError(error, "setWindowTitle")); + }, [windowTitle]); return ( <> @@ -223,7 +236,12 @@ export function Shell() { {sessionMode === "folder-only" && folderContext && !folderContext.isEmpty && ( )} - {activeDocument && } + {activeDocument && ( + commands.executeCommand("file.new")} + /> + )} diff --git a/src/components/screens/document-screen.test.tsx b/src/components/screens/document-screen.test.tsx index 0202a128..ce285978 100644 --- a/src/components/screens/document-screen.test.tsx +++ b/src/components/screens/document-screen.test.tsx @@ -4,20 +4,30 @@ import { describe, expect, it } from "vitest"; import { getActiveDocumentKey } from "@/features/document"; import { useSettingsStore } from "@/features/preferences"; -import { documentEditorBridge, useSessionStore } from "@/features/session"; +import { documentEditorBridge, getActiveTab, useSessionStore } from "@/features/session"; import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; -import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; +import { + replaceActiveDocument, + setDefaultSession, + setDefaultSettings, +} from "@/test/utils/appStores"; import { act, render, renderWithUser, screen, waitFor, within } from "@/test/utils/react"; import { DocumentScreen } from "./document-screen"; const MARKDOWN = "# Notes\n\nBody text.\n"; +function ActiveDocumentScreen() { + const tab = useSessionStore(getActiveTab); + + return tab ? : null; +} + describe("DocumentScreen", () => { it("resets the viewport when another document replaces the active document", async () => { const activeDocument = createSavedDocument({ content: MARKDOWN }); setDefaultSession({ activeDocument }); - const { rerender } = render(); + render(); await waitFor(() => { expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready"); }); @@ -28,8 +38,7 @@ describe("DocumentScreen", () => { const replacement = createSavedDocument({ path: "C:/Notes/other.md", content: "# Other\n" }); act(() => { - useSessionStore.getState().setActiveDocument(replacement); - rerender(); + replaceActiveDocument(replacement); }); await waitFor(() => { expect(documentEditorBridge.getCommandState(replacement.path).status).toBe("ready"); @@ -47,7 +56,7 @@ describe("DocumentScreen", () => { it.each([false, true])("restores a same-document reload with focused=%s", async (focused) => { const activeDocument = createSavedDocument({ content: MARKDOWN }); setDefaultSession({ activeDocument }); - const { rerender } = render(); + render(); await waitFor(() => { expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready"); }); @@ -57,11 +66,10 @@ describe("DocumentScreen", () => { viewport.scrollTop = 600; const reloaded = { ...activeDocument, content: `${MARKDOWN}\nNew text.\n` }; act(() => { - useSessionStore.getState().setActiveDocument(reloaded, { + replaceActiveDocument(reloaded, { reload: true, viewState: { anchor: 10, head: 10, focused }, }); - rerender(); }); await waitFor(() => { expect(documentEditorBridge.getMarkdown(reloaded.path)).toBe(reloaded.content); @@ -86,7 +94,7 @@ describe("DocumentScreen", () => { setDefaultSession({ activeDocument }); const contentChanges: string[] = []; const subscription = documentEditorBridge.onDidChangeContent((key) => contentChanges.push(key)); - const { unmount } = render(); + const { unmount } = render(); await waitFor(() => { expect(documentEditorBridge.getCommandState(documentKey).status).toBe("ready"); }); @@ -94,9 +102,10 @@ describe("DocumentScreen", () => { act(() => { expect(documentEditorBridge.insertLink(documentKey, "Guide", "guide.md")).toBe(true); }); + // Reading the text settles the source projection around the new link, itself an edit. + subscription.dispose(); const edited = documentEditorBridge.getMarkdown(documentKey); - subscription.dispose(); expect(edited).toContain("[Guide](guide.md)"); expect(contentChanges).toEqual([documentKey]); expect(useSessionStore.getState().activeDocument).toMatchObject({ @@ -109,7 +118,7 @@ describe("DocumentScreen", () => { const retained = useSessionStore.getState().activeDocument!; expect(retained).toMatchObject({ content: edited, isDirty: true }); - render(); + render(); await waitFor(() => { expect(documentEditorBridge.getMarkdown(documentKey)).toBe(edited); }); @@ -122,7 +131,7 @@ describe("DocumentScreen", () => { }); const documentKey = getActiveDocumentKey(activeDocument); setDefaultSession({ activeDocument }); - const { user } = renderWithUser(); + const { user } = renderWithUser(); const outline = await screen.findByRole("navigation", { name: "Document outline" }); await waitFor(() => { @@ -155,7 +164,7 @@ describe("DocumentScreen", () => { const activeDocument = createUntitledDocument({ content: "Body text.\n" }); const documentKey = getActiveDocumentKey(activeDocument); setDefaultSession({ activeDocument }); - render(); + render(); await waitFor(() => { expect(documentEditorBridge.getCommandState(documentKey).status).toBe("ready"); @@ -169,7 +178,7 @@ describe("DocumentScreen", () => { const documentKey = getActiveDocumentKey(activeDocument); setDefaultSession({ activeDocument }); - render(); + render(); const host = screen.getByTestId("milkdown-editor-host"); await waitFor(() => { diff --git a/src/components/screens/document-screen.tsx b/src/components/screens/document-screen.tsx index 800c3144..423f6e9c 100644 --- a/src/components/screens/document-screen.tsx +++ b/src/components/screens/document-screen.tsx @@ -1,12 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useState } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - getActiveDocumentKey, - openMarkdownDocument, - type ActiveDocumentState, -} from "@/features/document"; +import { getActiveDocumentKey, openMarkdownDocument } from "@/features/document"; import { EMPTY_HEADING_OUTLINE, HeadingOutline, @@ -14,7 +10,6 @@ import { type HeadingOutlineState, type MilkdownEditorBridge, } from "@/features/editor"; -import { getArticlePaths } from "@/features/folder-context"; import { useSettingsStore } from "@/features/preferences"; import { documentEditorBridge, @@ -23,12 +18,15 @@ import { openMarkdownFileAtPath, retainEditorMarkdown, useSessionStore, + type DocumentTab, } from "@/features/session"; import { useLocalization } from "@/lib/i18n"; import { getPathIdentityKey } from "@/lib/path"; interface DocumentScreenProps { - activeDocument: ActiveDocumentState; + tab: DocumentTab; + active: boolean; + wikiCompletionPaths: string[]; } const handleOpenMarkdownPath = async (path: string, heading?: string) => { @@ -42,7 +40,7 @@ const handleOpenMarkdownPath = async (path: string, heading?: string) => { } }; -export function DocumentScreen({ activeDocument }: DocumentScreenProps) { +export function DocumentScreen({ active, tab, wikiCompletionPaths }: DocumentScreenProps) { const { t } = useLocalization(); const autoPairBracketsAndQuotes = useSettingsStore((state) => state.autoPairBracketsAndQuotes); const displayCodeBlockLineNumbers = useSettingsStore( @@ -54,18 +52,11 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { const lineSpacing = useSettingsStore((state) => state.lineSpacing); const outlineDepth = useSettingsStore((state) => state.outlineDepth); const updateSetting = useSettingsStore((state) => state.updateSetting); - const folderContext = useSessionStore((state) => state.folderContext); const folderContextPath = useSessionStore(getSessionFolderPath); - const wikiCompletionPaths = useMemo( - () => (folderContext ? getArticlePaths(folderContext.tree) : []), - [folderContext], - ); - const markActiveDocumentDirty = useSessionStore((state) => state.markActiveDocumentDirty); - const loadId = useSessionStore((state) => state.activeDocumentLoadId); - const initialViewState = useSessionStore((state) => state.activeDocumentViewState); - const documentKey = getActiveDocumentKey(activeDocument); - const viewportKey = - activeDocument.status === "saved" ? getPathIdentityKey(activeDocument.path) : activeDocument.id; + const markDocumentDirty = useSessionStore((state) => state.markDocumentDirty); + const { document, loadId, viewState } = tab; + const documentKey = getActiveDocumentKey(document); + const viewportKey = document.status === "saved" ? getPathIdentityKey(document.path) : document.id; const editorKey = `${documentKey}:${loadId}`; const [publishedOutline, setPublishedOutline] = useState<{ editorKey: string; @@ -81,10 +72,17 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { }, [documentKey], ); + + useLayoutEffect(() => { + if (active) { + documentEditorBridge.show(documentKey); + } + }, [active, documentKey]); + return (
@@ -97,9 +95,9 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { (await openMarkdownDocument(path)).content} @@ -111,7 +109,7 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { textSize={textSize} lineSpacing={lineSpacing} onContentChanged={() => { - markActiveDocumentDirty(documentKey); + markDocumentDirty(documentKey); documentEditorBridge.fireContentChanged(documentKey); }} onCommandStateChanged={documentEditorBridge.fireCommandStateChanged} diff --git a/src/features/editor/hooks/useMilkdownEditorInstance.ts b/src/features/editor/hooks/useMilkdownEditorInstance.ts index 99ed199d..fb570d2f 100644 --- a/src/features/editor/hooks/useMilkdownEditorInstance.ts +++ b/src/features/editor/hooks/useMilkdownEditorInstance.ts @@ -108,6 +108,8 @@ export interface MilkdownEditorBridge { getCommandState?: () => EditorCommandState; getDocumentStatus?: () => EditorDocumentStatus; getViewState?: () => EditorViewState | null; + /** Focuses the editor where its selection is, without scrolling. */ + focus?: () => void; insertLink?: (label: string, target: string) => boolean; navigateToHeading?: (heading: string) => void; navigateToOutlineHeading?: (position: number) => boolean; @@ -315,6 +317,11 @@ export const useMilkdownEditorInstance = ({ pendingSearchMatchRef.current = { query, target, resolve }; }); }, + focus: () => { + if (editorRef.current?.ctx) { + editorRef.current.ctx.get(editorViewCtx).focus(); + } + }, focusChosenSearchMatch: () => { if (editorRef.current?.ctx) { focusChosenSearchMatch(editorRef.current.ctx.get(editorViewCtx)); diff --git a/src/features/folder-search/index.ts b/src/features/folder-search/index.ts index b64ea46d..647c886c 100644 --- a/src/features/folder-search/index.ts +++ b/src/features/folder-search/index.ts @@ -17,7 +17,7 @@ export { FolderSearchEngine, hasCompleteReplacementPlan, type FolderReplaceFilePlan, - type FolderSearchActiveDocument, + type FolderSearchOpenDocument, type FolderSearchFileResult, type FolderSearchMatch, type FolderSearchRequest, diff --git a/src/features/folder-search/services/folderSearchEngine.test.ts b/src/features/folder-search/services/folderSearchEngine.test.ts index d3ac95ff..1f6b87fd 100644 --- a/src/features/folder-search/services/folderSearchEngine.test.ts +++ b/src/features/folder-search/services/folderSearchEngine.test.ts @@ -7,12 +7,13 @@ import { type MarkdownSearchTextParser, type TextSearchQuery, } from "@/features/editor"; +import { isSamePath } from "@/lib/path"; import type { FolderSearchFileOutcome, ReadFolderSearchFilesArgs } from "./folderSearchApi"; import { FOLDER_SEARCH_MATCH_LIMIT, FolderSearchEngine, - type FolderSearchActiveDocument, + type FolderSearchOpenDocument, type FolderSearchResults, } from "./folderSearchEngine"; @@ -173,7 +174,7 @@ const createDisk = (files: Record) => { const createEngine = ( disk: ReturnType, - activeDocument: () => FolderSearchActiveDocument | null = () => null, + activeDocument: () => FolderSearchOpenDocument | null = () => null, ) => { const parser = createParser(); const published: (FolderSearchResults | null)[] = []; @@ -181,6 +182,11 @@ const createEngine = ( { createParser: async () => parser, getActiveDocument: activeDocument, + getOpenDocument: (path) => { + const open = activeDocument(); + + return open && isSamePath(open.path, path) ? open : null; + }, readFiles: disk.readFiles, }, (results) => published.push(results), @@ -405,7 +411,7 @@ describe("folder search engine", () => { it("never lets an older read replace a newer one", async () => { const disk = createDisk({ "a.md": "leaf" }); - let active: FolderSearchActiveDocument | null = null; + let active: FolderSearchOpenDocument | null = null; const { engine, search, settled } = createEngine(disk, () => active); await search("leaf"); diff --git a/src/features/folder-search/services/folderSearchEngine.ts b/src/features/folder-search/services/folderSearchEngine.ts index 82e1b29e..c8c3d499 100644 --- a/src/features/folder-search/services/folderSearchEngine.ts +++ b/src/features/folder-search/services/folderSearchEngine.ts @@ -103,8 +103,8 @@ export interface FolderSearchResults { skipped: FolderSearchSkippedFile[]; } -/** The document the editor holds, searched as it stands rather than as its file does. */ -export interface FolderSearchActiveDocument { +/** A document an editor holds, searched as it stands rather than as its file does. */ +export interface FolderSearchOpenDocument { path: string; readMatches: ( query: TextSearchQuery, @@ -114,7 +114,9 @@ export interface FolderSearchActiveDocument { } export interface FolderSearchHost { - getActiveDocument: () => FolderSearchActiveDocument | null; + /** The document on screen, whose source projection a new search settles before reading it. */ + getActiveDocument: () => FolderSearchOpenDocument | null; + getOpenDocument: (path: string) => FolderSearchOpenDocument | null; /** Text read by earlier searches, which the host keeps, and clears, for as long as it holds. */ textCache?: FolderSearchTextCache; readFiles?: (args: ReadFolderSearchFilesArgs) => Promise; @@ -346,7 +348,7 @@ export class FolderSearchEngine implements Disposable { // Read from the editor at once, so a caller that refreshes and then marks a match knows the // results it marks are the ones shown. - if (this.writeActiveDocument(search, path, capacity)) { + if (this.writeOpenDocument(search, path, capacity)) { this.settleIfIdle(search); this.publishNow(); return; @@ -423,7 +425,7 @@ export class FolderSearchEngine implements Disposable { const capacity = search.matchLimit - keptMatchCount; - if (!this.writeActiveDocument(search, path, capacity)) { + if (!this.writeOpenDocument(search, path, capacity)) { this.applyOutcome(search, path, outcome, parser, capacity); } @@ -512,18 +514,18 @@ export class FolderSearchEngine implements Disposable { return cached.metadata; } - private writeActiveDocument(search: ActiveSearch, path: string, capacity = Infinity) { - const active = this.host.getActiveDocument(); + private writeOpenDocument(search: ActiveSearch, path: string, capacity = Infinity) { + const open = this.host.getOpenDocument(path); - if (!active || !isSamePath(active.path, path)) { + if (!open) { return false; } if (search.replacement) { - return this.writeActiveDocumentReplacement(search, active, path); + return this.writeOpenDocumentReplacement(search, open, path); } - const found = active.readMatches(search.query, { finalizeProjection: false }); + const found = open.readMatches(search.query, { finalizeProjection: false }); if (!found) { return false; @@ -545,12 +547,12 @@ export class FolderSearchEngine implements Disposable { return true; } - private writeActiveDocumentReplacement( + private writeOpenDocumentReplacement( search: ActiveSearch, - active: FolderSearchActiveDocument, + open: FolderSearchOpenDocument, path: string, ) { - const plan = active.planReplacement(search.query, search.replacement?.text ?? ""); + const plan = open.planReplacement(search.query, search.replacement?.text ?? ""); if (!plan) { return false; diff --git a/src/features/session/hooks/useActiveDocumentWatcher.test.ts b/src/features/session/hooks/useDocumentWatchers.test.ts similarity index 76% rename from src/features/session/hooks/useActiveDocumentWatcher.test.ts rename to src/features/session/hooks/useDocumentWatchers.test.ts index 324ea576..41a9f46c 100644 --- a/src/features/session/hooks/useActiveDocumentWatcher.test.ts +++ b/src/features/session/hooks/useDocumentWatchers.test.ts @@ -11,7 +11,7 @@ import { toastManager } from "@/lib/toast"; import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; import { createFolderContext } from "@/test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH } from "@/test/fixtures/paths"; -import { setDefaultSession } from "@/test/utils/appStores"; +import { closeActiveTab, replaceActiveDocument, setDefaultSession } from "@/test/utils/appStores"; import { findDiagnosticPayload } from "@/test/utils/diagnostics"; import { act, renderHook } from "@/test/utils/react"; import { getWindowListenHandler } from "@/test/utils/tauri"; @@ -21,8 +21,8 @@ import { useSessionStore } from "../stores/session"; import { DOCUMENT_WATCH_CHECK_DELAY_MS, resetDocumentWatcherScopeGenerationForTests, - useActiveDocumentWatcher, -} from "./useActiveDocumentWatcher"; + useDocumentWatchers, +} from "./useDocumentWatchers"; const OUTSIDE_MARKDOWN_PATH = "C:/Elsewhere/outside.md"; const RENAMED_MARKDOWN_PATH = "C:/Notes/intro.md"; @@ -48,7 +48,7 @@ const settle = async (delayMs = DOCUMENT_WATCH_CHECK_DELAY_MS) => { }); }; -describe("useActiveDocumentWatcher", () => { +describe("useDocumentWatchers", () => { beforeEach(() => { vi.useFakeTimers(); resetDocumentWatcherScopeGenerationForTests(); @@ -60,10 +60,9 @@ describe("useActiveDocumentWatcher", () => { }); it("does not watch without a saved document", async () => { - const { rerender } = renderHook(() => useActiveDocumentWatcher()); + setDefaultSession({ activeDocument: createUntitledDocument() }); - act(() => useSessionStore.getState().setActiveDocument(createUntitledDocument())); - rerender(); + renderHook(() => useDocumentWatchers()); await settle(); expect(getCurrentWindow().listen).not.toHaveBeenCalled(); @@ -78,7 +77,7 @@ describe("useActiveDocumentWatcher", () => { ])("watches a saved document %s and checks it once watching", async (_, path, folderContext) => { setDefaultSession({ activeDocument: createSavedDocument({ path }), folderContext }); - renderHook(() => useActiveDocumentWatcher()); + renderHook(() => useDocumentWatchers()); await settle(); expect(getCurrentWindow().listen).toHaveBeenCalledWith( @@ -102,7 +101,7 @@ describe("useActiveDocumentWatcher", () => { it("checks the document once for a burst of change events", async () => { setDefaultSession({ activeDocument: createSavedDocument() }); - renderHook(() => useActiveDocumentWatcher()); + renderHook(() => useDocumentWatchers()); await settle(); emitDocumentChanged(TEST_MARKDOWN_FILE_PATH); @@ -119,7 +118,7 @@ describe("useActiveDocumentWatcher", () => { it("ignores change events for another document", async () => { setDefaultSession({ activeDocument: createSavedDocument() }); - renderHook(() => useActiveDocumentWatcher()); + renderHook(() => useDocumentWatchers()); await settle(); emitDocumentChanged(OUTSIDE_MARKDOWN_PATH); @@ -132,14 +131,10 @@ describe("useActiveDocumentWatcher", () => { const unlisten = vi.fn(); vi.mocked(getCurrentWindow().listen).mockResolvedValue(unlisten); setDefaultSession({ activeDocument: createSavedDocument() }); - const { rerender } = renderHook(() => useActiveDocumentWatcher()); + const { rerender } = renderHook(() => useDocumentWatchers()); await settle(); - act(() => - useSessionStore - .getState() - .setActiveDocument(createSavedDocument({ path: RENAMED_MARKDOWN_PATH })), - ); + act(() => replaceActiveDocument(createSavedDocument({ path: RENAMED_MARKDOWN_PATH }))); rerender(); await settle(); @@ -152,7 +147,7 @@ describe("useActiveDocumentWatcher", () => { scopeId: "document-watch:2", }); - act(() => useSessionStore.getState().setActiveDocument(null)); + act(() => closeActiveTab()); rerender(); await settle(); @@ -162,13 +157,49 @@ describe("useActiveDocumentWatcher", () => { }); }); + it("watches each open saved document and stops only the one whose tab closes", async () => { + setDefaultSession({ + documents: [ + createSavedDocument(), + createUntitledDocument(), + createSavedDocument({ path: OUTSIDE_MARKDOWN_PATH }), + ], + }); + renderHook(() => useDocumentWatchers()); + await settle(); + + expect(countTauriApiCalls("watchMarkdownDocument")).toBe(2); + expect(countTauriApiCalls("inspectMarkdownFile")).toBe(2); + + emitDocumentChanged(OUTSIDE_MARKDOWN_PATH); + await settle(); + + expect(getLastTauriApiArgs("inspectMarkdownFile")).toMatchObject({ + path: OUTSIDE_MARKDOWN_PATH, + }); + + act(() => useSessionStore.getState().closeTabs([useSessionStore.getState().tabs[2].id])); + await settle(); + + expect(countTauriApiCalls("unwatchMarkdownDocument")).toBe(1); + expect(getLastTauriApiArgs("unwatchMarkdownDocument")).toEqual({ + scopeId: "document-watch:2", + }); + + act(() => useSessionStore.getState().activateTab(useSessionStore.getState().tabs[1].id)); + await settle(); + + expect(countTauriApiCalls("watchMarkdownDocument")).toBe(2); + expect(countTauriApiCalls("unwatchMarkdownDocument")).toBe(1); + }); + it("does not stop a watch it closed before asking to start", async () => { const listener = Promise.withResolvers<() => void>(); vi.mocked(getCurrentWindow().listen).mockReturnValue(listener.promise); setDefaultSession({ activeDocument: createSavedDocument() }); - const { rerender } = renderHook(() => useActiveDocumentWatcher()); + const { rerender } = renderHook(() => useDocumentWatchers()); - act(() => useSessionStore.getState().setActiveDocument(null)); + act(() => closeActiveTab()); rerender(); listener.resolve(vi.fn<() => void>()); await settle(); @@ -183,7 +214,7 @@ describe("useActiveDocumentWatcher", () => { watchMarkdownDocument: () => Promise.reject({ kind: "watcherStateFailed", message: "poisoned" }), }); - const { unmount } = renderHook(() => useActiveDocumentWatcher()); + const { unmount } = renderHook(() => useDocumentWatchers()); await settle(); unmount(); @@ -195,11 +226,11 @@ describe("useActiveDocumentWatcher", () => { it("drops a pending check when the document closes", async () => { setDefaultSession({ activeDocument: createSavedDocument() }); - const { rerender } = renderHook(() => useActiveDocumentWatcher()); + const { rerender } = renderHook(() => useDocumentWatchers()); await settle(); emitDocumentChanged(TEST_MARKDOWN_FILE_PATH); - act(() => useSessionStore.getState().setActiveDocument(null)); + act(() => closeActiveTab()); rerender(); await settle(); @@ -218,7 +249,7 @@ describe("useActiveDocumentWatcher", () => { inspectMarkdownFile: () => ({ kind: "missing" }), }); - renderHook(() => useActiveDocumentWatcher()); + renderHook(() => useDocumentWatchers()); await settle(); expect( diff --git a/src/features/session/hooks/useActiveDocumentWatcher.ts b/src/features/session/hooks/useDocumentWatchers.ts similarity index 72% rename from src/features/session/hooks/useActiveDocumentWatcher.ts rename to src/features/session/hooks/useDocumentWatchers.ts index ae347bf3..23e70eab 100644 --- a/src/features/session/hooks/useActiveDocumentWatcher.ts +++ b/src/features/session/hooks/useDocumentWatchers.ts @@ -15,11 +15,11 @@ import { import { DebouncedTaskRunner } from "@/lib/async"; import { isCancellationError } from "@/lib/cancellation"; import { getErrorDescription, handleUnexpectedError } from "@/lib/errors"; -import { MutableDisposable } from "@/lib/lifecycle"; -import { isSamePath } from "@/lib/path"; +import { MutableDisposable, type Disposable } from "@/lib/lifecycle"; +import { isSamePath, PathMap, PathSet } from "@/lib/path"; -import { checkActiveDocumentFile } from "../services/externalChanges"; -import { useSessionStore } from "../stores/session"; +import { checkDocumentFile } from "../services/externalChanges"; +import { useSessionStore, type DocumentTab } from "../stores/session"; export const DOCUMENT_WATCH_CHECK_DELAY_MS = 150; let nextDocumentWatcherScopeGeneration = 0; @@ -31,37 +31,74 @@ export const resetDocumentWatcherScopeGenerationForTests = () => { nextDocumentWatcherScopeGeneration = 0; }; -/** Watches the active saved document on its own, whether or not the folder context contains it. */ -export const useActiveDocumentWatcher = () => { - const documentPath = useSessionStore((state) => - state.activeDocument?.status === "saved" ? state.activeDocument.path : null, - ); - +/** Watches each open saved document on its own, whether or not the folder context contains it. */ +export const useDocumentWatchers = () => { useEffect(() => { - if (!documentPath) { - return; - } + const watchers = new DocumentWatchers(); - const session = new ActiveDocumentWatchSession(documentPath); - session.start(); + watchers.sync(useSessionStore.getState().tabs); + const unsubscribe = useSessionStore.subscribe((state, previous) => { + if (state.tabs !== previous.tabs) { + watchers.sync(state.tabs); + } + }); return () => { - session.dispose(); + unsubscribe(); + watchers.dispose(); }; - }, [documentPath]); + }, []); }; +class DocumentWatchers implements Disposable { + private readonly sessions = new PathMap(); + private readonly paths = new PathSet(); + + sync(tabs: readonly DocumentTab[]) { + const nextPaths = new PathSet( + tabs.flatMap(({ document }) => (document.status === "saved" ? [document.path] : [])), + ); + + for (const path of this.paths) { + if (!nextPaths.has(path)) { + this.sessions.get(path)?.dispose(); + this.sessions.delete(path); + this.paths.delete(path); + } + } + + for (const path of nextPaths) { + if (!this.paths.has(path)) { + const session = new DocumentWatchSession(path); + + this.sessions.set(path, session); + this.paths.add(path); + session.start(); + } + } + } + + dispose() { + for (const path of this.paths) { + this.sessions.get(path)?.dispose(); + } + + this.sessions.clear(); + this.paths.clear(); + } +} + const createDocumentWatcherScopeId = () => { nextDocumentWatcherScopeGeneration += 1; return `document-watch:${nextDocumentWatcherScopeGeneration}`; }; -class ActiveDocumentWatchSession { +class DocumentWatchSession { private readonly appWindow = getCurrentWindow(); private readonly nativeWatchScopeId = createDocumentWatcherScopeId(); private readonly checkRunner = new DebouncedTaskRunner( - checkActiveDocumentFile, + (cancellationToken) => checkDocumentFile(this.documentPath, cancellationToken), DOCUMENT_WATCH_CHECK_DELAY_MS, ); private readonly changedListener = new MutableDisposable<() => void>(); @@ -121,7 +158,7 @@ class ActiveDocumentWatchSession { void this.checkRunner.run().catch((error) => { if (!isCancellationError(error)) { - handleUnexpectedError(error, "checkActiveDocumentFile"); + handleUnexpectedError(error, "checkDocumentFile"); } }); } diff --git a/src/features/session/index.ts b/src/features/session/index.ts index 32277c44..c9d8901b 100644 --- a/src/features/session/index.ts +++ b/src/features/session/index.ts @@ -1,13 +1,14 @@ export { DroppedPathOverlay } from "./components/dropped-path-overlay"; -export { useActiveDocumentWatcher } from "./hooks/useActiveDocumentWatcher"; +export { useDocumentWatchers } from "./hooks/useDocumentWatchers"; export { useFolderContextWatcher } from "./hooks/useFolderContextWatcher"; export { useFolderSearchSession } from "./hooks/useFolderSearchSession"; export { useDroppedPathListener, type DroppedPathIndicator } from "./hooks/useDroppedPathListener"; -export { confirmDiscardActiveDocumentChanges } from "./services/unsavedChanges"; +export { confirmDiscardAllDocumentChanges } from "./services/unsavedChanges"; export { declineWindowClose, WINDOW_CLOSE_REQUESTED_EVENT } from "./services/windowCloseApi"; export { documentEditorBridge } from "./services/documentEditorBridge"; export { closeActiveMarkdownDocument, + closeDocumentTab, createNewMarkdownDocument, retainEditorMarkdown, saveActiveMarkdownDocument, @@ -44,12 +45,16 @@ export { pickAndOpenMarkdownFile, reopenMarkdownFileWithChosenEncoding, } from "./services/openSession"; +export { activateAdjacentTab, activateDocumentTab, activateTabAt } from "./services/tabWorkflows"; export { + getActiveTab, getSessionFolderPath, getSessionMode, useSessionStore, + type DocumentTab, type FolderContextLoad, type SessionMode, type SessionState, type SessionStore, } from "./stores/session"; +export { getDocumentName } from "./utils/documentNames"; diff --git a/src/features/session/services/documentEditorBridge.test.ts b/src/features/session/services/documentEditorBridge.test.ts index 7917f337..9602ebcf 100644 --- a/src/features/session/services/documentEditorBridge.test.ts +++ b/src/features/session/services/documentEditorBridge.test.ts @@ -65,16 +65,94 @@ describe("document editor bridge", () => { listenerDisposable.dispose(); }); - it("navigates outline headings only in the current document", () => { + it("keeps each open document's editor while another document's editor arrives", () => { const navigateToOutlineHeading = vi.fn(() => true); documentEditorBridge.set("doc:first", createMilkdownEditorBridge({ navigateToOutlineHeading })); expect(documentEditorBridge.navigateToOutlineHeading("doc:first", 0)).toBe(true); expect(documentEditorBridge.navigateToOutlineHeading("doc:other", 0)).toBe(false); - documentEditorBridge.set("doc:second", createMilkdownEditorBridge()); + documentEditorBridge.set( + "doc:second", + createMilkdownEditorBridge({ getMarkdown: () => "# Second" }), + ); + + expect(documentEditorBridge.navigateToOutlineHeading("doc:first", 0)).toBe(true); + expect(documentEditorBridge.getMarkdown("doc:second")).toBe("# Second"); + expect(navigateToOutlineHeading).toHaveBeenCalledTimes(2); + + documentEditorBridge.set("doc:first", null); expect(documentEditorBridge.navigateToOutlineHeading("doc:first", 0)).toBe(false); - expect(navigateToOutlineHeading).toHaveBeenCalledTimes(1); + }); + + it("navigates to a requested heading once its document is shown", () => { + const navigateToHeading = vi.fn(); + documentEditorBridge.set("doc:first", createMilkdownEditorBridge({ navigateToHeading })); + documentEditorBridge.show("doc:other"); + + documentEditorBridge.requestHeading("doc:first", "Setup"); + + expect(navigateToHeading).not.toHaveBeenCalled(); + + documentEditorBridge.show("doc:first"); + + expect(navigateToHeading).toHaveBeenCalledExactlyOnceWith("Setup"); + }); + + it("drops a requested heading when another document is shown first", () => { + const navigateToHeading = vi.fn(); + documentEditorBridge.set("doc:first", createMilkdownEditorBridge({ navigateToHeading })); + + documentEditorBridge.requestHeading("doc:first", "Setup"); + documentEditorBridge.show("doc:other"); + documentEditorBridge.show("doc:first"); + + expect(navigateToHeading).not.toHaveBeenCalled(); + }); + + it("chooses a search match once its document's editor is ready and shown", async () => { + const chooseSearchMatch = vi.fn(async () => 2); + const query = { caseSensitive: false, text: "leaf", wholeWord: false }; + const target = { + ordinal: 2, + context: { before: "", match: "leaf", after: "", clippedBefore: false, clippedAfter: false }, + }; + + const choosing = documentEditorBridge.chooseSearchMatch("doc:first", query, target); + + documentEditorBridge.set("doc:first", createMilkdownEditorBridge({ chooseSearchMatch })); + expect(chooseSearchMatch).not.toHaveBeenCalled(); + + documentEditorBridge.show("doc:first"); + + await expect(choosing).resolves.toBe(2); + expect(chooseSearchMatch).toHaveBeenCalledWith(query, target); + }); + + it("resolves a pending search match to null when another document is shown", async () => { + const query = { caseSensitive: false, text: "leaf", wholeWord: false }; + + const choosing = documentEditorBridge.chooseSearchMatch("doc:first", query, { + ordinal: 0, + context: { before: "", match: "leaf", after: "", clippedBefore: false, clippedAfter: false }, + }); + documentEditorBridge.show("doc:other"); + + await expect(choosing).resolves.toBeNull(); + }); + + it("focuses an editor once its document is shown", () => { + const focus = vi.fn(); + documentEditorBridge.set("doc:first", createMilkdownEditorBridge({ focus })); + documentEditorBridge.show("doc:other"); + + documentEditorBridge.focusWhenShown("doc:first"); + + expect(focus).not.toHaveBeenCalled(); + + documentEditorBridge.show("doc:first"); + + expect(focus).toHaveBeenCalledOnce(); }); it("returns inactive command state for stale document keys", () => { diff --git a/src/features/session/services/documentEditorBridge.ts b/src/features/session/services/documentEditorBridge.ts index 1685bebd..31f2cc43 100644 --- a/src/features/session/services/documentEditorBridge.ts +++ b/src/features/session/services/documentEditorBridge.ts @@ -13,14 +13,9 @@ import { type EditorCommandId, type EditorCommandState, } from "@/features/editor/commands/contract"; -import { isSamePath } from "@/lib/path"; +import { isSameNullablePath, isSamePath, PathMap } from "@/lib/path"; import { SignalSource } from "@/lib/signal"; -interface ActiveDocumentEditorBridgeEntry { - bridge: MilkdownEditorBridge; - documentKey: string; -} - interface PendingSearchMatch { documentKey: string; query: TextSearchQuery; @@ -28,8 +23,15 @@ interface PendingSearchMatch { resolve: (ordinal: number | null) => void; } +/** + * Holds the editor of every open document by document key. Every tab keeps its editor mounted, so + * text can be read from any of them, while requests that scroll or focus wait until their + * document's editor is the one shown. + */ class DocumentEditorBridgeStore { - private activeBridgeEntry: ActiveDocumentEditorBridgeEntry | null = null; + private readonly bridges = new PathMap(); + private shownDocumentKey: string | null = null; + private pendingFocus: string | null = null; private pendingHeading: { documentKey: string; heading: string } | null = null; private pendingSearchMatch: PendingSearchMatch | null = null; private readonly commandStateChanged = new SignalSource(); @@ -43,8 +45,7 @@ class DocumentEditorBridgeStore { set = (documentKey: string, bridge: MilkdownEditorBridge | null) => { if (!bridge) { - if (this.activeBridgeEntry?.documentKey === documentKey) { - this.activeBridgeEntry = null; + if (this.bridges.delete(documentKey)) { this.fireCommandStateChanged(); this.fireDocumentStatusChanged(); } @@ -52,63 +53,69 @@ class DocumentEditorBridgeStore { return; } - this.activeBridgeEntry = { bridge, documentKey }; - if (this.pendingHeading && isSamePath(this.pendingHeading.documentKey, documentKey)) { - bridge.navigateToHeading?.(this.pendingHeading.heading); - this.pendingHeading = null; - } - this.settlePendingSearchMatch(documentKey, bridge); + this.bridges.set(documentKey, bridge); + this.settlePendingRequests(); this.fireCommandStateChanged(); this.fireDocumentStatusChanged(); }; - getMarkdown = (documentKey: string) => { - if (this.activeBridgeEntry?.documentKey !== documentKey) { - return null; + /** + * Names the document whose editor is on screen. A request waiting for another document is + * dropped, since that document was not the one shown next. + */ + show = (documentKey: string) => { + this.shownDocumentKey = documentKey; + + if (this.pendingFocus !== null && !isSamePath(this.pendingFocus, documentKey)) { + this.pendingFocus = null; + } + + if (this.pendingHeading && !isSamePath(this.pendingHeading.documentKey, documentKey)) { + this.pendingHeading = null; } - return this.activeBridgeEntry.bridge.getMarkdown(); + if (this.pendingSearchMatch && !isSamePath(this.pendingSearchMatch.documentKey, documentKey)) { + this.pendingSearchMatch.resolve(null); + this.pendingSearchMatch = null; + } + + this.settlePendingRequests(); }; + getMarkdown = (documentKey: string) => this.bridges.get(documentKey)?.getMarkdown() ?? null; + getHtmlExportSnapshot = (documentKey: string) => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.getHtmlExportSnapshot?.() ?? null) - : null; + this.bridges.get(documentKey)?.getHtmlExportSnapshot?.() ?? null; - getCommandState = (documentKey: string): EditorCommandState => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.getCommandState?.() ?? READY_DISABLED_EDITOR_COMMAND_STATE) + getCommandState = (documentKey: string): EditorCommandState => { + const bridge = this.bridges.get(documentKey); + + return bridge + ? (bridge.getCommandState?.() ?? READY_DISABLED_EDITOR_COMMAND_STATE) : INACTIVE_EDITOR_COMMAND_STATE; + }; getDocumentStatus = (documentKey: string): EditorDocumentStatus | null => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.getDocumentStatus?.() ?? null) - : null; + this.bridges.get(documentKey)?.getDocumentStatus?.() ?? null; getViewState = (documentKey: string): EditorViewState | null => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.getViewState?.() ?? null) - : null; + this.bridges.get(documentKey)?.getViewState?.() ?? null; - navigateToOutlineHeading = (documentKey: string, position: number) => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.navigateToOutlineHeading?.(position) ?? false) - : false; + /** Focuses the document's editor once it is shown, as it is after switching to its tab. */ + focusWhenShown = (documentKey: string) => { + this.pendingFocus = documentKey; + this.settlePendingRequests(); + }; - insertLink = (documentKey: string, label: string, target: string) => { - if (this.activeBridgeEntry?.documentKey !== documentKey) { - return false; - } + navigateToOutlineHeading = (documentKey: string, position: number) => + this.bridges.get(documentKey)?.navigateToOutlineHeading?.(position) ?? false; - return this.activeBridgeEntry.bridge.insertLink?.(label, target) ?? false; - }; + insertLink = (documentKey: string, label: string, target: string) => + this.bridges.get(documentKey)?.insertLink?.(label, target) ?? false; requestHeading = (documentKey: string, heading: string) => { - if (this.activeBridgeEntry && isSamePath(this.activeBridgeEntry.documentKey, documentKey)) { - this.activeBridgeEntry.bridge.navigateToHeading?.(heading); - return; - } this.pendingHeading = { documentKey, heading }; + this.settlePendingRequests(); }; readSearchMatches = ( @@ -116,18 +123,14 @@ class DocumentEditorBridgeStore { query: TextSearchQuery, options: { finalizeProjection: boolean }, ): DocumentSearchMatches | null => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.readSearchMatches?.(query, options) ?? null) - : null; + this.bridges.get(documentKey)?.readSearchMatches?.(query, options) ?? null; planSearchReplacement = ( documentKey: string, query: TextSearchQuery, replacement: string, ): DocumentReplacementPlan | null => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.planSearchReplacement?.(query, replacement) ?? null) - : null; + this.bridges.get(documentKey)?.planSearchReplacement?.(query, replacement) ?? null; applySearchReplacement = ( documentKey: string, @@ -135,15 +138,12 @@ class DocumentEditorBridgeStore { replacement: string, baseline: string, ): number | null => - this.activeBridgeEntry?.documentKey === documentKey - ? (this.activeBridgeEntry.bridge.applySearchReplacement?.(query, replacement, baseline) ?? - null) - : null; + this.bridges.get(documentKey)?.applySearchReplacement?.(query, replacement, baseline) ?? null; /** - * Chooses a match in the document once its editor is ready, which may be after the document was - * just opened. Resolves to the match's place, or `null` when it is gone or another document took - * the editor first. + * Chooses a match in the document once its editor is ready and shown, which may be after the + * document was just opened. Resolves to the match's place, or `null` when it is gone or another + * request took its place first. */ chooseSearchMatch = ( documentKey: string, @@ -151,39 +151,32 @@ class DocumentEditorBridgeStore { target: SearchMatchTarget, ): Promise => { this.pendingSearchMatch?.resolve(null); - this.pendingSearchMatch = null; - - if (this.activeBridgeEntry && isSamePath(this.activeBridgeEntry.documentKey, documentKey)) { - return ( - this.activeBridgeEntry.bridge.chooseSearchMatch?.(query, target) ?? Promise.resolve(null) - ); - } return new Promise((resolve) => { this.pendingSearchMatch = { documentKey, query, target, resolve }; + this.settlePendingRequests(); }); }; focusChosenSearchMatch = (documentKey: string) => { - if (this.activeBridgeEntry?.documentKey !== documentKey) { + const bridge = this.bridges.get(documentKey); + + if (!bridge) { return false; } - this.activeBridgeEntry.bridge.focusChosenSearchMatch?.(); + bridge.focusChosenSearchMatch?.(); return true; }; - runCommand = (documentKey: string, commandId: EditorCommandId) => { - if (this.activeBridgeEntry?.documentKey !== documentKey) { - return false; - } - - return this.activeBridgeEntry.bridge.runCommand?.(commandId) ?? false; - }; + runCommand = (documentKey: string, commandId: EditorCommandId) => + this.bridges.get(documentKey)?.runCommand?.(commandId) ?? false; clear = () => { - this.activeBridgeEntry = null; + this.bridges.clear(); + this.shownDocumentKey = null; + this.pendingFocus = null; this.pendingHeading = null; this.pendingSearchMatch?.resolve(null); this.pendingSearchMatch = null; @@ -191,23 +184,45 @@ class DocumentEditorBridgeStore { this.fireDocumentStatusChanged(); }; - private settlePendingSearchMatch(documentKey: string, bridge: MilkdownEditorBridge) { - const pending = this.pendingSearchMatch; + private getShownBridge(documentKey: string) { + return isSameNullablePath(this.shownDocumentKey, documentKey) + ? this.bridges.get(documentKey) + : undefined; + } + + private settlePendingRequests() { + const focusBridge = this.pendingFocus !== null && this.getShownBridge(this.pendingFocus); + + if (focusBridge) { + this.pendingFocus = null; + focusBridge.focus?.(); + } + + const heading = this.pendingHeading; + const headingBridge = heading && this.getShownBridge(heading.documentKey); + + if (heading && headingBridge) { + this.pendingHeading = null; + headingBridge.navigateToHeading?.(heading.heading); + } + + const searchMatch = this.pendingSearchMatch; + const searchMatchBridge = searchMatch && this.getShownBridge(searchMatch.documentKey); - if (!pending) { + if (!searchMatch || !searchMatchBridge) { return; } this.pendingSearchMatch = null; - if (!isSamePath(pending.documentKey, documentKey) || !bridge.chooseSearchMatch) { - pending.resolve(null); + if (!searchMatchBridge.chooseSearchMatch) { + searchMatch.resolve(null); return; } - void bridge - .chooseSearchMatch(pending.query, pending.target) - .then(pending.resolve, () => pending.resolve(null)); + void searchMatchBridge + .chooseSearchMatch(searchMatch.query, searchMatch.target) + .then(searchMatch.resolve, () => searchMatch.resolve(null)); } fireCommandStateChanged = () => { diff --git a/src/features/session/services/documentWorkflows.test.ts b/src/features/session/services/documentWorkflows.test.ts index 94d7fb17..4833e27f 100644 --- a/src/features/session/services/documentWorkflows.test.ts +++ b/src/features/session/services/documentWorkflows.test.ts @@ -6,6 +6,7 @@ import type { SavedMarkdownDocument } from "@/features/document"; import { DEFAULT_IGNORED_DIRECTORIES } from "@/features/preferences"; import { useSessionStore } from "@/features/session"; import { requestConfirmation } from "@/lib/confirmation"; +import { toastManager } from "@/lib/toast"; import { createSavedDocument, createSavedMarkdownDocumentResult, @@ -15,7 +16,11 @@ import { import { createMilkdownEditorBridge } from "@/test/factories/editor"; import { createArticleTree, createFolderContext } from "@/test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH } from "@/test/fixtures/paths"; -import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; +import { + replaceActiveDocument, + setDefaultSession, + setDefaultSettings, +} from "@/test/utils/appStores"; import { countTauriApiCalls, getLastTauriApiArgs, @@ -27,8 +32,8 @@ import { import { documentEditorBridge } from "./documentEditorBridge"; import { closeActiveMarkdownDocument, + closeDocumentTab, createNewMarkdownDocument, - resetDocumentWorkflowIdsForTests, retainEditorMarkdown, saveActiveMarkdownDocument, saveActiveMarkdownDocumentAs, @@ -59,7 +64,6 @@ describe("document workflows", () => { beforeEach(() => { vi.mocked(requestConfirmation).mockReset().mockResolvedValue(false); documentEditorBridge.clear(); - resetDocumentWorkflowIdsForTests(); }); describe("new document", () => { @@ -85,20 +89,38 @@ describe("document workflows", () => { expect(invoke).not.toHaveBeenCalled(); }); - it("cancels New when the dirty document transition is declined", async () => { + it("adds an untitled tab after the active one, keeping the unsaved document open", async () => { setDefaultSession({ - activeDocument: createUntitledDocument({ isDirty: true }), + documents: [ + createUntitledDocument({ isDirty: true }), + createSavedDocument({ path: OUTSIDE_DRAFT_MD_PATH }), + ], }); - await expect(createNewMarkdownDocument()).resolves.toBe(false); + await expect(createNewMarkdownDocument()).resolves.toBe(true); - expect(requestConfirmation).toHaveBeenCalledOnce(); - expect(useSessionStore.getState().activeDocument).toMatchObject({ - status: "untitled", - id: TEST_UNTITLED_DOCUMENT_ID, - content: "Draft", - isDirty: true, + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { id: TEST_UNTITLED_DOCUMENT_ID, content: "Draft", isDirty: true }, + { status: "untitled", id: "untitled:2", content: "" }, + { path: OUTSIDE_DRAFT_MD_PATH }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ id: "untitled:2" }); + }); + + it("numbers a new untitled document with the lowest number no open one uses", async () => { + setDefaultSession({ + documents: [createUntitledDocument({ id: "untitled:2" })], }); + + await createNewMarkdownDocument(); + await createNewMarkdownDocument(); + + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { id: "untitled:2" }, + { id: "untitled:1" }, + { id: "untitled:3" }, + ]); }); }); @@ -162,7 +184,7 @@ describe("document workflows", () => { ); }); - useSessionStore.getState().setActiveDocument(createNextUntitledDocument()); + replaceActiveDocument(createNextUntitledDocument()); saveDeferred.resolve(createSavedMarkdownDocumentResult()); await expect(savePromise).resolves.toBe(false); @@ -172,6 +194,34 @@ describe("document workflows", () => { }); }); + it("saves the document that was active when Save was chosen, after a queued save", async () => { + const firstSave = Promise.withResolvers(); + setDefaultSession({ + documents: [ + createSavedDocument({ isDirty: true }), + createSavedDocument({ path: OUTSIDE_DRAFT_MD_PATH, isDirty: true }), + ], + }); + mockTauriApiCommand("saveMarkdownFile", (args: { path: string }) => + args.path === TEST_MARKDOWN_FILE_PATH && countTauriApiCalls("saveMarkdownFile") === 1 + ? firstSave.promise + : createSavedMarkdownDocumentResult({ path: args.path }), + ); + + const first = saveActiveMarkdownDocument(); + await vi.waitFor(() => expect(countTauriApiCalls("saveMarkdownFile")).toBe(1)); + const second = saveActiveMarkdownDocument(); + + useSessionStore.getState().activateTab(useSessionStore.getState().tabs[1].id); + firstSave.resolve(createSavedMarkdownDocumentResult()); + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(getLastTauriApiArgs("saveMarkdownFile")).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + expect(useSessionStore.getState().tabs[1].document).toMatchObject({ isDirty: true }); + }); + it("serializes concurrent save requests", async () => { const firstSave = Promise.withResolvers(); const secondSave = Promise.withResolvers(); @@ -233,7 +283,7 @@ describe("document workflows", () => { ); retainEditorMarkdown(TEST_MARKDOWN_FILE_PATH); - useSessionStore.getState().markActiveDocumentDirty(TEST_MARKDOWN_FILE_PATH); + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); retainEditorMarkdown(OUTSIDE_DRAFT_MD_PATH); expect(getMarkdown).not.toHaveBeenCalled(); @@ -377,7 +427,7 @@ describe("document workflows", () => { ); }); - useSessionStore.getState().setActiveDocument(createNextUntitledDocument()); + replaceActiveDocument(createNextUntitledDocument()); saveDeferred.resolve(createSavedMarkdownDocumentResult()); await expect(savePromise).resolves.toBe(false); @@ -494,6 +544,39 @@ describe("document workflows", () => { isDirty: false, }); }); + + it("refuses to save over a file open in another tab", async () => { + setDefaultSession({ + documents: [createUntitledDocument(), createSavedDocument({ path: DRAFT_MD_PATH })], + }); + vi.mocked(save).mockResolvedValue("c:\\notes\\DRAFT.md"); + + await expect(saveActiveMarkdownDocumentAs()).resolves.toBe(false); + + expect(countTauriApiCalls("saveMarkdownFile")).toBe(0); + expect(toastManager.add).toHaveBeenCalledWith({ + title: "Document is already open", + description: + "draft.md is open in another tab. Close it before saving over it, or choose another name.", + type: "error", + }); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + 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); + mockTauriApi({ + saveMarkdownFile: () => createSavedMarkdownDocumentResult(), + scanMarkdownFolder: () => notesFolderContext, + }); + + await expect(saveActiveMarkdownDocumentAs()).resolves.toBe(true); + + expect(countTauriApiCalls("saveMarkdownFile")).toBe(1); + }); }); describe("close", () => { @@ -525,6 +608,47 @@ describe("document workflows", () => { isDirty: true, }); }); + + it("activates the next tab after closing the active one and focuses its editor", async () => { + const focus = vi.fn(); + setDefaultSession({ + documents: [createSavedDocument(), createSavedDocument({ path: OUTSIDE_DRAFT_MD_PATH })], + }); + documentEditorBridge.set(OUTSIDE_DRAFT_MD_PATH, createMilkdownEditorBridge({ focus })); + + await expect(closeActiveMarkdownDocument()).resolves.toBe(true); + documentEditorBridge.show(OUTSIDE_DRAFT_MD_PATH); + + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: OUTSIDE_DRAFT_MD_PATH, + }); + expect(focus).toHaveBeenCalledOnce(); + }); + + it("asks about an inactive tab's unsaved changes by name and leaves the active tab", async () => { + setDefaultSession({ + documents: [ + createSavedDocument(), + createSavedDocument({ path: OUTSIDE_DRAFT_MD_PATH, isDirty: true }), + ], + }); + vi.mocked(requestConfirmation).mockResolvedValue(true); + const [, inactiveTab] = useSessionStore.getState().tabs; + + await expect(closeDocumentTab(inactiveTab.id)).resolves.toBe(true); + + expect(requestConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + message: "draft.md has unsaved changes. Discard them and continue?", + }), + ); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: TEST_MARKDOWN_FILE_PATH }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + }); }); describe("unrepresentable characters", () => { @@ -637,7 +761,7 @@ describe("document workflows", () => { const saving = saveActiveMarkdownDocument(); await vi.waitFor(() => expect(countTauriApiCalls("saveMarkdownFile")).toBe(1)); - useSessionStore.getState().setActiveDocument(createUntitledDocument()); + replaceActiveDocument(createUntitledDocument()); result.reject(new Error("Disk unavailable")); await expect(saving).rejects.toThrow("Disk unavailable"); @@ -721,7 +845,7 @@ describe("document workflows", () => { const saving = saveActiveMarkdownDocument(); await vi.waitFor(() => expect(requestConfirmation).toHaveBeenCalledOnce()); - useSessionStore.getState().setActiveDocument(createSavedDocument({ content: "Reopened" })); + replaceActiveDocument(createSavedDocument({ content: "Reopened" })); decision.resolve(true); await expect(saving).resolves.toBe(false); @@ -803,7 +927,7 @@ describe("document workflows", () => { const saving = saveActiveMarkdownDocument(); await vi.waitFor(() => expect(requestConfirmation).toHaveBeenCalledOnce()); - useSessionStore.getState().setActiveDocument(createSavedDocument({ content: "Reopened" })); + replaceActiveDocument(createSavedDocument({ content: "Reopened" })); decision.resolve(true); await expect(saving).resolves.toBe(false); diff --git a/src/features/session/services/documentWorkflows.ts b/src/features/session/services/documentWorkflows.ts index a85ef7d2..a983480b 100644 --- a/src/features/session/services/documentWorkflows.ts +++ b/src/features/session/services/documentWorkflows.ts @@ -8,7 +8,6 @@ import { getActiveDocumentKey, isSaveMarkdownFileError, isUnrepresentableCharactersError, - matchesActiveDocumentKey, NEW_DOCUMENT_ENCODING, saveMarkdownDocument, selectMarkdownSavePath, @@ -28,11 +27,26 @@ import { SequentialTaskQueue } from "@/lib/async"; import { requestConfirmation } from "@/lib/confirmation"; import { t } from "@/lib/i18n"; import { isSameOrParentPath } from "@/lib/path"; +import { notifyError } from "@/lib/toast"; -import { getSessionFolderPath, useSessionStore } from "../stores/session"; +import { + findCurrentTab, + findTab, + findTabByDocumentKey, + findTabByPath, + getActiveTab, + getSessionFolderPath, + useSessionStore, + type DocumentTab, +} from "../stores/session"; +import { + getDocumentName, + getNextUntitledNumber, + toUntitledDocumentId, +} from "../utils/documentNames"; import { documentEditorBridge } from "./documentEditorBridge"; import { getSessionFolderScanOptions } from "./folderContextWorkflows"; -import { confirmDiscardActiveDocumentChanges } from "./unsavedChanges"; +import { confirmDiscardTabChanges } from "./unsavedChanges"; interface SerializedDocumentForSave { content: string; @@ -40,23 +54,30 @@ interface SerializedDocumentForSave { encoding: DocumentEncoding; } +/** A tab together with the version of its document a workflow started from. */ +interface TabVersion { + tabId: string; + generation: number; +} + const saveTaskQueue = new SequentialTaskQueue(); -let nextUntitledId = 1; -export const resetDocumentWorkflowIdsForTests = () => { - nextUntitledId = 1; -}; +const toTabVersion = ({ generation, id }: DocumentTab): TabVersion => ({ + tabId: id, + generation, +}); -export const createNewMarkdownDocument = async () => { - if (!(await confirmDiscardActiveDocumentChanges())) { - return false; - } +const getCurrentDocument = ({ generation, tabId }: TabVersion) => + findCurrentTab(useSessionStore.getState(), tabId, generation)?.document ?? null; +export const createNewMarkdownDocument = async () => { const { defaultNewDocumentLineEnding } = useSettingsStore.getState(); + const { tabs } = useSessionStore.getState(); + const number = getNextUntitledNumber(tabs.map((tab) => tab.document)); - useSessionStore.getState().setActiveDocument( + useSessionStore.getState().openTab( toUntitledDocument({ - id: `untitled:${nextUntitledId++}`, + id: toUntitledDocumentId(number), content: "", lineEnding: defaultNewDocumentLineEnding, encoding: NEW_DOCUMENT_ENCODING, @@ -66,54 +87,85 @@ export const createNewMarkdownDocument = async () => { return true; }; -export const closeActiveMarkdownDocument = async () => { - if (!useSessionStore.getState().activeDocument) { +export const closeDocumentTab = async (tabId: string) => { + if (!findTab(useSessionStore.getState(), tabId)) { return false; } - if (!(await confirmDiscardActiveDocumentChanges())) { + if (!(await confirmDiscardTabChanges([tabId]))) { return false; } - useSessionStore.getState().setActiveDocument(null); + const wasActive = useSessionStore.getState().activeTabId === tabId; + + useSessionStore.getState().closeTabs([tabId]); + + const nextActiveTab = getActiveTab(useSessionStore.getState()); + + if (wasActive && nextActiveTab) { + documentEditorBridge.focusWhenShown(getActiveDocumentKey(nextActiveTab.document)); + } return true; }; +export const closeActiveMarkdownDocument = async () => { + const { activeTabId } = useSessionStore.getState(); + + return activeTabId ? closeDocumentTab(activeTabId) : false; +}; + export const runAfterPendingSaves = (task: () => Promise) => saveTaskQueue.run(task); -export const saveActiveMarkdownDocument = () => saveTaskQueue.run(saveActiveMarkdownDocumentNow); +export const saveDocumentTab = (tabId: string) => + saveTaskQueue.run(async () => { + const tab = findTab(useSessionStore.getState(), tabId); + + return tab ? saveDocumentNow(toTabVersion(tab)) : false; + }); + +// The tab is taken when the command is given, since another tab may be active by the time a +// queued save runs. +export const saveActiveMarkdownDocument = () => { + const activeTabId = useSessionStore.getState().activeTabId; + + return activeTabId ? saveDocumentTab(activeTabId) : Promise.resolve(false); +}; + +export const saveActiveMarkdownDocumentAs = () => { + const activeTabId = useSessionStore.getState().activeTabId; + + return activeTabId + ? saveTaskQueue.run(async () => { + const tab = findTab(useSessionStore.getState(), activeTabId); -export const saveActiveMarkdownDocumentAs = () => - saveTaskQueue.run(saveActiveMarkdownDocumentAsNow); + return tab ? saveDocumentAsNow(toTabVersion(tab)) : false; + }) + : Promise.resolve(false); +}; -const saveActiveMarkdownDocumentNow = async () => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); +const saveDocumentNow = async (version: TabVersion) => { + const document = getCurrentDocument(version); - if (!activeDocument) { + if (!document) { return false; } - if (activeDocument.status === "untitled") { - return saveActiveMarkdownDocumentAsNow(); + if (document.status === "untitled") { + return saveDocumentAsNow(version); } - return saveExistingMarkdownDocument( - activeDocument, - serializeActiveDocumentForSave(activeDocument), - activeDocumentGeneration, - ); + return saveExistingMarkdownDocument(document, serializeDocumentForSave(document), version); }; -const saveActiveMarkdownDocumentAsNow = async () => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); +const saveDocumentAsNow = async (version: TabVersion) => { + const document = getCurrentDocument(version); - if (!activeDocument) { + if (!document) { return false; } - const documentKey = getActiveDocumentKey(activeDocument); - const defaultPath = await getSaveAsDefaultPath(activeDocument); + const defaultPath = await getSaveAsDefaultPath(document); const selectedPath = await selectMarkdownSavePath(defaultPath); if (!selectedPath) { @@ -122,22 +174,31 @@ const saveActiveMarkdownDocumentAsNow = async () => { const { defaultNewDocumentExtension } = useSettingsStore.getState(); const path = await ensureMarkdownExtension(selectedPath, defaultNewDocumentExtension); + const otherTab = findTabByPath(useSessionStore.getState(), path); + + // Two tabs holding one file would each save over the other's changes. + if (otherTab && otherTab.id !== version.tabId) { + notifyError({ + title: t("session.saveAsOpenInTab.title"), + description: t("session.saveAsOpenInTab.description", { + name: getDocumentName(otherTab.document, t), + }), + }); - return saveActiveMarkdownDocumentToNewPath(documentKey, activeDocumentGeneration, path); + return false; + } + + return saveDocumentToNewPath(version, path); }; -const saveActiveMarkdownDocumentToNewPath = async ( - documentKey: string, - activeDocumentGeneration: number, - path: string, -): Promise => { - const latestDocument = getActiveDocumentByKey(documentKey, activeDocumentGeneration); +const saveDocumentToNewPath = async (version: TabVersion, path: string): Promise => { + const latestDocument = getCurrentDocument(version); if (!latestDocument) { return false; } - const serializedDocument = serializeActiveDocumentForSave(latestDocument); + const serializedDocument = serializeDocumentForSave(latestDocument); let result; try { @@ -151,8 +212,8 @@ const saveActiveMarkdownDocumentToNewPath = async ( throw error; } - return handleUnrepresentableCharacters(error, documentKey, activeDocumentGeneration, () => - saveActiveMarkdownDocumentToNewPath(documentKey, activeDocumentGeneration, path), + return handleUnrepresentableCharacters(error, version, () => + saveDocumentToNewPath(version, path), ); } @@ -165,7 +226,7 @@ const saveActiveMarkdownDocumentToNewPath = async ( existingFolderContext, ); - if (!getActiveDocumentByKey(documentKey, activeDocumentGeneration)) { + if (!getCurrentDocument(version)) { return false; } @@ -178,27 +239,27 @@ const saveActiveMarkdownDocumentToNewPath = async ( fingerprint: result.fingerprint, }); - if (nextFolderContext) { - useSessionStore.getState().setActiveDocumentSession(nextFolderContext, savedDocument); - } else { - useSessionStore.getState().setActiveDocument(savedDocument); - } + useSessionStore + .getState() + .replaceTabDocument( + version.tabId, + savedDocument, + nextFolderContext ? { folderContext: nextFolderContext } : {}, + ); return true; }; -const serializeActiveDocumentForSave = ( - activeDocument: ActiveDocumentState, -): SerializedDocumentForSave => { +const serializeDocumentForSave = (document: ActiveDocumentState): SerializedDocumentForSave => { const { defaultNewDocumentLineEnding, insertFinalNewline } = useSettingsStore.getState(); - const lineEnding = activeDocument.lineEnding ?? defaultNewDocumentLineEnding; - const documentKey = getActiveDocumentKey(activeDocument); - const markdown = documentEditorBridge.getMarkdown(documentKey) ?? activeDocument.content; + const lineEnding = document.lineEnding ?? defaultNewDocumentLineEnding; + const documentKey = getActiveDocumentKey(document); + const markdown = documentEditorBridge.getMarkdown(documentKey) ?? document.content; return { content: formatMarkdownForSave(markdown, lineEnding, insertFinalNewline), lineEnding, - encoding: activeDocument.encoding, + encoding: document.encoding, }; }; @@ -208,41 +269,42 @@ const serializeActiveDocumentForSave = ( * Save, or the next editor, does not fall back to the text from before the edits. */ export const retainEditorMarkdown = (documentKey: string) => { - const { activeDocument, setActiveDocumentContent } = useSessionStore.getState(); + const session = useSessionStore.getState(); - if (!activeDocument?.isDirty || !matchesActiveDocumentKey(activeDocument, documentKey)) { + if (!findTabByDocumentKey(session, documentKey)?.document.isDirty) { return; } const markdown = documentEditorBridge.getMarkdown(documentKey); if (markdown !== null) { - setActiveDocumentContent(documentKey, markdown); + session.setDocumentContent(documentKey, markdown); } }; const saveExistingMarkdownDocument = async ( - activeDocument: SavedDocumentState, + document: SavedDocumentState, serializedDocument: SerializedDocumentForSave, - activeDocumentGeneration: number, + version: TabVersion, overwrite = false, ): Promise => { try { const result = await saveMarkdownDocument( - activeDocument.path, + document.path, serializedDocument.content, serializedDocument.encoding, { - expectedMetadata: activeDocument.metadata, + expectedMetadata: document.metadata, overwrite, }, ); - if (!getActiveDocumentByKey(getActiveDocumentKey(activeDocument), activeDocumentGeneration)) { + if (!getCurrentDocument(version)) { return false; } - useSessionStore.getState().setActiveDocument( + useSessionStore.getState().replaceTabDocument( + version.tabId, toSavedDocument({ path: result.path, content: serializedDocument.content, @@ -256,26 +318,24 @@ const saveExistingMarkdownDocument = async ( return true; } catch (error) { if (isMissingFileSaveError(error)) { - if (!getActiveDocumentByKey(getActiveDocumentKey(activeDocument), activeDocumentGeneration)) { + if (!getCurrentDocument(version)) { return false; } - return handleMissingSavedFile(getActiveDocumentKey(activeDocument), activeDocumentGeneration); + return handleMissingSavedFile(version); } if (isExternalModificationSaveError(error)) { - if (!getActiveDocumentByKey(getActiveDocumentKey(activeDocument), activeDocumentGeneration)) { + if (!getCurrentDocument(version)) { return false; } - return handleExternalModification(activeDocument, activeDocumentGeneration); + return handleExternalModification(version); } if (isUnrepresentableCharactersError(error)) { - const documentKey = getActiveDocumentKey(activeDocument); - - return handleUnrepresentableCharacters(error, documentKey, activeDocumentGeneration, () => { - const latestDocument = getActiveDocumentByKey(documentKey, activeDocumentGeneration); + return handleUnrepresentableCharacters(error, version, () => { + const latestDocument = getCurrentDocument(version); if (latestDocument?.status !== "saved") { return Promise.resolve(false); @@ -283,8 +343,8 @@ const saveExistingMarkdownDocument = async ( return saveExistingMarkdownDocument( latestDocument, - serializeActiveDocumentForSave(latestDocument), - activeDocumentGeneration, + serializeDocumentForSave(latestDocument), + version, overwrite, ); }); @@ -300,29 +360,27 @@ export type UnpromptedSaveResult = | { kind: "failed"; error: SaveMarkdownFileError }; /** - * Saves the active saved document as Save does, but reports a missing file, a newer version on - * disk, or text its encoding cannot hold instead of asking about it. + * Saves an open saved document as Save does, but reports a missing file, a newer version on disk, + * or text its encoding cannot hold instead of asking about it. */ -export const saveActiveMarkdownDocumentWithoutPrompts = (documentKey: string) => +export const saveDocumentWithoutPrompts = (documentKey: string) => saveTaskQueue.run(async (): Promise => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); + const tab = findTabByDocumentKey(useSessionStore.getState(), documentKey); - if ( - activeDocument?.status !== "saved" || - !matchesActiveDocumentKey(activeDocument, documentKey) - ) { + if (tab?.document.status !== "saved") { return { kind: "closed" }; } - const serializedDocument = serializeActiveDocumentForSave(activeDocument); + const { document } = tab; + const serializedDocument = serializeDocumentForSave(document); let result; try { result = await saveMarkdownDocument( - activeDocument.path, + document.path, serializedDocument.content, serializedDocument.encoding, - { expectedMetadata: activeDocument.metadata }, + { expectedMetadata: document.metadata }, ); } catch (error) { if (isSaveMarkdownFileError(error)) { @@ -332,8 +390,9 @@ export const saveActiveMarkdownDocumentWithoutPrompts = (documentKey: string) => throw error; } - if (getActiveDocumentByKey(documentKey, activeDocumentGeneration)) { - useSessionStore.getState().setActiveDocument( + if (getCurrentDocument(toTabVersion(tab))) { + useSessionStore.getState().replaceTabDocument( + tab.id, toSavedDocument({ path: result.path, content: serializedDocument.content, @@ -350,11 +409,10 @@ export const saveActiveMarkdownDocumentWithoutPrompts = (documentKey: string) => const handleUnrepresentableCharacters = async ( error: UnrepresentableCharactersError, - documentKey: string, - activeDocumentGeneration: number, + version: TabVersion, saveAgain: () => Promise, ) => { - if (!getActiveDocumentByKey(documentKey, activeDocumentGeneration)) { + if (!getCurrentDocument(version)) { return false; } @@ -367,17 +425,18 @@ const handleUnrepresentableCharacters = async ( confirmLabel: t("session.unrepresentableCharacters.confirm"), cancelLabel: t("session.unrepresentableCharacters.cancel"), }); + const document = shouldConvert ? getCurrentDocument(version) : null; - if (!shouldConvert || !getActiveDocumentByKey(documentKey, activeDocumentGeneration)) { + if (!document) { return false; } - useSessionStore.getState().setActiveDocumentEncoding(documentKey, UTF8_ENCODING); + useSessionStore.getState().setDocumentEncoding(getActiveDocumentKey(document), UTF8_ENCODING); return saveAgain(); }; -const handleMissingSavedFile = async (documentKey: string, activeDocumentGeneration: number) => { +const handleMissingSavedFile = async (version: TabVersion) => { const shouldSaveAs = await requestConfirmation({ title: t("session.missingSavedFile.title"), message: t("session.missingSavedFile.message"), @@ -385,21 +444,14 @@ const handleMissingSavedFile = async (documentKey: string, activeDocumentGenerat cancelLabel: t("session.missingSavedFile.cancel"), }); - if (!getActiveDocumentByKey(documentKey, activeDocumentGeneration)) { - return false; - } - - if (!shouldSaveAs) { + if (!shouldSaveAs || !getCurrentDocument(version)) { return false; } - return saveActiveMarkdownDocumentAsNow(); + return saveDocumentAsNow(version); }; -const handleExternalModification = async ( - activeDocument: SavedDocumentState, - activeDocumentGeneration: number, -) => { +const handleExternalModification = async (version: TabVersion) => { const shouldOverwrite = await requestConfirmation({ title: t("session.externalModification.title"), message: t("session.externalModification.message"), @@ -411,19 +463,16 @@ const handleExternalModification = async ( return false; } - const latestDocument = getActiveDocumentByKey(activeDocument.path, activeDocumentGeneration); + const latestDocument = getCurrentDocument(version); - if ( - latestDocument?.status !== "saved" || - !matchesActiveDocumentKey(latestDocument, activeDocument.path) - ) { + if (latestDocument?.status !== "saved") { return false; } return saveExistingMarkdownDocument( latestDocument, - serializeActiveDocumentForSave(latestDocument), - activeDocumentGeneration, + serializeDocumentForSave(latestDocument), + version, true, ); }; @@ -434,24 +483,9 @@ const isMissingFileSaveError = (error: unknown) => const isExternalModificationSaveError = (error: unknown) => isSaveMarkdownFileError(error) && error.kind === "externalModification"; -const getActiveDocumentByKey = (documentKey: string, activeDocumentGeneration: number) => { - const session = useSessionStore.getState(); - const activeDocument = session.activeDocument; - - if ( - !activeDocument || - session.activeDocumentGeneration !== activeDocumentGeneration || - !matchesActiveDocumentKey(activeDocument, documentKey) - ) { - return null; - } - - return activeDocument; -}; - -const getSaveAsDefaultPath = async (activeDocument: ActiveDocumentState) => { - if (activeDocument.status === "saved") { - return activeDocument.path; +const getSaveAsDefaultPath = async (document: ActiveDocumentState) => { + if (document.status === "saved") { + return document.path; } const fileName = `${t("session.untitledFileName")}${useSettingsStore.getState().defaultNewDocumentExtension}`; diff --git a/src/features/session/services/externalChanges.test.ts b/src/features/session/services/externalChanges.test.ts index 785ff562..df975da1 100644 --- a/src/features/session/services/externalChanges.test.ts +++ b/src/features/session/services/externalChanges.test.ts @@ -20,13 +20,17 @@ import { import { createMilkdownEditorBridge } from "@/test/factories/editor"; import { createFolderContext } from "@/test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH, TEST_NOTES_FOLDER_PATH } from "@/test/fixtures/paths"; -import { setDefaultRecentItems, setDefaultSession } from "@/test/utils/appStores"; +import { + replaceActiveDocument, + setDefaultRecentItems, + setDefaultSession, +} from "@/test/utils/appStores"; import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; -import { useSessionStore } from "../stores/session"; +import { getActiveTab, useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; import { saveActiveMarkdownDocument } from "./documentWorkflows"; -import { checkActiveDocumentFile } from "./externalChanges"; +import { checkDocumentFile } from "./externalChanges"; import { renameFolderEntry } from "./folderEntryWorkflows"; vi.mock("@/lib/confirmation", () => ({ requestConfirmation: vi.fn(async () => false) })); @@ -68,6 +72,16 @@ const missingWarning = { type: "warning", }; +const getActiveTabVersion = () => { + const tab = getActiveTab(useSessionStore.getState()); + + if (!tab) { + throw new Error("Expected an active tab."); + } + + return { generation: tab.generation, loadId: tab.loadId, viewState: tab.viewState }; +}; + describe("external document changes", () => { beforeEach(() => { vi.mocked(requestConfirmation).mockReset().mockResolvedValue(false); @@ -78,7 +92,7 @@ describe("external document changes", () => { setDefaultSession({ activeDocument: createUntitledDocument() }); mockTauriApi({}); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(countTauriApiCalls("inspectMarkdownFile")).toBe(0); }); @@ -89,7 +103,7 @@ describe("external document changes", () => { mockTauriApi({ inspectMarkdownFile: () => ({ kind: "unchanged" }) }); const sessionBeforeCheck = useSessionStore.getState(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(getLastTauriApiArgs("inspectMarkdownFile")).toEqual({ path: TEST_MARKDOWN_FILE_PATH, @@ -124,15 +138,17 @@ describe("external document changes", () => { fingerprint: CHANGED_FINGERPRINT, }), }); - const { activeDocumentGeneration, activeDocumentLoadId } = useSessionStore.getState(); + const before = getActiveTabVersion(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); + expect(getActiveTabVersion()).toMatchObject({ + loadId: before.loadId + 1, + viewState: { anchor: 4, head: 6, focused: true }, + }); + expect(getActiveTabVersion().generation).not.toBe(before.generation); expect(useSessionStore.getState()).toMatchObject({ folderContext, - activeDocumentGeneration: activeDocumentGeneration + 1, - activeDocumentLoadId: activeDocumentLoadId + 1, - activeDocumentViewState: { anchor: 4, head: 6, focused: true }, activeDocument: { status: "saved", path: TEST_MARKDOWN_FILE_PATH, @@ -163,7 +179,7 @@ describe("external document changes", () => { openMarkdownFile: () => createReloadedDocument(), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(getLastTauriApiArgs("openMarkdownFile")).toEqual({ path: TEST_MARKDOWN_FILE_PATH, @@ -171,21 +187,40 @@ describe("external document changes", () => { }); }); + it("reloads a document in an inactive tab without switching to it", async () => { + const otherDocument = createSavedDocument({ path: `${TEST_NOTES_FOLDER_PATH}/other.md` }); + setDefaultSession({ + documents: [createSavedDocument({ content: "# Before\n" }), otherDocument], + activeIndex: 1, + }); + mockTauriApi({ + inspectMarkdownFile: () => contentChanged(), + openMarkdownFile: () => createReloadedDocument(), + }); + + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); + + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: TEST_MARKDOWN_FILE_PATH, content: "# After\n", isDirty: false }, + otherDocument, + ]); + expect(useSessionStore.getState().activeDocument).toEqual(otherDocument); + }); + it("accepts a touched file's metadata without reloading or warning", async () => { const activeDocument = createSavedDocument(); setDefaultSession({ activeDocument }); mockTauriApi({ inspectMarkdownFile: () => ({ kind: "metadataChanged", metadata: CHANGED_METADATA }), }); - const { activeDocumentGeneration, activeDocumentLoadId } = useSessionStore.getState(); + const before = getActiveTabVersion(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState()).toMatchObject({ activeDocument: { ...activeDocument, metadata: CHANGED_METADATA }, - activeDocumentGeneration, - activeDocumentLoadId, }); + expect(getActiveTabVersion()).toEqual(before); expect(countTauriApiCalls("openMarkdownFile")).toBe(0); expect(toastManager.add).not.toHaveBeenCalled(); }); @@ -199,8 +234,8 @@ describe("external document changes", () => { openMarkdownFile: () => Promise.reject(invalidEncoding), }); - await checkActiveDocumentFile(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toEqual({ ...activeDocument, @@ -222,10 +257,10 @@ describe("external document changes", () => { const activeDocument = createSavedDocument({ content: "# Local edit\n", isDirty: true }); setDefaultSession({ activeDocument }); mockTauriApi({ inspectMarkdownFile: () => contentChanged() }); - const { activeDocumentGeneration, activeDocumentLoadId } = useSessionStore.getState(); + const before = getActiveTabVersion(); - await checkActiveDocumentFile(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState()).toMatchObject({ activeDocument: { @@ -236,15 +271,14 @@ describe("external document changes", () => { fingerprint: CHANGED_FINGERPRINT, }, }, - activeDocumentGeneration, - activeDocumentLoadId, }); + expect(getActiveTabVersion()).toEqual(before); expect(countTauriApiCalls("openMarkdownFile")).toBe(0); expect(toastManager.add).toHaveBeenCalledExactlyOnceWith(modifiedWarning); mockTauriApi({ inspectMarkdownFile: () => contentChanged({ metadata: LATER_METADATA }) }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ metadata: activeDocument.metadata, @@ -256,7 +290,7 @@ describe("external document changes", () => { inspectMarkdownFile: () => contentChanged({ fingerprint: OTHER_FINGERPRINT }), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "# Local edit\n", @@ -278,7 +312,7 @@ describe("external document changes", () => { }), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); await expect(saveActiveMarkdownDocument()).resolves.toBe(false); expect(getLastTauriApiArgs("saveMarkdownFile")).toMatchObject({ @@ -305,7 +339,7 @@ describe("external document changes", () => { inspectMarkdownFile: () => ({ kind: "metadataChanged", metadata: LATER_METADATA }), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ metadata: LATER_METADATA, @@ -323,8 +357,8 @@ describe("external document changes", () => { setDefaultSession({ activeDocument }); mockTauriApi({ inspectMarkdownFile: () => ({ kind: "missing" }) }); - await checkActiveDocumentFile(); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toEqual({ ...activeDocument, @@ -340,7 +374,7 @@ describe("external document changes", () => { }); mockTauriApi({ inspectMarkdownFile: () => ({ kind: "unchanged" }) }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ externalChange: null }); expect(toastManager.add).not.toHaveBeenCalled(); @@ -355,7 +389,7 @@ describe("external document changes", () => { openMarkdownFile: () => createReloadedDocument("# Recreated\n"), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "# Recreated\n", @@ -372,7 +406,7 @@ describe("external document changes", () => { Promise.reject({ kind: "missingFile", path: TEST_MARKDOWN_FILE_PATH }), }); - await checkActiveDocumentFile(); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toEqual({ ...activeDocument, @@ -392,7 +426,7 @@ describe("external document changes", () => { }); const saving = saveActiveMarkdownDocument(); - const checking = checkActiveDocumentFile(); + const checking = checkDocumentFile(TEST_MARKDOWN_FILE_PATH); await vi.waitFor(() => expect(countTauriApiCalls("saveMarkdownFile")).toBe(1)); expect(countTauriApiCalls("inspectMarkdownFile")).toBe(0); @@ -413,7 +447,7 @@ describe("external document changes", () => { expect(toastManager.add).not.toHaveBeenCalled(); }); - it("waits for a pending rename and checks the document at its new path", async () => { + it("waits for a pending rename, after which the document is checked at its new path", async () => { const renamedPath = `${TEST_NOTES_FOLDER_PATH}/intro.md`; setDefaultSession({ activeDocument: createSavedDocument(), folderContext }); const rename = Promise.withResolvers<{ path: string }>(); @@ -424,7 +458,7 @@ describe("external document changes", () => { }); const renaming = renameFolderEntry(TEST_MARKDOWN_FILE_PATH, "intro"); - const checking = checkActiveDocumentFile(); + const checking = checkDocumentFile(TEST_MARKDOWN_FILE_PATH); await vi.waitFor(() => expect(countTauriApiCalls("renameFolderEntry")).toBe(1)); expect(countTauriApiCalls("inspectMarkdownFile")).toBe(0); @@ -432,6 +466,11 @@ describe("external document changes", () => { rename.resolve({ path: renamedPath }); await Promise.all([renaming, checking]); + // The document left the path the check was queued for; its new path is watched and checked. + expect(countTauriApiCalls("inspectMarkdownFile")).toBe(0); + + await checkDocumentFile(renamedPath); + expect(getLastTauriApiArgs("inspectMarkdownFile")).toMatchObject({ path: renamedPath }); expect(useSessionStore.getState().activeDocument).toMatchObject({ path: renamedPath, @@ -448,9 +487,9 @@ describe("external document changes", () => { mockTauriApi({ inspectMarkdownFile: () => inspection.promise }); const otherDocument = createSavedDocument({ path: OTHER_MARKDOWN_PATH }); - const checking = checkActiveDocumentFile(); + const checking = checkDocumentFile(TEST_MARKDOWN_FILE_PATH); await vi.waitFor(() => expect(countTauriApiCalls("inspectMarkdownFile")).toBe(1)); - useSessionStore.getState().setActiveDocument(otherDocument); + replaceActiveDocument(otherDocument); inspection.resolve({ kind: "missing" }); await checking; @@ -466,16 +505,16 @@ describe("external document changes", () => { inspectMarkdownFile: () => contentChanged(), openMarkdownFile: () => reload.promise, }); - const { activeDocumentLoadId } = useSessionStore.getState(); + const { loadId } = getActiveTabVersion(); - const checking = checkActiveDocumentFile(); + const checking = checkDocumentFile(TEST_MARKDOWN_FILE_PATH); await vi.waitFor(() => expect(countTauriApiCalls("openMarkdownFile")).toBe(1)); - useSessionStore.getState().markActiveDocumentDirty(TEST_MARKDOWN_FILE_PATH); + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); reload.resolve(createReloadedDocument()); await checking; + expect(getActiveTabVersion().loadId).toBe(loadId); expect(useSessionStore.getState()).toMatchObject({ - activeDocumentLoadId, activeDocument: { content: "# Before\n", isDirty: true, @@ -494,7 +533,7 @@ describe("external document changes", () => { setDefaultSession({ activeDocument: createSavedDocument() }); mockTauriApi({}); - await checkActiveDocumentFile(CancellationToken.Cancelled); + await checkDocumentFile(TEST_MARKDOWN_FILE_PATH, CancellationToken.Cancelled); expect(countTauriApiCalls("inspectMarkdownFile")).toBe(0); }); diff --git a/src/features/session/services/externalChanges.ts b/src/features/session/services/externalChanges.ts index 1053b2af..605a9f40 100644 --- a/src/features/session/services/externalChanges.ts +++ b/src/features/session/services/externalChanges.ts @@ -2,7 +2,6 @@ import { inspectMarkdownDocument, isInspectMarkdownFileError, isOpenMarkdownFileError, - matchesActiveDocumentKey, openMarkdownDocument, toSavedDocument, type ExternalFileChange, @@ -15,32 +14,45 @@ import { t } from "@/lib/i18n"; import { getPathParts } from "@/lib/path"; import { notifySuccess, notifyWarning } from "@/lib/toast"; -import { useSessionStore } from "../stores/session"; +import { findCurrentTab, findTabByPath, useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; import { runAfterPendingSaves } from "./documentWorkflows"; import { notifyOpenMarkdownFileError } from "./openSession"; type ExternalVersion = Extract; +interface CheckedDocument { + tabId: string; + generation: number; + document: SavedDocumentState; +} + /** - * Saves, Save as, and renames replace the file and the document's baseline in one queued task, so - * a check waiting behind them never compares a file Leafdown just wrote with the baseline before it. + * Compares the file of an open document with the version the document holds. Saves, Save as, and + * renames replace the file and the document's baseline in one queued task, so a check waiting + * behind them never compares a file Leafdown just wrote with the baseline before it. */ -export const checkActiveDocumentFile = ( +export const checkDocumentFile = ( + path: string, cancellationToken: CancellationToken = CancellationToken.None, -) => runAfterPendingSaves(() => checkActiveDocumentFileNow(cancellationToken)); +) => runAfterPendingSaves(() => checkDocumentFileNow(path, cancellationToken)); -const checkActiveDocumentFileNow = async (cancellationToken: CancellationToken) => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); +const checkDocumentFileNow = async (path: string, cancellationToken: CancellationToken) => { + const tab = findTabByPath(useSessionStore.getState(), path); - if (cancellationToken.isCancellationRequested || activeDocument?.status !== "saved") { + if (cancellationToken.isCancellationRequested || tab?.document.status !== "saved") { return; } + const checked: CheckedDocument = { + tabId: tab.id, + generation: tab.generation, + document: tab.document, + }; let fileState: MarkdownFileState; try { - fileState = await inspectMarkdownDocument(activeDocument, cancellationToken); + fileState = await inspectMarkdownDocument(checked.document, cancellationToken); } catch (error) { // A failed inspection is logged where it happens; the next filesystem event checks again, and // Save still verifies the file before writing. @@ -51,7 +63,7 @@ const checkActiveDocumentFileNow = async (cancellationToken: CancellationToken) throw error; } - const document = getUnchangedBaseline(activeDocument, activeDocumentGeneration); + const document = getUnchangedBaseline(checked); if (!document) { return; @@ -84,17 +96,17 @@ const checkActiveDocumentFileNow = async (cancellationToken: CancellationToken) return; } - await reloadCleanDocument(document, activeDocumentGeneration, version, cancellationToken); + await reloadCleanDocument(checked, version, cancellationToken); } } }; const reloadCleanDocument = async ( - document: SavedDocumentState, - activeDocumentGeneration: number, + checked: CheckedDocument, version: ExternalVersion, cancellationToken: CancellationToken, ) => { + const { document } = checked; let reloaded; try { @@ -104,7 +116,7 @@ const reloadCleanDocument = async ( document.fileEncoding.name, ); } catch (error) { - const latestDocument = getUnchangedBaseline(document, activeDocumentGeneration); + const latestDocument = getUnchangedBaseline(checked); if (isCancellationError(error) || !latestDocument) { return; @@ -120,7 +132,7 @@ const reloadCleanDocument = async ( return; } - const latestDocument = getUnchangedBaseline(document, activeDocumentGeneration); + const latestDocument = getUnchangedBaseline(checked); if (!latestDocument) { return; @@ -140,7 +152,8 @@ const reloadCleanDocument = async ( return; } - useSessionStore.getState().setActiveDocument( + useSessionStore.getState().replaceTabDocument( + checked.tabId, toSavedDocument({ path: latestDocument.path, content: reloaded.content, @@ -159,22 +172,19 @@ const reloadCleanDocument = async ( }); }; -/** Returns the active document only while it is still the version the check compared against. */ -const getUnchangedBaseline = (document: SavedDocumentState, activeDocumentGeneration: number) => { - const session = useSessionStore.getState(); - const { activeDocument } = session; +/** Returns the tab's document only while it is still the version the check compared against. */ +const getUnchangedBaseline = ({ document, generation, tabId }: CheckedDocument) => { + const latestDocument = findCurrentTab(useSessionStore.getState(), tabId, generation)?.document; if ( - session.activeDocumentGeneration !== activeDocumentGeneration || - activeDocument?.status !== "saved" || - !matchesActiveDocumentKey(activeDocument, document.path) || - activeDocument.metadata !== document.metadata || - activeDocument.fingerprint !== document.fingerprint + latestDocument?.status !== "saved" || + latestDocument.metadata !== document.metadata || + latestDocument.fingerprint !== document.fingerprint ) { return null; } - return activeDocument; + return latestDocument; }; const isObservedVersion = (document: SavedDocumentState, version: ExternalVersion) => @@ -186,9 +196,7 @@ const setFileState = ( metadata: FileMetadataSnapshot, externalChange: ExternalFileChange | null, ) => { - useSessionStore - .getState() - .setActiveDocumentFileState(document.path, { metadata, externalChange }); + useSessionStore.getState().setDocumentFileState(document.path, { metadata, externalChange }); }; const recordExternalVersion = (document: SavedDocumentState, version: ExternalVersion) => { diff --git a/src/features/session/services/folderContextWorkflows.test.ts b/src/features/session/services/folderContextWorkflows.test.ts index 584f5f71..3da7d2d1 100644 --- a/src/features/session/services/folderContextWorkflows.test.ts +++ b/src/features/session/services/folderContextWorkflows.test.ts @@ -54,6 +54,29 @@ describe("folder context workflows", () => { }); }); + it("closes every tab with the folder after one dialog naming the unsaved ones", async () => { + vi.mocked(requestConfirmation).mockResolvedValue(true); + setDefaultSession({ + documents: [ + createSavedDocument({ isDirty: true }), + createSavedDocument({ path: "C:/Notes/clean.md" }), + createSavedDocument({ path: "C:/Elsewhere/outside.md", isDirty: true }), + ], + folderContext: notesFolderContext, + }); + + await expect(closeFolderContext()).resolves.toBe(true); + + expect(requestConfirmation).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ detail: "readme.md\noutside.md", confirmLabel: "Discard all" }), + ); + expect(useSessionStore.getState()).toMatchObject({ + tabs: [], + activeDocument: null, + folderContext: null, + }); + }); + it("does not close anything without an active folder context", async () => { setDefaultSession({ activeDocument: createSavedDocument() }); diff --git a/src/features/session/services/folderContextWorkflows.ts b/src/features/session/services/folderContextWorkflows.ts index 853671c8..5764976a 100644 --- a/src/features/session/services/folderContextWorkflows.ts +++ b/src/features/session/services/folderContextWorkflows.ts @@ -3,14 +3,15 @@ import { useSettingsStore } from "@/features/preferences"; import { isSamePath } from "@/lib/path"; import { useSessionStore } from "../stores/session"; -import { confirmDiscardActiveDocumentChanges } from "./unsavedChanges"; +import { confirmDiscardAllDocumentChanges } from "./unsavedChanges"; +/** Closes the folder context together with every tab, returning the window to the welcome screen. */ export const closeFolderContext = async () => { if (!useSessionStore.getState().folderContext) { return false; } - if (!(await confirmDiscardActiveDocumentChanges())) { + if (!(await confirmDiscardAllDocumentChanges())) { return false; } diff --git a/src/features/session/services/folderEntryWorkflows.test.ts b/src/features/session/services/folderEntryWorkflows.test.ts index bacb244c..0fda3803 100644 --- a/src/features/session/services/folderEntryWorkflows.test.ts +++ b/src/features/session/services/folderEntryWorkflows.test.ts @@ -53,25 +53,9 @@ describe("folder entry workflows", () => { documentEditorBridge.clear(); }); - it("leaves no new file behind when discarding the active document is declined", async () => { - setDefaultSession({ - activeDocument: createSavedDocument({ isDirty: true }), - folderContext, - }); - - await expect(createArticleInFolder(TEST_NOTES_FOLDER_PATH, "plan")).resolves.toBeNull(); - - expect(requestConfirmation).toHaveBeenCalledOnce(); - expect(countTauriApiCalls("createMarkdownArticle")).toBe(0); - expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: true }); - }); - - it("creates an article with the default extension and opens it after one discard prompt", async () => { - vi.mocked(requestConfirmation).mockResolvedValue(true); - setDefaultSession({ - activeDocument: createSavedDocument({ isDirty: true }), - folderContext, - }); + it("creates an article with the default extension and opens it in a new tab", async () => { + const activeDocument = createSavedDocument({ isDirty: true }); + setDefaultSession({ activeDocument, folderContext }); mockFolderEntryCommands({ createMarkdownArticle: () => ({ path: CREATED_ARTICLE_PATH }), openMarkdownFile: () => @@ -82,7 +66,7 @@ describe("folder entry workflows", () => { CREATED_ARTICLE_PATH, ); - expect(requestConfirmation).toHaveBeenCalledOnce(); + expect(requestConfirmation).not.toHaveBeenCalled(); expect(getLastTauriApiArgs("createMarkdownArticle")).toEqual({ defaultExtension: ".markdown", folderPath: TEST_NOTES_FOLDER_PATH, @@ -90,8 +74,11 @@ describe("folder entry workflows", () => { parentPath: TEST_NOTES_FOLDER_PATH, }); expect(countTauriApiCalls("scanMarkdownFolder")).toBe(1); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toEqual([ + activeDocument, + expect.objectContaining({ isDirty: false, path: CREATED_ARTICLE_PATH }), + ]); expect(useSessionStore.getState().activeDocument).toMatchObject({ - isDirty: false, path: CREATED_ARTICLE_PATH, }); }); @@ -155,6 +142,32 @@ describe("folder entry workflows", () => { ]); }); + it("moves every open document inside a renamed directory, 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, + }); + attachEditorMarkdown(TEST_NESTED_MARKDOWN_FILE_PATH, "# Unsaved"); + mockFolderEntryCommands({ renameFolderEntry: () => ({ path: RENAMED_DIRECTORY_PATH }) }); + + await renameFolderEntry(TEST_NESTED_DIRECTORY_PATH, "guides"); + + 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, + }); + }); + it("leaves the session unchanged when a rename is refused", async () => { const activeDocument = createSavedDocument(); setDefaultSession({ activeDocument, folderContext }); @@ -208,6 +221,35 @@ describe("folder entry workflows", () => { expect(countTauriApiCalls("scanMarkdownFolder")).toBe(1); }); + it("asks once about the unsaved tabs a deleted directory holds and closes all its tabs", async () => { + const nestedPath = `${TEST_NESTED_DIRECTORY_PATH}/notes.md`; + vi.mocked(requestConfirmation).mockResolvedValue(true); + setDefaultSession({ + documents: [ + createSavedDocument({ path: TEST_NESTED_MARKDOWN_FILE_PATH, isDirty: true }), + createSavedDocument(), + createSavedDocument({ path: nestedPath, isDirty: true }), + createSavedDocument({ path: `${TEST_NESTED_DIRECTORY_PATH}/clean.md` }), + ], + activeIndex: 2, + folderContext, + }); + mockFolderEntryCommands({ trashFolderEntry: () => undefined }); + + await expect(deleteFolderEntry(TEST_NESTED_DIRECTORY_PATH, "directory")).resolves.toBe(true); + + expect(requestConfirmation).toHaveBeenCalledTimes(2); + expect(requestConfirmation).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: "readme.md\nnotes.md", confirmLabel: "Discard all" }), + ); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: TEST_MARKDOWN_FILE_PATH }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + }); + }); + it("keeps an unrelated active document open after trashing another file", async () => { vi.mocked(requestConfirmation).mockResolvedValue(true); const activeDocument = createSavedDocument({ isDirty: true }); diff --git a/src/features/session/services/folderEntryWorkflows.ts b/src/features/session/services/folderEntryWorkflows.ts index f3838884..f4ee246e 100644 --- a/src/features/session/services/folderEntryWorkflows.ts +++ b/src/features/session/services/folderEntryWorkflows.ts @@ -18,14 +18,14 @@ import { documentEditorBridge } from "./documentEditorBridge"; import { runAfterPendingSaves } from "./documentWorkflows"; import { refreshFolderContext } from "./folderContextWorkflows"; import { notifyOpenMarkdownFileError, openMarkdownFileAtPath } from "./openSession"; -import { confirmDiscardActiveDocumentChanges } from "./unsavedChanges"; +import { confirmDiscardTabChanges } from "./unsavedChanges"; export type FolderEntryKind = "directory" | "file"; export const createArticleInFolder = async (parentPath: string, name: string) => { const folderPath = getFolderContextPath(); - if (!folderPath || !(await confirmDiscardActiveDocumentChanges())) { + if (!folderPath) { return null; } @@ -39,7 +39,7 @@ export const createArticleInFolder = async (parentPath: string, name: string) => await refreshAfterFolderEntryChange(); try { - await openMarkdownFileAtPath(path, { discardConfirmed: true }); + await openMarkdownFileAtPath(path); } catch (error) { notifyOpenMarkdownFileError(error); } @@ -72,7 +72,7 @@ export const renameFolderEntry = (path: string, name: string) => const nextPath = await renameFolderContextEntry({ folderPath, name, path }); if (nextPath !== path) { - moveActiveDocument(path, nextPath); + moveOpenDocuments(path, nextPath); useRecentItemsStore.getState().moveRecentPaths(path, nextPath); useArticleNavigatorStore.getState().moveDirectoryPaths(path, nextPath); await refreshAfterFolderEntryChange(); @@ -103,20 +103,14 @@ export const deleteFolderEntry = async (path: string, kind: FolderEntryKind) => cancelLabel: t("session.deleteEntry.cancel"), }); - if (!confirmed) { - return false; - } - - if (activeDocumentIsInside(path) && !(await confirmDiscardActiveDocumentChanges())) { + if (!confirmed || !(await confirmDiscardTabChanges(getTabIdsInside(path)))) { return false; } return runAfterPendingSaves(async () => { await trashFolderContextEntry({ folderPath, path }); - if (activeDocumentIsInside(path)) { - useSessionStore.getState().setActiveDocument(null); - } + useSessionStore.getState().closeTabs(getTabIdsInside(path)); await refreshAfterFolderEntryChange(); @@ -136,30 +130,32 @@ const refreshAfterFolderEntryChange = async () => { const getFolderContextPath = () => useSessionStore.getState().folderContext?.path ?? null; -const activeDocumentIsInside = (path: string) => { - const { activeDocument } = useSessionStore.getState(); - - return activeDocument?.status === "saved" && isSameOrParentPath(path, activeDocument.path); -}; +const getTabIdsInside = (path: string) => + useSessionStore + .getState() + .tabs.filter( + ({ document }) => document.status === "saved" && isSameOrParentPath(path, document.path), + ) + .map((tab) => tab.id); -// The editor is keyed by the document path, so the moved document remounts from the Markdown the +// 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 moveActiveDocument = (fromPath: string, toPath: string) => { - const { activeDocument, setActiveDocument } = useSessionStore.getState(); +const moveOpenDocuments = (fromPath: string, toPath: string) => { + const session = useSessionStore.getState(); - if (activeDocument?.status !== "saved") { - return; - } + for (const { document, id } of session.tabs) { + if (document.status !== "saved") { + continue; + } - const nextPath = rebasePath(activeDocument.path, fromPath, toPath); + const nextPath = rebasePath(document.path, fromPath, toPath); - if (nextPath === null) { - return; + if (nextPath !== null) { + session.replaceTabDocument(id, { + ...document, + content: documentEditorBridge.getMarkdown(document.path) ?? document.content, + path: nextPath, + }); + } } - - setActiveDocument({ - ...activeDocument, - content: documentEditorBridge.getMarkdown(activeDocument.path) ?? activeDocument.content, - path: nextPath, - }); }; diff --git a/src/features/session/services/folderReplaceWorkflows.test.ts b/src/features/session/services/folderReplaceWorkflows.test.ts index b7bf4876..ee708512 100644 --- a/src/features/session/services/folderReplaceWorkflows.test.ts +++ b/src/features/session/services/folderReplaceWorkflows.test.ts @@ -17,7 +17,11 @@ import type { import { useFolderSearchStore } from "@/features/folder-search/stores/folderSearch"; import { requestDecision, type Decision } from "@/lib/confirmation"; import type { Disposable } from "@/lib/lifecycle"; -import { createOpenedMarkdownDocument, createSavedDocument } from "@/test/factories/document"; +import { + createOpenedMarkdownDocument, + createSavedDocument, + createUntitledDocument, +} from "@/test/factories/document"; import { createMilkdownEditorBridge } from "@/test/factories/editor"; import { createArticleTree, createFolderContext } from "@/test/factories/folderContext"; import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; @@ -133,7 +137,7 @@ const getReport = () => { }; // The editor's text is its document as a save would write it, a paragraph per line. -const createActiveEditor = (markdown: string) => { +const createActiveEditor = (markdown: string, path = ALPHA) => { const editor = { markdown, applied: [] as string[] }; const planFor = (query: TextSearchQuery, replacement: string): DocumentReplacementPlan => { const text = editor.markdown.trimEnd().split("\n").join("\u0000"); @@ -170,7 +174,7 @@ const createActiveEditor = (markdown: string) => { editor.applied.push(replacement); editor.markdown = next.replaced ?? editor.markdown; - useSessionStore.getState().markActiveDocumentDirty(ALPHA); + useSessionStore.getState().markDocumentDirty(path); return next.matches.length; }, @@ -455,6 +459,93 @@ describe("applying a folder replacement", () => { expect(useFolderSearchStore.getState().apply.status).toBe("done"); }); + describe("with several open documents affected", () => { + const openDocument = (path: string, markdown: string, isDirty: boolean) => { + const { bridge, editor } = createActiveEditor(markdown, path); + + documentEditorBridge.set(path, bridge); + + return { + editor, + document: createSavedDocument({ + path, + content: markdown, + isDirty, + metadata: metadataOf(path), + fingerprint: disk.get(path) ?? "", + }), + }; + }; + + it("asks about each unsaved document the plan includes, then plans again", async () => { + const alpha = openDocument(ALPHA, "Unsaved alpha leaf\n", true); + const beta = openDocument(BETA, "Unsaved beta leaf\n", true); + setDefaultSession({ + folderContext: createFolder(), + documents: [alpha.document, beta.document], + activeIndex: 1, + }); + vi.mocked(requestDecision) + .mockResolvedValueOnce("confirm") + .mockResolvedValueOnce("alternate"); + + await plan("leaf", "tree"); + await applyFolderReplacement(); + + expect(vi.mocked(requestDecision).mock.calls.map(([{ message }]) => message)).toEqual([ + "alpha.md has unsaved changes the replacement would include. Save or discard them first, then review the updated preview before replacing.", + "beta.md has unsaved changes the replacement would include. Save or discard them first, then review the updated preview before replacing.", + ]); + expect(getLastTauriApiArgs("saveMarkdownFile")).toMatchObject({ path: ALPHA }); + expect(getLastTauriApiArgs("openMarkdownFile")).toMatchObject({ path: BETA }); + expect(countTauriApiCalls("writeFolderReplacementFiles")).toBe(0); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: ALPHA, isDirty: false }, + { path: BETA, isDirty: false, content: "Beta leaf and another leaf.\n" }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: BETA }); + }); + + it("stops asking once one is cancelled, saving those already settled", async () => { + const alpha = openDocument(ALPHA, "Unsaved alpha leaf\n", true); + const beta = openDocument(BETA, "Unsaved beta leaf\n", true); + setDefaultSession({ + folderContext: createFolder(), + documents: [alpha.document, beta.document], + }); + vi.mocked(requestDecision).mockResolvedValueOnce("confirm").mockResolvedValueOnce("cancel"); + + await plan("leaf", "tree"); + await applyFolderReplacement(); + + expect(requestDecision).toHaveBeenCalledTimes(2); + expect(countTauriApiCalls("saveMarkdownFile")).toBe(1); + expect(countTauriApiCalls("openMarkdownFile")).toBe(0); + expect(countTauriApiCalls("writeFolderReplacementFiles")).toBe(0); + expect(useSessionStore.getState().tabs[1].document).toMatchObject({ isDirty: true }); + }); + + it("replaces a clean document in an inactive tab through its editor", async () => { + const alpha = openDocument(ALPHA, "An alpha leaf.\n", false); + setDefaultSession({ + folderContext: createFolder(), + documents: [alpha.document, createUntitledDocument()], + activeIndex: 1, + }); + + await plan("leaf", "tree"); + await applyFolderReplacement(); + + expect(alpha.editor.applied).toEqual(["tree"]); + expect(getLastTauriApiArgs("saveMarkdownFile")).toMatchObject({ + path: ALPHA, + content: "An alpha tree.\n", + }); + expect(writeOrder).toEqual([BETA]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ status: "untitled" }); + }); + }); + describe("with the open document affected", () => { it("asks about its unsaved changes and, cancelled, changes nothing", async () => { const editor = openActiveDocument("Unsaved leaf\n", true); diff --git a/src/features/session/services/folderReplaceWorkflows.ts b/src/features/session/services/folderReplaceWorkflows.ts index 930595fb..93001d93 100644 --- a/src/features/session/services/folderReplaceWorkflows.ts +++ b/src/features/session/services/folderReplaceWorkflows.ts @@ -21,15 +21,12 @@ import { import { requestDecision } from "@/lib/confirmation"; import { handleUnexpectedError } from "@/lib/errors"; import { t } from "@/lib/i18n"; -import { getPathParts, getRelativePath, isSamePath, PathMap } from "@/lib/path"; +import { getPathParts, getRelativePath, PathMap, PathSet } from "@/lib/path"; import { notifyError, notifySuccess, notifyWarning } from "@/lib/toast"; -import { useSessionStore } from "../stores/session"; +import { findTabByPath, useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; -import { - saveActiveMarkdownDocument, - saveActiveMarkdownDocumentWithoutPrompts, -} from "./documentWorkflows"; +import { saveDocumentTab, saveDocumentWithoutPrompts } from "./documentWorkflows"; import { refreshFolderSearch } from "./folderSearchWorkflows"; import { notifyOpenMarkdownFileError, openMarkdownFileAtPath } from "./openSession"; @@ -51,8 +48,8 @@ const isEditorFile = (file: PlannedFile): file is PlannedFile<"editor"> => /** * Writes the replacement the folder search planned, file by file in the order it lists them. The - * open document's unsaved changes are first saved or discarded, which plans again rather than - * writing, so an Apply only ever writes what the preview showed. + * unsaved changes of the open documents it includes are first saved or discarded, which plans + * again rather than writing, so an Apply only ever writes what the preview showed. */ export const applyFolderReplacement = async () => { const { apply, caseSensitive, query, replaceOpen, replacement, results, wholeWord } = @@ -72,7 +69,7 @@ export const applyFolderReplacement = async () => { const planned = sortByRelativePath(results.folderPath, results.files.filter(isPlanned)); - if (!(await settleActiveDocumentChanges(planned))) { + if (!(await settleOpenDocumentChanges(planned))) { return false; } @@ -99,45 +96,58 @@ const sortByRelativePath = (folderPath: string, files: PlannedFile[]) => .toSorted((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) .map(({ file }) => file); -const settleActiveDocumentChanges = async (planned: readonly PlannedFile[]) => { - const { activeDocument } = useSessionStore.getState(); - - if ( - activeDocument?.status !== "saved" || - !activeDocument.isDirty || - !planned.some((file) => isSamePath(file.path, activeDocument.path)) - ) { +const settleOpenDocumentChanges = async (planned: readonly PlannedFile[]) => { + const plannedPaths = new PathSet(planned.map((file) => file.path)); + const unsaved = useSessionStore + .getState() + .tabs.flatMap(({ document }) => + document.status === "saved" && document.isDirty && plannedPaths.has(document.path) + ? [document.path] + : [], + ); + + if (unsaved.length === 0) { return true; } - const decision = await requestDecision({ - title: t("session.folderReplace.unsavedChanges.title"), - message: t("session.folderReplace.unsavedChanges.message", { - name: getPathParts(activeDocument.path).name, - }), - confirmLabel: t("session.folderReplace.unsavedChanges.save"), - alternateLabel: t("session.folderReplace.unsavedChanges.discard"), - cancelLabel: t("session.folderReplace.unsavedChanges.cancel"), - }); + let settled = false; + + for (const path of unsaved) { + const decision = await requestDecision({ + title: t("session.folderReplace.unsavedChanges.title"), + message: t("session.folderReplace.unsavedChanges.message", { + name: getPathParts(path).name, + }), + confirmLabel: t("session.folderReplace.unsavedChanges.save"), + alternateLabel: t("session.folderReplace.unsavedChanges.discard"), + cancelLabel: t("session.folderReplace.unsavedChanges.cancel"), + }); - if (decision === "cancel") { - return false; - } + if (decision === "cancel") { + break; + } - const latest = useSessionStore.getState().activeDocument; + const latest = findTabByPath(useSessionStore.getState(), path); + + if (latest?.document.status !== "saved") { + continue; + } + + settled = true; - if (latest?.status === "saved" && isSamePath(latest.path, activeDocument.path)) { if (decision === "confirm") { try { - await saveActiveMarkdownDocument(); + await saveDocumentTab(latest.id); } catch (error) { notifyError(getSaveMarkdownFileErrorMessage(error)); } } else { try { - await openMarkdownFileAtPath(latest.path, { + await openMarkdownFileAtPath(path, { + activate: false, discardConfirmed: true, - encoding: latest.fileEncoding.name, + encoding: latest.document.fileEncoding.name, + reload: true, }); } catch (error) { notifyOpenMarkdownFileError(error); @@ -145,7 +155,9 @@ const settleActiveDocumentChanges = async (planned: readonly PlannedFile[]) => { } } - refreshFolderSearch(); + if (settled) { + refreshFolderSearch(); + } return false; }; @@ -215,7 +227,7 @@ const commit = async ( if (stopped) { recordNotAttempted([file], report); } else { - await commitActiveDocument(file, results, report); + await commitOpenDocument(file, results, report); } completed += 1; @@ -230,7 +242,7 @@ const commit = async ( const checked = preflight.get(file.path); // A file opened since the plan is the editor's to write, and its plan read the file instead. - if (isActiveDocumentPath(file.path)) { + if (isOpenDocumentPath(file.path)) { report.stale.push({ path: file.path, reason: "changedInEditor", unsavedInEditor: false }); } else if (checked?.kind !== "ready") { recordOutcome(file, checked, report); @@ -348,27 +360,24 @@ const recordNotAttempted = (files: readonly PlannedFile[], report: FolderReplace ); }; -const isActiveDocumentPath = (path: string) => { - const { activeDocument } = useSessionStore.getState(); - - return activeDocument?.status === "saved" && isSamePath(activeDocument.path, path); -}; +const isOpenDocumentPath = (path: string) => + findTabByPath(useSessionStore.getState(), path) !== null; /** - * The open document takes the replacement in its editor, as one step to undo, and is then saved. - * A document that changed since the plan, in the editor or on disk, is left as it is. + * An open document takes the replacement in its editor, as one step to undo, and is then saved. A + * document that changed since the plan, in the editor or on disk, is left as it is. */ -const commitActiveDocument = async ( +const commitOpenDocument = async ( file: PlannedFile<"editor">, results: FolderSearchResults, report: FolderReplaceReport, ) => { const stale = (reason: "changedInEditor" | "changedOnDisk" | "missing") => report.stale.push({ path: file.path, reason, unsavedInEditor: false }); - const session = useSessionStore.getState(); - const document = session.activeDocument; + const tab = findTabByPath(useSessionStore.getState(), file.path); + const document = tab?.document; - if (document?.status !== "saved" || !isSamePath(document.path, file.path) || document.isDirty) { + if (!tab || document?.status !== "saved" || document.isDirty) { stale("changedInEditor"); return; } @@ -407,19 +416,16 @@ const commitActiveDocument = async ( } const latest = useSessionStore.getState(); - const latestDocument = latest.activeDocument; + const latestTab = findTabByPath(latest, file.path); - if ( - latest.activeDocumentGeneration !== session.activeDocumentGeneration || - latestDocument !== document - ) { + if (latestTab?.generation !== tab.generation || latestTab.document !== document) { stale("changedInEditor"); return; } // A touch leaves the bytes the plan read, so Save is told the file's metadata as it now stands. if (fileState.kind === "metadataChanged") { - latest.setActiveDocumentFileState(document.path, { + latest.setDocumentFileState(document.path, { metadata: fileState.metadata, externalChange: null, }); @@ -441,7 +447,7 @@ const commitActiveDocument = async ( let saved; try { - saved = await saveActiveMarkdownDocumentWithoutPrompts(documentKey); + saved = await saveDocumentWithoutPrompts(documentKey); } catch (error) { handleUnexpectedError(error, "saveFolderReplacementDocument"); report.failed.push({ path: file.path, reason: "interrupted", unsavedInEditor: true }); @@ -456,11 +462,11 @@ const commitActiveDocument = async ( report.failed.push({ path: file.path, reason: "interrupted", unsavedInEditor: false }); return; case "failed": - recordActiveDocumentSaveFailure(file.path, saved.error, report); + recordOpenDocumentSaveFailure(file.path, saved.error, report); } }; -const recordActiveDocumentSaveFailure = ( +const recordOpenDocumentSaveFailure = ( path: string, error: SaveMarkdownFileError, report: FolderReplaceReport, diff --git a/src/features/session/services/folderSearchWorkflows.test.ts b/src/features/session/services/folderSearchWorkflows.test.ts index f9e3959a..19f67983 100644 --- a/src/features/session/services/folderSearchWorkflows.test.ts +++ b/src/features/session/services/folderSearchWorkflows.test.ts @@ -243,6 +243,7 @@ describe("folder search workflows", () => { expect(useSessionStore.getState().activeDocument).toMatchObject({ path: BETA }), ); documentEditorBridge.set(BETA, bridge); + documentEditorBridge.show(BETA); await opening; expect(editor.chosen).toEqual([ @@ -254,21 +255,97 @@ describe("folder search workflows", () => { expect(useFolderSearchStore.getState().chosenMatch).toEqual({ path: BETA, ordinal: 1 }); }); - it("leaves the editor and the results as they were when switching is declined", async () => { + it("opens a match's file in a new tab without asking about unsaved changes", async () => { setDefaultSession({ folderContext: createFolder(), activeDocument: createSavedDocument({ path: ALPHA, content: "unsaved", isDirty: true }), }); - const results = await search("leaf"); + await search("leaf"); + void openFolderSearchMatch({ path: BETA, ordinal: 0 }); - vi.mocked(requestConfirmation).mockResolvedValueOnce(false); - await openFolderSearchMatch({ path: BETA, ordinal: 0 }); + await vi.waitFor(() => + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: BETA }), + ); + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: ALPHA, isDirty: true }, + { path: BETA }, + ]); + }); + + it("switches to the tab holding a match's file and chooses the match there", async () => { + const { bridge, editor } = createEditor("An alpha leaf."); + + editor.choose(0); + setDefaultSession({ + folderContext: createFolder(), + documents: [ + createSavedDocument({ path: ALPHA, content: "An alpha leaf.\n" }), + createSavedDocument({ path: BETA }), + ], + activeIndex: 1, + }); + documentEditorBridge.set(ALPHA, bridge); + documentEditorBridge.show(BETA); + + await search("leaf"); + const opening = openFolderSearchMatch({ path: ALPHA, ordinal: 0 }); + + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: ALPHA }); + documentEditorBridge.show(ALPHA); + await opening; - expect(requestConfirmation).toHaveBeenCalledTimes(1); expect(countTauriApiCalls("openMarkdownFile")).toBe(0); - expect(useSessionStore.getState().activeDocument).toMatchObject({ path: ALPHA, isDirty: true }); - expect(useFolderSearchStore.getState().results).toBe(results); + expect(editor.chosen).toHaveLength(1); + expect(useFolderSearchStore.getState().chosenMatch).toEqual({ path: ALPHA, ordinal: 0 }); + }); + + it("searches an inactive tab's unsaved text rather than its file", async () => { + const { bridge } = createEditor("unsaved leaf\nleaf again\nleaf"); + + setDefaultSession({ + folderContext: createFolder(), + documents: [ + createSavedDocument({ path: ALPHA, content: "unsaved", isDirty: true }), + createSavedDocument({ path: BETA }), + ], + activeIndex: 1, + }); + documentEditorBridge.set(ALPHA, bridge); + + await search("leaf"); + + expect(summarize()).toEqual([ + [ALPHA, 3], + [BETA, 2], + ]); + }); + + it("reads a document's file again once its tab closes", async () => { + const { bridge } = createEditor("unsaved leaf\nleaf again\nleaf"); + + setDefaultSession({ + folderContext: createFolder(), + documents: [ + createSavedDocument({ path: ALPHA, content: "unsaved", isDirty: true }), + createSavedDocument({ path: BETA }), + ], + activeIndex: 1, + }); + documentEditorBridge.set(ALPHA, bridge); + + await search("leaf"); + + useSessionStore.getState().closeTabs([useSessionStore.getState().tabs[0].id]); + documentEditorBridge.set(ALPHA, null); + + await vi.waitFor(() => + expect(summarize()).toEqual([ + [ALPHA, 1], + [BETA, 2], + ]), + ); }); it("marks a match whose file is gone without asking about unsaved changes", async () => { @@ -301,6 +378,7 @@ describe("folder search workflows", () => { expect(useSessionStore.getState().activeDocument).toMatchObject({ path: BETA }), ); documentEditorBridge.set(BETA, bridge); + documentEditorBridge.show(BETA); await opening; expect(useFolderSearchStore.getState().chosenMatch).toBeNull(); @@ -323,6 +401,7 @@ describe("folder search workflows", () => { }), }); documentEditorBridge.set(ALPHA, bridge); + documentEditorBridge.show(ALPHA); await search("leaf"); await openFolderSearchMatch({ path: ALPHA, ordinal: 0 }); diff --git a/src/features/session/services/folderSearchWorkflows.ts b/src/features/session/services/folderSearchWorkflows.ts index 07988822..a361fc43 100644 --- a/src/features/session/services/folderSearchWorkflows.ts +++ b/src/features/session/services/folderSearchWorkflows.ts @@ -1,7 +1,7 @@ import { getActiveDocumentKey, inspectMarkdownDocument, - matchesActiveDocumentKey, + type SavedDocumentState, } from "@/features/document"; import { getArticlePaths, @@ -12,7 +12,7 @@ import { FolderSearchEngine, FolderSearchTextCache, useFolderSearchStore, - type FolderSearchActiveDocument, + type FolderSearchOpenDocument, type FolderSearchMatchKey, type FolderSearchState, } from "@/features/folder-search"; @@ -22,27 +22,26 @@ import { DisposableStore, toDisposable, type Disposable } from "@/lib/lifecycle" import { getPathParts, isSamePath, PathSet } from "@/lib/path"; import { notifyWarning } from "@/lib/toast"; -import { useSessionStore, type SessionState } from "../stores/session"; +import { + findTabByDocumentKey, + findTabByPath, + useSessionStore, + type SessionState, +} from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; import { notifyOpenMarkdownFileError, openMarkdownFileAtPath } from "./openSession"; // Typing waits this long for the query to settle before the folder is read for it. const QUERY_DELAY_MS = 200; -// Reading the open document's text costs as much as the document is long, so an edit waits this +// Reading an open document's text costs as much as the document is long, so an edit waits this // long for typing to settle before the document is searched again. const EDIT_DELAY_MS = 200; -const getActiveDocument = (): FolderSearchActiveDocument | null => { - const { activeDocument } = useSessionStore.getState(); - - if (activeDocument?.status !== "saved") { - return null; - } - - const documentKey = getActiveDocumentKey(activeDocument); +const toOpenDocument = (document: SavedDocumentState): FolderSearchOpenDocument => { + const documentKey = getActiveDocumentKey(document); return { - path: activeDocument.path, + path: document.path, readMatches: (query, options) => documentEditorBridge.readSearchMatches(documentKey, query, options), planReplacement: (query, replacement) => @@ -50,6 +49,21 @@ const getActiveDocument = (): FolderSearchActiveDocument | null => { }; }; +const getActiveDocument = () => { + const { activeDocument } = useSessionStore.getState(); + + return activeDocument?.status === "saved" ? toOpenDocument(activeDocument) : null; +}; + +const getOpenDocument = (path: string) => { + const document = findTabByPath(useSessionStore.getState(), path)?.document; + + return document?.status === "saved" ? toOpenDocument(document) : null; +}; + +const getSavedDocuments = (state: Pick) => + state.tabs.flatMap(({ document }) => (document.status === "saved" ? [document] : [])); + const hasQueryChanged = (state: FolderSearchState, previous: FolderSearchState) => state.query !== previous.query || state.caseSensitive !== previous.caseSensitive || @@ -83,6 +97,7 @@ class FolderSearchSession implements Disposable { private articlePaths: string[] = []; private queryTimeout: ReturnType | undefined; private editTimeout: ReturnType | undefined; + private readonly editedDocumentKeys = new Set(); private readonly subscriptions = new DisposableStore(); constructor() { @@ -175,23 +190,19 @@ class FolderSearchSession implements Disposable { this.search(false); } - const active = state.activeDocument; - const previousActive = previous.activeDocument; - const activeKey = active && getActiveDocumentKey(active); - const previousKey = previousActive && getActiveDocumentKey(previousActive); - - // The document the editor let go of is read from its file again; what the editor held, such - // as discarded changes, is no longer what the folder holds. - if (activeKey !== previousKey) { - if (previousActive?.status === "saved") { - this.engine.refreshFile(previousActive.path); - } - + if (state.tabs === previous.tabs) { return; } - if (active?.status === "saved" && active.content !== previousActive?.content) { - this.engine.refreshFile(active.path); + // A document an editor let go of is read from its file again; what the editor held, such as + // discarded changes, is no longer what the folder holds. One whose text was replaced, by a + // save or a reload, is read again as it now stands. + for (const document of getSavedDocuments(previous)) { + const current = findTabByPath(state, document.path)?.document; + + if (current?.status !== "saved" || current.content !== document.content) { + this.engine.refreshFile(document.path); + } } } @@ -200,15 +211,20 @@ class FolderSearchSession implements Disposable { return; } + this.editedDocumentKeys.add(documentKey); globalThis.clearTimeout(this.editTimeout); this.editTimeout = globalThis.setTimeout(() => { - const { activeDocument } = useSessionStore.getState(); + const session = useSessionStore.getState(); + const editedKeys = [...this.editedDocumentKeys]; - if ( - activeDocument?.status === "saved" && - matchesActiveDocumentKey(activeDocument, documentKey) - ) { - this.engine?.refreshFile(activeDocument.path); + this.editedDocumentKeys.clear(); + + for (const key of editedKeys) { + const document = findTabByDocumentKey(session, key)?.document; + + if (document?.status === "saved") { + this.engine?.refreshFile(document.path); + } } }, EDIT_DELAY_MS); } @@ -225,7 +241,7 @@ class FolderSearchSession implements Disposable { } this.engine ??= new FolderSearchEngine( - { getActiveDocument, textCache: this.textCache }, + { getActiveDocument, getOpenDocument, textCache: this.textCache }, (results) => useFolderSearchStore.getState().setResults(results), ); this.engine.start({ @@ -241,15 +257,17 @@ class FolderSearchSession implements Disposable { // leaves the folder, since what is searched is the editor's text rather than the file. private readArticlePaths(folderContext: FolderContextState) { const paths = getArticlePaths(folderContext.tree); - const { activeDocument } = useSessionStore.getState(); + const folderPaths = new PathSet(paths); - if (activeDocument?.status === "saved" && activeDocument.isDirty) { - const previousIndex = this.articlePaths.findIndex((path) => - isSamePath(path, activeDocument.path), - ); + for (const document of getSavedDocuments(useSessionStore.getState())) { + if (!document.isDirty || folderPaths.has(document.path)) { + continue; + } + + const previousIndex = this.articlePaths.findIndex((path) => isSamePath(path, document.path)); - if (previousIndex !== -1 && !new PathSet(paths).has(activeDocument.path)) { - paths.splice(Math.min(previousIndex, paths.length), 0, activeDocument.path); + if (previousIndex !== -1) { + paths.splice(Math.min(previousIndex, paths.length), 0, document.path); } } @@ -261,6 +279,7 @@ class FolderSearchSession implements Disposable { private stop() { globalThis.clearTimeout(this.queryTimeout); globalThis.clearTimeout(this.editTimeout); + this.editedDocumentKeys.clear(); this.engine?.dispose(); this.engine = null; this.articlePaths = []; @@ -347,10 +366,11 @@ export const openFolderSearchMatch = async (match: FolderSearchMatchKey) => { const isCurrent = () => useFolderSearchStore.getState().results?.id === results.id; const target = { ordinal: match.ordinal, context: found.context }; - const { activeDocument } = useSessionStore.getState(); - const isActive = activeDocument?.status === "saved" && isSamePath(activeDocument.path, file.path); + const openTab = findTabByPath(useSessionStore.getState(), file.path); - if (!isActive) { + if (openTab) { + useSessionStore.getState().activateTab(openTab.id); + } else { if (file.version.source === "disk") { const state = await inspectMarkdownDocument({ path: file.path, diff --git a/src/features/session/services/htmlExportWorkflow.test.ts b/src/features/session/services/htmlExportWorkflow.test.ts index 67a0500d..8631772b 100644 --- a/src/features/session/services/htmlExportWorkflow.test.ts +++ b/src/features/session/services/htmlExportWorkflow.test.ts @@ -12,7 +12,7 @@ import { import { createMilkdownEditorBridge } from "@/test/factories/editor"; import { createFolderContext } from "@/test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH } from "@/test/fixtures/paths"; -import { setDefaultSession } from "@/test/utils/appStores"; +import { replaceActiveDocument, setDefaultSession } from "@/test/utils/appStores"; import { countTauriApiCalls, getLastTauriApiArgs, mockTauriApi } from "@/test/utils/tauriApi"; import { documentEditorBridge } from "./documentEditorBridge"; @@ -82,7 +82,7 @@ describe("HTML export workflow", () => { expect(sessionAfter.activeDocument).toBe(sessionBefore.activeDocument); expect(sessionAfter.activeDocument?.isDirty).toBe(true); expect(sessionAfter.folderContext).toBe(sessionBefore.folderContext); - expect(sessionAfter.activeDocumentGeneration).toBe(sessionBefore.activeDocumentGeneration); + expect(sessionAfter.tabs).toBe(sessionBefore.tabs); }); it.each(["Guide.mdown", "Guide.MKD"])( @@ -134,7 +134,7 @@ describe("HTML export workflow", () => { it("writes nothing when the document changed while the picker was open", async () => { setExportableDocument(); vi.mocked(save).mockImplementation(async () => { - useSessionStore.getState().setActiveDocument(createUntitledDocument()); + replaceActiveDocument(createUntitledDocument()); return "C:/Notes/readme.html"; }); diff --git a/src/features/session/services/htmlExportWorkflow.ts b/src/features/session/services/htmlExportWorkflow.ts index 944c1151..1e285a7e 100644 --- a/src/features/session/services/htmlExportWorkflow.ts +++ b/src/features/session/services/htmlExportWorkflow.ts @@ -4,7 +4,6 @@ import { ensureHtmlExtension, getActiveDocumentKey, MARKDOWN_FILE_EXTENSIONS, - matchesActiveDocumentKey, selectHtmlExportPath, writeHtmlDocument, type ActiveDocumentState, @@ -14,7 +13,12 @@ import { SequentialTaskQueue } from "@/lib/async"; import { t } from "@/lib/i18n"; import { getPathParts } from "@/lib/path"; -import { getSessionFolderPath, useSessionStore } from "../stores/session"; +import { + findCurrentTab, + getActiveTab, + getSessionFolderPath, + useSessionStore, +} from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; export type HtmlExportOutcome = @@ -55,22 +59,18 @@ export const exportActiveMarkdownDocumentAsHtml = () => exportTaskQueue.run(exportActiveMarkdownDocumentAsHtmlNow); const exportActiveMarkdownDocumentAsHtmlNow = async (): Promise => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); + const activeTab = getActiveTab(useSessionStore.getState()); - if (!activeDocument) { + if (!activeTab) { return { status: "cancelled" }; } + const activeDocument = activeTab.document; const documentKey = getActiveDocumentKey(activeDocument); const selectedPath = await selectHtmlExportPath(await getExportDefaultPath(activeDocument)); const session = useSessionStore.getState(); - if ( - !selectedPath || - !session.activeDocument || - session.activeDocumentGeneration !== activeDocumentGeneration || - !matchesActiveDocumentKey(session.activeDocument, documentKey) - ) { + if (!selectedPath || !findCurrentTab(session, activeTab.id, activeTab.generation)) { return { status: "cancelled" }; } diff --git a/src/features/session/services/openSession.test.ts b/src/features/session/services/openSession.test.ts index aee12ad4..378f7b61 100644 --- a/src/features/session/services/openSession.test.ts +++ b/src/features/session/services/openSession.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { ENCODING_CHOICES, type OpenedMarkdownDocument } from "@/features/document"; import type { FolderContextState } from "@/features/folder-context"; import { useRecentItemsStore } from "@/features/preferences"; -import { useSessionStore } from "@/features/session"; +import { getActiveTab, useSessionStore } from "@/features/session"; import { requestConfirmation } from "@/lib/confirmation"; import { t } from "@/lib/i18n"; import { toastManager } from "@/lib/toast"; @@ -38,7 +38,6 @@ const OTHER_MARKDOWN_PATH = "C:/Notes/other.md"; const OTHER_FOLDER_PATH = "C:/Other"; const LATEST_MARKDOWN_PATH = "C:/Notes/latest.md"; const LATEST_FOLDER_PATH = "C:/Latest"; -const NEXT_UNTITLED_DOCUMENT_ID = "untitled:next"; const emptyNotesFolderContext = createEmptyFolderContext(); @@ -52,12 +51,6 @@ const openedLatestMarkdownFile = createOpenedMarkdownDocument({ content: "# Latest", }); -const createNextUntitledDocument = () => - createUntitledDocument({ - id: NEXT_UNTITLED_DOCUMENT_ID, - content: "Next", - }); - interface OpenFolderContextCommandResult { folder: FolderContextState; indexDocument: null; @@ -87,8 +80,8 @@ describe("reopen with a chosen encoding", () => { path: TEST_MARKDOWN_FILE_PATH, encoding: "windows-1252", }); + expect(getActiveTab(useSessionStore.getState())?.loadId).toBe(1); expect(useSessionStore.getState()).toMatchObject({ - activeDocumentLoadId: 1, activeDocument: { content: "# Café", isDirty: false, @@ -133,8 +126,8 @@ describe("reopen with a chosen encoding", () => { reopenMarkdownFileWithChosenEncoding(TEST_MARKDOWN_FILE_PATH, "windows-1252"), ).resolves.toBe(true); + expect(getActiveTab(useSessionStore.getState())?.loadId).toBe(0); expect(useSessionStore.getState()).toMatchObject({ - activeDocumentLoadId: 0, activeDocument: { encoding: { name: "windows-1252", bom: false } }, }); }); @@ -157,8 +150,8 @@ describe("reopen with a chosen encoding", () => { reopenMarkdownFileWithChosenEncoding(TEST_MARKDOWN_FILE_PATH, "windows-1252"), ).resolves.toBe(true); + expect(getActiveTab(useSessionStore.getState())?.loadId).toBe(1); expect(useSessionStore.getState()).toMatchObject({ - activeDocumentLoadId: 1, activeDocument: { content: "# Plain", isDirty: false }, }); }); @@ -374,45 +367,62 @@ describe("open session workflows", () => { }); }); - it("cancels open-file transitions before reading a new target", async () => { + it("opens a file in a new tab without asking about the active document's unsaved changes", async () => { + const activeDocument = createSavedDocument({ content: "# Local", isDirty: true }); + setDefaultSession({ activeDocument }); + mockTauriApi({ openMarkdownFile: () => openedOtherMarkdownFile }); + + await expect(openMarkdownFileAtPath(OTHER_MARKDOWN_PATH)).resolves.toBe(true); + + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + activeDocument, + { path: OTHER_MARKDOWN_PATH, content: "# Other" }, + ]); + expect(useSessionStore.getState().activeDocument).toMatchObject({ path: OTHER_MARKDOWN_PATH }); + }); + + it("adds the new tab after the active one", async () => { setDefaultSession({ - activeDocument: createSavedDocument({ - content: "# Local", - isDirty: true, - }), + documents: [createSavedDocument(), createSavedDocument({ path: LATEST_MARKDOWN_PATH })], }); + mockTauriApi({ openMarkdownFile: () => openedOtherMarkdownFile }); - await expect(openMarkdownFileAtPath(OTHER_MARKDOWN_PATH)).resolves.toBe(false); + await openMarkdownFileAtPath(OTHER_MARKDOWN_PATH); - expect(invoke).not.toHaveBeenCalled(); - expect(useSessionStore.getState().activeDocument).toMatchObject({ - status: "saved", - path: TEST_MARKDOWN_FILE_PATH, - content: "# Local", - isDirty: true, - }); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { path: TEST_MARKDOWN_FILE_PATH }, + { path: OTHER_MARKDOWN_PATH }, + { path: LATEST_MARKDOWN_PATH }, + ]); }); - it("cancels open-folder transitions before scanning a new folder", async () => { + it("activates the tab already holding a file instead of reading it again", async () => { setDefaultSession({ - activeDocument: createUntitledDocument({ isDirty: true }), + documents: [ + createSavedDocument({ isDirty: true }), + createSavedDocument({ path: OTHER_MARKDOWN_PATH }), + ], + activeIndex: 1, }); + mockTauriApi({}); - await expect(openFolderContextAtPath(OTHER_FOLDER_PATH)).resolves.toBe(false); + await expect(openMarkdownFileAtPath("c:\\notes\\README.md")).resolves.toBe(true); - expect(invoke).not.toHaveBeenCalled(); - expect(useSessionStore.getState().activeDocument).toMatchObject( - createUntitledDocument({ isDirty: true }), - ); + expect(countTauriApiCalls("openMarkdownFile")).toBe(0); + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(useSessionStore.getState().tabs).toHaveLength(2); + expect(useSessionStore.getState().activeDocument).toMatchObject({ + path: TEST_MARKDOWN_FILE_PATH, + isDirty: true, + }); + expect(useRecentItemsStore.getState().recentFiles).toMatchObject([ + { path: TEST_MARKDOWN_FILE_PATH }, + ]); }); - it("does not apply opened file results after the active document changes", async () => { + it("activates a tab that opened the same file while it was being read", async () => { const openFileDeferred = Promise.withResolvers(); - setDefaultSession({ - activeDocument: createSavedDocument({ - content: "# Readme", - }), - }); mockTauriApi({ openMarkdownFile: () => openFileDeferred.promise, scanMarkdownFolder: () => emptyNotesFolderContext, @@ -420,19 +430,17 @@ describe("open session workflows", () => { const openPromise = openMarkdownFileAtPath(OTHER_MARKDOWN_PATH); - await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith(tauriApiCommand("openMarkdownFile"), expect.any(Object)); - }); + await vi.waitFor(() => expect(countTauriApiCalls("openMarkdownFile")).toBe(1)); - useSessionStore.getState().setActiveDocument(createNextUntitledDocument()); + const openedMeanwhile = useSessionStore + .getState() + .openTab(createSavedDocument({ path: OTHER_MARKDOWN_PATH, content: "# Meanwhile" })); + useSessionStore.getState().openTab(createSavedDocument({ path: LATEST_MARKDOWN_PATH })); openFileDeferred.resolve(openedOtherMarkdownFile); - await expect(openPromise).resolves.toBe(false); - expect(useSessionStore.getState().activeDocument).toMatchObject({ - status: "untitled", - id: NEXT_UNTITLED_DOCUMENT_ID, - content: "Next", - }); + await expect(openPromise).resolves.toBe(true); + expect(useSessionStore.getState().tabs).toHaveLength(2); + expect(useSessionStore.getState().activeTabId).toBe(openedMeanwhile.id); }); it("does not apply superseded opened file results", async () => { @@ -486,42 +494,61 @@ describe("open session workflows", () => { }); }); - it("does not apply opened folder results after the active document changes", async () => { - const openedOtherFolder: OpenFolderContextCommandResult = { + it("opens a folder while keeping the open tabs, without asking about unsaved changes", async () => { + const activeDocument = createUntitledDocument({ isDirty: true }); + setDefaultSession({ activeDocument }); + mockTauriApiCommand("openMarkdownFolder", () => ({ folder: createEmptyFolderContext({ path: OTHER_FOLDER_PATH }), indexDocument: null, indexError: null, - }; - const openFolderDeferred = Promise.withResolvers(); - setDefaultSession({ - activeDocument: createSavedDocument({ - content: "# Readme", - }), - }); - mockTauriApi({ - openMarkdownFolder: () => openFolderDeferred.promise, - }); + })); - const openPromise = openFolderContextAtPath(OTHER_FOLDER_PATH); + await expect(openFolderContextAtPath(OTHER_FOLDER_PATH)).resolves.toBe(true); - await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - tauriApiCommand("openMarkdownFolder"), - expect.any(Object), - ); + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(useSessionStore.getState()).toMatchObject({ + folderContext: { path: OTHER_FOLDER_PATH }, + activeDocument, }); + }); + + it("opens the folder's index file in a new tab after the active one", async () => { + setDefaultSession({ activeDocument: createUntitledDocument() }); + mockTauriApiCommand("openMarkdownFolder", () => ({ + folder: createEmptyFolderContext({ path: OTHER_FOLDER_PATH }), + indexDocument: createOpenedMarkdownDocument({ path: `${OTHER_FOLDER_PATH}/index.md` }), + indexError: null, + })); - useSessionStore.getState().setActiveDocument(createNextUntitledDocument()); - openFolderDeferred.resolve(openedOtherFolder); + await openFolderContextAtPath(OTHER_FOLDER_PATH); - await expect(openPromise).resolves.toBe(false); + expect(useSessionStore.getState().tabs.map(({ document }) => document)).toMatchObject([ + { status: "untitled" }, + { path: `${OTHER_FOLDER_PATH}/index.md` }, + ]); expect(useSessionStore.getState().activeDocument).toMatchObject({ - status: "untitled", - id: NEXT_UNTITLED_DOCUMENT_ID, - content: "Next", + path: `${OTHER_FOLDER_PATH}/index.md`, }); }); + it("activates the index file's tab when the file is already open", async () => { + const indexDocument = createSavedDocument({ + path: `${OTHER_FOLDER_PATH}/index.md`, + isDirty: true, + }); + setDefaultSession({ documents: [indexDocument, createUntitledDocument()], activeIndex: 1 }); + mockTauriApiCommand("openMarkdownFolder", () => ({ + folder: createEmptyFolderContext({ path: OTHER_FOLDER_PATH }), + indexDocument: createOpenedMarkdownDocument({ path: `${OTHER_FOLDER_PATH}/index.md` }), + indexError: null, + })); + + await openFolderContextAtPath(OTHER_FOLDER_PATH); + + expect(useSessionStore.getState().tabs).toHaveLength(2); + expect(useSessionStore.getState().activeDocument).toEqual(indexDocument); + }); + it("does not apply superseded opened folder results", async () => { const supersededOpenFolder = Promise.withResolvers(); const latestOpenFolder = Promise.withResolvers(); @@ -581,7 +608,7 @@ describe("open session workflows", () => { await expect(latestOpenPromise).resolves.toBe(true); expect(useSessionStore.getState()).toMatchObject({ folderContext: { path: LATEST_FOLDER_PATH }, - activeDocument: null, + activeDocument: { status: "saved", path: TEST_MARKDOWN_FILE_PATH }, }); }); }); @@ -671,7 +698,7 @@ describe("folder context after opening a file", () => { expect(useSessionStore.getState()).toMatchObject({ folderContext: { path: LATEST_FOLDER_PATH }, folderContextLoad: null, - activeDocument: null, + activeDocument: { path: OTHER_MARKDOWN_PATH }, }); expect(useRecentItemsStore.getState().recentFolders).toMatchObject([ { path: LATEST_FOLDER_PATH }, diff --git a/src/features/session/services/openSession.ts b/src/features/session/services/openSession.ts index e6a4db32..2d36a717 100644 --- a/src/features/session/services/openSession.ts +++ b/src/features/session/services/openSession.ts @@ -1,6 +1,5 @@ import { ENCODING_CHOICES, - getActiveDocumentKey, getOpenMarkdownFileErrorMessage, isEncodingOpenError, matchesActiveDocumentKey, @@ -8,7 +7,6 @@ import { selectMarkdownFilePath, toSavedDocument, type TextEncodingName, - type SavedDocumentState, } from "@/features/document"; import { getScanFolderContextErrorMessage, @@ -27,53 +25,80 @@ import { t } from "@/lib/i18n"; import type { MessageData } from "@/lib/messages"; import { notifyError, notifyErrorWithActionMenu, notifySuccess } from "@/lib/toast"; -import { type FolderContextLoad, useSessionStore } from "../stores/session"; +import { + findTab, + findTabByPath, + useSessionStore, + type DocumentTab, + type FolderContextLoad, +} from "../stores/session"; import { getSessionFolderOpenOptions, getSessionFolderScanOptions } from "./folderContextWorkflows"; import { takeLaunchDocumentPath } from "./launchDocumentApi"; -import { confirmDiscardActiveDocumentChanges } from "./unsavedChanges"; +import { confirmDiscardTabChanges } from "./unsavedChanges"; const openTransitionRunner = new RestartableTaskRunner(); interface OpenMarkdownFileOptions { + /** Reads the file again into the tab already holding it, instead of only activating that tab. */ + reload?: boolean; + /** Leaves the active tab as it is when reloading a document open in another tab. */ + activate?: boolean; + /** Skips asking about the open tab's unsaved changes, which the caller has already settled. */ discardConfirmed?: boolean; encoding?: TextEncodingName; } +/** + * Opens a file in a new tab after the active one, or activates the tab already holding it, so + * opening never replaces another document. + */ export const openMarkdownFileAtPath = ( path: string, - { discardConfirmed = false, encoding }: OpenMarkdownFileOptions = {}, + { + activate = true, + discardConfirmed = false, + encoding, + reload = false, + }: OpenMarkdownFileOptions = {}, ) => runLatestOpenTransition(async (cancellationToken) => { - if ( - !discardConfirmed && - !(await runWithCancellation(cancellationToken, confirmDiscardActiveDocumentChanges)) - ) { - return false; + const openTab = findTabByPath(useSessionStore.getState(), path); + + if (openTab && !reload) { + useSessionStore.getState().activateTab(openTab.id); + recordRecentFile(openTab.document.status === "saved" ? openTab.document.path : path); + + return true; + } + + if (openTab) { + return reloadTab(openTab, { activate, discardConfirmed, encoding }, cancellationToken); } - const initialDocumentKey = getActiveDocumentKeySnapshot(); const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); const { parentFolderPath, ...documentFields } = openedDocument; - const savedDocument = toSavedDocument(documentFields); + const session = useSessionStore.getState(); + const tabOpenedMeanwhile = findTabByPath(session, documentFields.path); - if (!activeDocumentMatchesSnapshot(initialDocumentKey)) { - return false; + if (tabOpenedMeanwhile) { + session.activateTab(tabOpenedMeanwhile.id); + recordRecentFile(documentFields.path); + + return true; } - const session = useSessionStore.getState(); - const reload = replacesActiveDocumentText(savedDocument); + const savedDocument = toSavedDocument(documentFields); if (session.folderContext || session.folderContextLoad) { - session.setActiveDocument(savedDocument, { reload }); + session.openTab(savedDocument); recordRecentFile(documentFields.path); return true; } - const folderContextLoad = session.setActiveDocumentWithFolderContextLoad( + const { folderContextLoad } = session.openTabWithFolderContextLoad( parentFolderPath, savedDocument, - { reload }, ); recordRecentFile(documentFields.path); void loadFolderContext(folderContextLoad); @@ -81,6 +106,56 @@ export const openMarkdownFileAtPath = ( return true; }); +/** + * The editor keeps its state across a same-path update, so a reload must say when the text + * changed. The tab's text predates any edits, so an edited document is replaced: its edits were + * already discarded to reload it. + */ +const reloadTab = async ( + tab: DocumentTab, + { + activate, + discardConfirmed, + encoding, + }: Pick, + cancellationToken: CancellationToken, +) => { + if ( + !discardConfirmed && + !(await runWithCancellation(cancellationToken, () => confirmDiscardTabChanges([tab.id]))) + ) { + return false; + } + + const path = tab.document.status === "saved" ? tab.document.path : null; + + if (!path) { + return false; + } + + const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); + const { parentFolderPath: _parentFolderPath, ...documentFields } = openedDocument; + const savedDocument = toSavedDocument(documentFields); + const session = useSessionStore.getState(); + const latestTab = findTab(session, tab.id); + + if (!latestTab || !matchesActiveDocumentKey(latestTab.document, path)) { + return false; + } + + session.replaceTabDocument(tab.id, savedDocument, { + reload: latestTab.document.isDirty || latestTab.document.content !== savedDocument.content, + }); + + if (activate) { + session.activateTab(tab.id); + } + + recordRecentFile(documentFields.path); + + return true; +}; + /** The document is already open, so a slow or failed scan of its folder only delays or loses the navigator. */ const loadFolderContext = async ({ id, path }: FolderContextLoad) => { try { @@ -96,21 +171,6 @@ const loadFolderContext = async ({ id, path }: FolderContextLoad) => { } }; -/** - * The editor keeps its state across a same-path update, so a reopen must say when the text changed. - * The session's text predates any edits, so an edited document is replaced: its edits were already - * discarded to reopen it. - */ -const replacesActiveDocumentText = (openedDocument: SavedDocumentState) => { - const { activeDocument } = useSessionStore.getState(); - - return ( - activeDocument !== null && - matchesActiveDocumentKey(activeDocument, openedDocument.path) && - (activeDocument.isDirty || activeDocument.content !== openedDocument.content) - ); -}; - export const notifyOpenMarkdownFileError = ( error: unknown, message: MessageData = getOpenMarkdownFileErrorMessage(error), @@ -134,7 +194,7 @@ export const reopenMarkdownFileWithChosenEncoding = async ( encoding: TextEncodingName, ) => { try { - const opened = await openMarkdownFileAtPath(path, { encoding }); + const opened = await openMarkdownFileAtPath(path, { encoding, reload: true }); if (opened) { notifySuccess(t("session.documentOpened")); @@ -173,29 +233,24 @@ 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. */ export const openFolderContextAtPath = (path: string) => runLatestOpenTransition(async (cancellationToken) => { - if (!(await runWithCancellation(cancellationToken, confirmDiscardActiveDocumentChanges))) { - return false; - } - - const initialDocumentKey = getActiveDocumentKeySnapshot(); const { folderContext, indexDocument, indexError } = await openFolderContext( path, getSessionFolderOpenOptions(), cancellationToken, ); + const session = useSessionStore.getState(); + const indexTab = indexDocument && findTabByPath(session, indexDocument.path); - if (!activeDocumentMatchesSnapshot(initialDocumentKey)) { - return false; - } - - if (indexDocument) { - useSessionStore - .getState() - .setActiveDocumentSession(folderContext, toSavedDocument(indexDocument)); + if (indexTab) { + session.setFolderContext(folderContext); + session.activateTab(indexTab.id); + } else if (indexDocument) { + session.openTab(toSavedDocument(indexDocument), { folderContext }); } else { - useSessionStore.getState().setFolderOnlySession(folderContext); + session.setFolderContext(folderContext); } recordRecentFolderSession(folderContext.path); @@ -214,26 +269,6 @@ export const pickAndOpenFolderContext = async () => { return openFolderContextAtPath(selectedPath); }; -const getActiveDocumentKeySnapshot = () => { - const { activeDocument } = useSessionStore.getState(); - - if (!activeDocument) { - return null; - } - - return getActiveDocumentKey(activeDocument); -}; - -const activeDocumentMatchesSnapshot = (documentKey: string | null) => { - const { activeDocument } = useSessionStore.getState(); - - if (!activeDocument || !documentKey) { - return documentKey === activeDocument; - } - - return matchesActiveDocumentKey(activeDocument, documentKey); -}; - const runLatestOpenTransition = async ( transition: (cancellationToken: CancellationToken) => Promise, ) => { diff --git a/src/features/session/services/tabWorkflows.ts b/src/features/session/services/tabWorkflows.ts new file mode 100644 index 00000000..dc27523d --- /dev/null +++ b/src/features/session/services/tabWorkflows.ts @@ -0,0 +1,37 @@ +import { findTab, getTabDocumentKey, useSessionStore } from "../stores/session"; +import { documentEditorBridge } from "./documentEditorBridge"; + +/** Switches to a tab and gives its editor focus, as choosing a tab or a tab shortcut does. */ +export const activateDocumentTab = (tabId: string) => { + const session = useSessionStore.getState(); + const tab = findTab(session, tabId); + + if (!tab) { + return false; + } + + session.activateTab(tab.id); + documentEditorBridge.focusWhenShown(getTabDocumentKey(tab)); + + return true; +}; + +/** Moves through the tabs in strip order, wrapping at either end. */ +export const activateAdjacentTab = (offset: 1 | -1) => { + const { activeTabId, tabs } = useSessionStore.getState(); + const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId); + + if (activeIndex === -1 || tabs.length < 2) { + return false; + } + + return activateDocumentTab(tabs[(activeIndex + offset + tabs.length) % tabs.length].id); +}; + +/** Switches to the tab at a position in the strip, counting from the end for a negative one. */ +export const activateTabAt = (index: number) => { + const { tabs } = useSessionStore.getState(); + const tab = tabs.at(index); + + return tab ? activateDocumentTab(tab.id) : false; +}; diff --git a/src/features/session/services/unsavedChanges.test.ts b/src/features/session/services/unsavedChanges.test.ts index 41e129b2..f2d309f4 100644 --- a/src/features/session/services/unsavedChanges.test.ts +++ b/src/features/session/services/unsavedChanges.test.ts @@ -3,19 +3,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useSessionStore } from "@/features/session"; import { requestConfirmation } from "@/lib/confirmation"; import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; -import { setDefaultSession } from "@/test/utils/appStores"; +import { replaceActiveDocument, setDefaultSession } from "@/test/utils/appStores"; -import { confirmDiscardActiveDocumentChanges } from "./unsavedChanges"; +import { confirmDiscardTabChanges, confirmDiscardAllDocumentChanges } from "./unsavedChanges"; vi.mock("@/lib/confirmation", () => ({ requestConfirmation: vi.fn(async () => false) })); -describe("confirmDiscardActiveDocumentChanges", () => { +const confirmDiscardActiveTabChanges = () => { + const { activeTabId } = useSessionStore.getState(); + + return confirmDiscardTabChanges(activeTabId ? [activeTabId] : []); +}; + +describe("confirmDiscardTabChanges", () => { beforeEach(() => { vi.mocked(requestConfirmation).mockReset().mockResolvedValue(false); }); it("returns true without prompting when there is no active document", async () => { - await expect(confirmDiscardActiveDocumentChanges()).resolves.toBe(true); + await expect(confirmDiscardActiveTabChanges()).resolves.toBe(true); expect(requestConfirmation).not.toHaveBeenCalled(); }); @@ -25,7 +31,7 @@ describe("confirmDiscardActiveDocumentChanges", () => { activeDocument: createSavedDocument(), }); - await expect(confirmDiscardActiveDocumentChanges()).resolves.toBe(true); + await expect(confirmDiscardActiveTabChanges()).resolves.toBe(true); expect(requestConfirmation).not.toHaveBeenCalled(); }); @@ -35,11 +41,11 @@ describe("confirmDiscardActiveDocumentChanges", () => { activeDocument: createUntitledDocument({ isDirty: true }), }); - await expect(confirmDiscardActiveDocumentChanges()).resolves.toBe(false); + await expect(confirmDiscardActiveTabChanges()).resolves.toBe(false); expect(requestConfirmation).toHaveBeenCalledWith({ title: "Unsaved changes", - message: "The active document has unsaved changes. Discard them and continue?", + message: "Untitled has unsaved changes. Discard them and continue?", confirmLabel: "Discard changes", cancelLabel: "Keep editing", }); @@ -51,7 +57,7 @@ describe("confirmDiscardActiveDocumentChanges", () => { }); vi.mocked(requestConfirmation).mockResolvedValue(true); - await expect(confirmDiscardActiveDocumentChanges()).resolves.toBe(true); + await expect(confirmDiscardActiveTabChanges()).resolves.toBe(true); }); it("returns false when the active document changes while confirmation is pending", async () => { @@ -61,11 +67,11 @@ describe("confirmDiscardActiveDocumentChanges", () => { }); vi.mocked(requestConfirmation).mockImplementation(() => confirmDeferred.promise); - const confirmation = confirmDiscardActiveDocumentChanges(); + const confirmation = confirmDiscardActiveTabChanges(); await vi.waitFor(() => expect(requestConfirmation).toHaveBeenCalledOnce()); - useSessionStore.getState().setActiveDocument(createUntitledDocument({ id: "untitled:next" })); + replaceActiveDocument(createUntitledDocument({ id: "untitled:next" })); confirmDeferred.resolve(true); await expect(confirmation).resolves.toBe(false); @@ -76,10 +82,85 @@ describe("confirmDiscardActiveDocumentChanges", () => { setDefaultSession({ activeDocument: createSavedDocument({ isDirty: true }) }); vi.mocked(requestConfirmation).mockReturnValue(decision.promise); - const confirmation = confirmDiscardActiveDocumentChanges(); + const confirmation = confirmDiscardActiveTabChanges(); + await vi.waitFor(() => expect(requestConfirmation).toHaveBeenCalledOnce()); + + replaceActiveDocument(createSavedDocument({ isDirty: true })); + decision.resolve(true); + + await expect(confirmation).resolves.toBe(false); + }); +}); + +describe("confirmDiscardAllDocumentChanges", () => { + beforeEach(() => { + vi.mocked(requestConfirmation).mockReset().mockResolvedValue(false); + }); + + it("names every unsaved document in one dialog offering to discard them all", async () => { + setDefaultSession({ + documents: [ + createSavedDocument({ isDirty: true }), + createSavedDocument({ path: "C:/Notes/clean.md" }), + createUntitledDocument({ id: "untitled:2", isDirty: true }), + ], + }); + vi.mocked(requestConfirmation).mockResolvedValue(true); + + await expect(confirmDiscardAllDocumentChanges()).resolves.toBe(true); + + expect(requestConfirmation).toHaveBeenCalledExactlyOnceWith({ + title: "Unsaved changes", + message: "2 documents have unsaved changes. Discard them and continue?", + detail: "readme.md\nUntitled 2", + confirmLabel: "Discard all", + cancelLabel: "Cancel", + }); + }); + + it("asks about a single unsaved document by name", async () => { + setDefaultSession({ + documents: [ + createSavedDocument({ path: "C:/Notes/clean.md" }), + createSavedDocument({ isDirty: true }), + ], + }); + + await expect(confirmDiscardAllDocumentChanges()).resolves.toBe(false); + + expect(requestConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + message: "readme.md has unsaved changes. Discard them and continue?", + confirmLabel: "Discard changes", + }), + ); + }); + + it("does not prompt when no document has unsaved changes", async () => { + setDefaultSession({ + documents: [createSavedDocument(), createUntitledDocument()], + }); + + await expect(confirmDiscardAllDocumentChanges()).resolves.toBe(true); + + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it("returns false when a document it asked about is replaced while the dialog is open", async () => { + const decision = Promise.withResolvers(); + setDefaultSession({ + documents: [ + createSavedDocument({ isDirty: true }), + createUntitledDocument({ isDirty: true }), + ], + activeIndex: 1, + }); + vi.mocked(requestConfirmation).mockReturnValue(decision.promise); + + const confirmation = confirmDiscardAllDocumentChanges(); await vi.waitFor(() => expect(requestConfirmation).toHaveBeenCalledOnce()); - useSessionStore.getState().setActiveDocument(createSavedDocument({ isDirty: true })); + replaceActiveDocument(createSavedDocument({ path: "C:/Notes/saved.md" })); decision.resolve(true); await expect(confirmation).resolves.toBe(false); diff --git a/src/features/session/services/unsavedChanges.ts b/src/features/session/services/unsavedChanges.ts index b3d4b8af..c7019cba 100644 --- a/src/features/session/services/unsavedChanges.ts +++ b/src/features/session/services/unsavedChanges.ts @@ -1,34 +1,49 @@ -import { getActiveDocumentKey, matchesActiveDocumentKey } from "@/features/document"; import { requestConfirmation } from "@/lib/confirmation"; import { t } from "@/lib/i18n"; -import { useSessionStore } from "../stores/session"; +import { findCurrentTab, findTab, useSessionStore, type DocumentTab } from "../stores/session"; +import { getDocumentName } from "../utils/documentNames"; -export const confirmDiscardActiveDocumentChanges = async () => { - const { activeDocument, activeDocumentGeneration } = useSessionStore.getState(); +/** + * Asks once about the unsaved changes in the given tabs, naming them. Resolves `true` when none has + * unsaved changes, or when they are discarded while every one still holds the version asked about. + */ +export const confirmDiscardTabChanges = async (tabIds: readonly string[]) => { + const session = useSessionStore.getState(); + const dirtyTabs = tabIds + .map((tabId) => findTab(session, tabId)) + .filter((tab): tab is DocumentTab => tab?.document.isDirty === true); - if (!activeDocument?.isDirty) { + if (dirtyTabs.length === 0) { return true; } - const documentKey = getActiveDocumentKey(activeDocument); - const shouldDiscard = await requestConfirmation({ - title: t("session.unsavedChanges.title"), - message: t("session.unsavedChanges.message"), - confirmLabel: t("session.unsavedChanges.confirm"), - cancelLabel: t("session.unsavedChanges.cancel"), - }); + const names = dirtyTabs.map((tab) => getDocumentName(tab.document, t)); + const shouldDiscard = await requestConfirmation( + names.length === 1 + ? { + title: t("session.unsavedChanges.title"), + message: t("session.unsavedChanges.message", { name: names[0] }), + confirmLabel: t("session.unsavedChanges.confirm"), + cancelLabel: t("session.unsavedChanges.cancel"), + } + : { + title: t("session.unsavedChanges.title"), + message: t("session.unsavedDocuments.message", { count: names.length }), + detail: names.join("\n"), + confirmLabel: t("session.unsavedDocuments.confirm"), + cancelLabel: t("session.unsavedDocuments.cancel"), + }, + ); if (!shouldDiscard) { return false; } const latestSession = useSessionStore.getState(); - const latestDocument = latestSession.activeDocument; - return ( - latestDocument !== null && - latestSession.activeDocumentGeneration === activeDocumentGeneration && - matchesActiveDocumentKey(latestDocument, documentKey) - ); + return dirtyTabs.every((tab) => findCurrentTab(latestSession, tab.id, tab.generation) !== null); }; + +export const confirmDiscardAllDocumentChanges = () => + confirmDiscardTabChanges(useSessionStore.getState().tabs.map((tab) => tab.id)); diff --git a/src/features/session/stores/session.test.ts b/src/features/session/stores/session.test.ts index ae1e0c4d..b128be3e 100644 --- a/src/features/session/stores/session.test.ts +++ b/src/features/session/stores/session.test.ts @@ -8,7 +8,15 @@ import { import { createFolderContext } from "@/test/factories/folderContext"; import { TEST_MARKDOWN_FILE_PATH, TEST_NOTES_FOLDER_PATH } from "@/test/fixtures/paths"; -import { getSessionMode, useSessionStore } from "./session"; +import { findTab, getActiveTab, getSessionMode, useSessionStore } from "./session"; + +const OTHER_PATH = "C:/Notes/other.md"; +const THIRD_PATH = "C:/Notes/third.md"; + +const getPaths = () => + useSessionStore + .getState() + .tabs.map(({ document }) => (document.status === "saved" ? document.path : document.id)); describe("session store", () => { beforeEach(() => useSessionStore.getState().reset()); @@ -29,93 +37,162 @@ describe("session store", () => { ).toBe("document"); }); - it("sets folder-only sessions by clearing the active document", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument()); + it("opens each document in a new tab after the active one and activates it", () => { + const { openTab } = useSessionStore.getState(); + const first = openTab(createSavedDocument()); + openTab(createSavedDocument({ path: OTHER_PATH })); - useSessionStore.getState().setFolderOnlySession(createFolderContext()); + useSessionStore.getState().activateTab(first.id); + const third = openTab(createSavedDocument({ path: THIRD_PATH })); + expect(getPaths()).toEqual([TEST_MARKDOWN_FILE_PATH, THIRD_PATH, OTHER_PATH]); expect(useSessionStore.getState()).toMatchObject({ - activeDocument: null, - folderContext: { path: TEST_NOTES_FOLDER_PATH }, + activeTabId: third.id, + activeDocument: { path: THIRD_PATH }, }); }); - it("sets active document sessions atomically", () => { - const activeDocument = createSavedDocument(); + it("replaces the folder context together with a tab it opens", () => { const folderContext = createFolderContext(); - useSessionStore.getState().setActiveDocumentSession(folderContext, activeDocument); + useSessionStore.getState().openTab(createSavedDocument(), { folderContext }); expect(useSessionStore.getState()).toMatchObject({ - activeDocument, - folderContext, + activeDocument: { path: TEST_MARKDOWN_FILE_PATH }, + folderContext: { path: TEST_NOTES_FOLDER_PATH }, }); }); + it("closes tabs, activating the next tab to the right, or else to the left", () => { + const { openTab } = useSessionStore.getState(); + const first = openTab(createSavedDocument()); + const second = openTab(createSavedDocument({ path: OTHER_PATH })); + const third = openTab(createSavedDocument({ path: THIRD_PATH })); + + useSessionStore.getState().activateTab(second.id); + useSessionStore.getState().closeTabs([second.id]); + + expect(useSessionStore.getState().activeTabId).toBe(third.id); + + useSessionStore.getState().closeTabs([third.id]); + + expect(useSessionStore.getState().activeTabId).toBe(first.id); + + useSessionStore.getState().closeTabs([first.id]); + + expect(useSessionStore.getState()).toMatchObject({ + tabs: [], + activeTabId: null, + activeDocument: null, + }); + }); + + it("keeps the active tab when another tab closes", () => { + const { openTab } = useSessionStore.getState(); + const first = openTab(createSavedDocument()); + const second = openTab(createSavedDocument({ path: OTHER_PATH })); + + useSessionStore.getState().closeTabs([first.id]); + + expect(useSessionStore.getState().activeTabId).toBe(second.id); + }); + + it("ignores activating a tab that is not open", () => { + const tab = useSessionStore.getState().openTab(createSavedDocument()); + const state = useSessionStore.getState(); + + useSessionStore.getState().activateTab("tab:missing"); + + expect(useSessionStore.getState()).toBe(state); + expect(useSessionStore.getState().activeTabId).toBe(tab.id); + }); + it("tracks document replacement separately from edits to the current document", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument()); - const generation = useSessionStore.getState().activeDocumentGeneration; + const tab = useSessionStore.getState().openTab(createSavedDocument()); + + useSessionStore.getState().setDocumentContent(TEST_MARKDOWN_FILE_PATH, "Edited"); + expect(findTab(useSessionStore.getState(), tab.id)?.generation).toBe(tab.generation); + + useSessionStore.getState().replaceTabDocument(tab.id, createSavedDocument()); + expect(findTab(useSessionStore.getState(), tab.id)?.generation).not.toBe(tab.generation); + }); + + it("does not replace a document when another tab is activated", () => { + const tab = useSessionStore.getState().openTab(createSavedDocument()); + useSessionStore.getState().openTab(createSavedDocument({ path: OTHER_PATH })); - useSessionStore.getState().setActiveDocumentContent(TEST_MARKDOWN_FILE_PATH, "Edited"); - expect(useSessionStore.getState().activeDocumentGeneration).toBe(generation); + useSessionStore.getState().activateTab(tab.id); - useSessionStore.getState().setActiveDocument(createSavedDocument()); - expect(useSessionStore.getState().activeDocumentGeneration).toBe(generation + 1); + expect(getActiveTab(useSessionStore.getState())).toMatchObject({ + generation: tab.generation, + document: tab.document, + }); }); - it("updates saved active document content only for matching document keys", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument({ content: "# Original" })); + it("updates saved document content only for matching document keys", () => { + useSessionStore.getState().openTab(createSavedDocument({ content: "# Original" })); - useSessionStore.getState().setActiveDocumentContent("C:/Other/readme.md", "# Stale"); + useSessionStore.getState().setDocumentContent("C:/Other/readme.md", "# Stale"); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "# Original", }); - useSessionStore.getState().setActiveDocumentContent(TEST_MARKDOWN_FILE_PATH, "# Updated"); + useSessionStore.getState().setDocumentContent(TEST_MARKDOWN_FILE_PATH, "# Updated"); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "# Updated", }); }); - it("matches saved active document keys by path identity", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument({ content: "# Original" })); + it("updates the document of an inactive tab without changing the active one", () => { + useSessionStore.getState().openTab(createSavedDocument()); + useSessionStore.getState().openTab(createSavedDocument({ path: OTHER_PATH })); + const activeDocument = useSessionStore.getState().activeDocument; + + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); + + expect(useSessionStore.getState().activeDocument).toBe(activeDocument); + expect(useSessionStore.getState().tabs[0].document.isDirty).toBe(true); + }); + + it("matches saved document keys by path identity", () => { + useSessionStore.getState().openTab(createSavedDocument({ content: "# Original" })); - useSessionStore.getState().setActiveDocumentContent("c:\\notes\\readme.md", "# Updated"); + useSessionStore.getState().setDocumentContent("c:\\notes\\readme.md", "# Updated"); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "# Updated", }); }); - it("updates untitled active document content only for matching document ids", () => { - useSessionStore.getState().setActiveDocument(createUntitledDocument()); + it("updates untitled document content only for matching document ids", () => { + useSessionStore.getState().openTab(createUntitledDocument()); - useSessionStore.getState().setActiveDocumentContent("untitled:stale", "Stale"); + useSessionStore.getState().setDocumentContent("untitled:stale", "Stale"); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "Draft", }); - useSessionStore.getState().setActiveDocumentContent(TEST_UNTITLED_DOCUMENT_ID, "Updated"); + useSessionStore.getState().setDocumentContent(TEST_UNTITLED_DOCUMENT_ID, "Updated"); expect(useSessionStore.getState().activeDocument).toMatchObject({ content: "Updated", }); }); - it("updates line endings only for the matching active document", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument()); + it("updates line endings only for the matching document", () => { + useSessionStore.getState().openTab(createSavedDocument()); - useSessionStore.getState().setActiveDocumentLineEnding("C:/Other/readme.md", "crlf"); + useSessionStore.getState().setDocumentLineEnding("C:/Other/readme.md", "crlf"); expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: false, lineEnding: "lf", }); - useSessionStore.getState().setActiveDocumentLineEnding(TEST_MARKDOWN_FILE_PATH, "crlf"); + useSessionStore.getState().setDocumentLineEnding(TEST_MARKDOWN_FILE_PATH, "crlf"); expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: true, @@ -123,18 +200,18 @@ describe("session store", () => { }); }); - it("marks the active document dirty when its encoding changes, keeping the file's encoding", () => { + it("marks a document dirty when its encoding changes, keeping the file's encoding", () => { const windows1252 = { name: "windows-1252", bom: false } as const; - useSessionStore.getState().setActiveDocument(createSavedDocument({ encoding: windows1252 })); + useSessionStore.getState().openTab(createSavedDocument({ encoding: windows1252 })); const activeDocument = useSessionStore.getState().activeDocument; - useSessionStore.getState().setActiveDocumentEncoding(TEST_MARKDOWN_FILE_PATH, windows1252); + useSessionStore.getState().setDocumentEncoding(TEST_MARKDOWN_FILE_PATH, windows1252); expect(useSessionStore.getState().activeDocument).toBe(activeDocument); useSessionStore .getState() - .setActiveDocumentEncoding(TEST_MARKDOWN_FILE_PATH, { name: "UTF-8", bom: true }); + .setDocumentEncoding(TEST_MARKDOWN_FILE_PATH, { name: "UTF-8", bom: true }); expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: true, @@ -144,65 +221,66 @@ describe("session store", () => { }); it("changes the editor load only for a reload", () => { - const { activeDocumentLoadId } = useSessionStore.getState(); + const tab = useSessionStore.getState().openTab(createSavedDocument()); - useSessionStore.getState().setActiveDocument(createSavedDocument()); + useSessionStore.getState().replaceTabDocument(tab.id, createSavedDocument()); - expect(useSessionStore.getState().activeDocumentLoadId).toBe(activeDocumentLoadId); + expect(findTab(useSessionStore.getState(), tab.id)?.loadId).toBe(tab.loadId); - useSessionStore.getState().setActiveDocument(createSavedDocument(), { reload: true }); + useSessionStore.getState().replaceTabDocument(tab.id, createSavedDocument(), { reload: true }); - expect(useSessionStore.getState().activeDocumentLoadId).toBe(activeDocumentLoadId + 1); + expect(findTab(useSessionStore.getState(), tab.id)?.loadId).toBe(tab.loadId + 1); }); it("keeps a view state only for the reload that carries it", () => { const viewState = { anchor: 3, head: 5, focused: true }; + const tab = useSessionStore.getState().openTab(createSavedDocument()); + const getViewState = () => findTab(useSessionStore.getState(), tab.id)?.viewState; - useSessionStore.getState().setActiveDocument(createSavedDocument(), { viewState }); + useSessionStore.getState().replaceTabDocument(tab.id, createSavedDocument(), { viewState }); - expect(useSessionStore.getState().activeDocumentViewState).toBeNull(); + expect(getViewState()).toBeNull(); useSessionStore .getState() - .setActiveDocument(createSavedDocument(), { reload: true, viewState }); + .replaceTabDocument(tab.id, createSavedDocument(), { reload: true, viewState }); - expect(useSessionStore.getState().activeDocumentViewState).toBe(viewState); + expect(getViewState()).toBe(viewState); - useSessionStore.getState().setActiveDocument(createSavedDocument()); + useSessionStore.getState().replaceTabDocument(tab.id, createSavedDocument()); - expect(useSessionStore.getState().activeDocumentViewState).toBeNull(); + expect(getViewState()).toBeNull(); }); it("records the file's state without replacing the document", () => { const activeDocument = createSavedDocument({ isDirty: true }); - useSessionStore.getState().setActiveDocument(activeDocument); - const { activeDocumentGeneration, activeDocumentLoadId } = useSessionStore.getState(); + const tab = useSessionStore.getState().openTab(activeDocument); const metadata = { sizeBytes: 12, modifiedAtUnixMs: 1_800_000_000_000 }; - useSessionStore.getState().setActiveDocumentFileState("C:/Other/readme.md", { + useSessionStore.getState().setDocumentFileState("C:/Other/readme.md", { metadata, externalChange: { kind: "missing" }, }); expect(useSessionStore.getState().activeDocument).toBe(activeDocument); - useSessionStore.getState().setActiveDocumentFileState(TEST_MARKDOWN_FILE_PATH, { + useSessionStore.getState().setDocumentFileState(TEST_MARKDOWN_FILE_PATH, { metadata, externalChange: { kind: "missing" }, }); - expect(useSessionStore.getState()).toMatchObject({ - activeDocument: { ...activeDocument, metadata, externalChange: { kind: "missing" } }, - activeDocumentGeneration, - activeDocumentLoadId, + expect(getActiveTab(useSessionStore.getState())).toMatchObject({ + document: { ...activeDocument, metadata, externalChange: { kind: "missing" } }, + generation: tab.generation, + loadId: tab.loadId, }); }); it("does not record a file state for an untitled document", () => { const untitledDocument = createUntitledDocument(); - useSessionStore.getState().setActiveDocument(untitledDocument); + useSessionStore.getState().openTab(untitledDocument); - useSessionStore.getState().setActiveDocumentFileState(TEST_UNTITLED_DOCUMENT_ID, { + useSessionStore.getState().setDocumentFileState(TEST_UNTITLED_DOCUMENT_ID, { metadata: { sizeBytes: 1, modifiedAtUnixMs: 1 }, externalChange: null, }); @@ -210,50 +288,74 @@ describe("session store", () => { expect(useSessionStore.getState().activeDocument).toBe(untitledDocument); }); - it("keeps active documents clean when setting the current line ending", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument()); + it("keeps documents clean when setting the current line ending", () => { + useSessionStore.getState().openTab(createSavedDocument()); const activeDocument = useSessionStore.getState().activeDocument; - useSessionStore.getState().setActiveDocumentLineEnding(TEST_MARKDOWN_FILE_PATH, "lf"); + useSessionStore.getState().setDocumentLineEnding(TEST_MARKDOWN_FILE_PATH, "lf"); expect(useSessionStore.getState().activeDocument).toBe(activeDocument); }); - it("marks active documents dirty only for matching document keys", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument()); + it("marks documents dirty only for matching document keys", () => { + useSessionStore.getState().openTab(createSavedDocument()); - useSessionStore.getState().markActiveDocumentDirty("C:/Other/readme.md"); + useSessionStore.getState().markDocumentDirty("C:/Other/readme.md"); expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: false, }); - useSessionStore.getState().markActiveDocumentDirty(TEST_MARKDOWN_FILE_PATH); + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toMatchObject({ isDirty: true, }); }); - it("does not rewrite already-dirty active documents", () => { - useSessionStore.getState().setActiveDocument(createSavedDocument({ isDirty: true })); + it("does not rewrite already-dirty documents", () => { + useSessionStore.getState().openTab(createSavedDocument({ isDirty: true })); const activeDocument = useSessionStore.getState().activeDocument; - useSessionStore.getState().markActiveDocumentDirty(TEST_MARKDOWN_FILE_PATH); + useSessionStore.getState().markDocumentDirty(TEST_MARKDOWN_FILE_PATH); expect(useSessionStore.getState().activeDocument).toBe(activeDocument); }); + it("applies a folder scan only while its load is still the current one", () => { + const { folderContextLoad } = useSessionStore + .getState() + .openTabWithFolderContextLoad(TEST_NOTES_FOLDER_PATH, createSavedDocument()); + + expect(useSessionStore.getState().folderContextLoad).toBe(folderContextLoad); + expect( + useSessionStore + .getState() + .finishFolderContextLoad(folderContextLoad.id + 1, createFolderContext()), + ).toBe(false); + expect( + useSessionStore + .getState() + .finishFolderContextLoad(folderContextLoad.id, createFolderContext()), + ).toBe(true); + expect(useSessionStore.getState()).toMatchObject({ + folderContext: { path: TEST_NOTES_FOLDER_PATH }, + folderContextLoad: null, + }); + }); + it("resets to the welcome session", () => { useSessionStore .getState() - .setActiveDocumentSession(createFolderContext(), createSavedDocument()); + .openTab(createSavedDocument(), { folderContext: createFolderContext() }); useSessionStore.getState().reset(); expect(useSessionStore.getState()).toMatchObject({ activeDocument: null, + activeTabId: null, folderContext: null, + tabs: [], }); }); }); diff --git a/src/features/session/stores/session.ts b/src/features/session/stores/session.ts index f4495e26..1e0e3075 100644 --- a/src/features/session/stores/session.ts +++ b/src/features/session/stores/session.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { + getActiveDocumentKey, isSameEncoding, matchesActiveDocumentKey, type ActiveDocumentState, @@ -11,17 +12,29 @@ import { } from "@/features/document"; import type { EditorViewState } from "@/features/editor"; import type { FolderContextState } from "@/features/folder-context"; +import { isSamePath } from "@/lib/path"; + +/** One open document in the window, with the editor state that outlives switching away from it. */ +export interface DocumentTab { + id: string; + document: ActiveDocumentState; + /** Changes whenever the document is replaced, so a decision about an older version is dropped. */ + generation: number; + /** Changes when the document's text is replaced from disk without its key changing. */ + loadId: number; + /** The caret and focus the editor restores when a reload remounts it. */ + viewState: EditorViewState | null; +} export interface SessionState { folderContext: FolderContextState | null; /** The folder a file open made the folder context while its scan has not finished. */ folderContextLoad: FolderContextLoad | null; + /** Open documents in tab order. */ + tabs: DocumentTab[]; + activeTabId: string | null; + /** The active tab's document, kept beside the tabs for readers that only need it. */ activeDocument: ActiveDocumentState | null; - activeDocumentGeneration: number; - /** Changes when the active document's text is replaced from disk without its key changing. */ - activeDocumentLoadId: number; - /** The caret and focus the editor restores when a reload remounts it. */ - activeDocumentViewState: EditorViewState | null; } export interface FolderContextLoad { @@ -31,39 +44,41 @@ export interface FolderContextLoad { export type SessionMode = "document" | "folder-only" | "welcome"; -export interface ActiveDocumentUpdateOptions { +export interface DocumentReplaceOptions { reload?: boolean; viewState?: EditorViewState | null; } -export interface ActiveDocumentFileState { +export interface DocumentFileState { metadata: FileMetadataSnapshot; externalChange: ExternalFileChange | null; } export interface SessionStore extends SessionState { setFolderContext: (folderContext: FolderContextState | null) => void; - setFolderOnlySession: (folderContext: FolderContextState) => void; - setActiveDocument: ( - activeDocument: ActiveDocumentState | null, - options?: ActiveDocumentUpdateOptions, + /** Adds a tab after the active one and activates it, optionally replacing the folder context. */ + openTab: ( + document: ActiveDocumentState, + options?: { folderContext?: FolderContextState }, + ) => DocumentTab; + openTabWithFolderContextLoad: ( + folderPath: string, + document: ActiveDocumentState, + ) => { tab: DocumentTab; folderContextLoad: FolderContextLoad }; + activateTab: (tabId: string) => void; + /** Closes the tabs, activating the nearest remaining tab when the active one closes. */ + closeTabs: (tabIds: readonly string[]) => void; + replaceTabDocument: ( + tabId: string, + document: ActiveDocumentState, + options?: DocumentReplaceOptions & { folderContext?: FolderContextState }, ) => void; - setActiveDocumentContent: (documentKey: string, content: string) => void; - setActiveDocumentLineEnding: (documentKey: string, lineEnding: LineEnding) => void; - setActiveDocumentEncoding: (documentKey: string, encoding: DocumentEncoding) => void; - markActiveDocumentDirty: (documentKey: string) => void; + setDocumentContent: (documentKey: string, content: string) => void; + setDocumentLineEnding: (documentKey: string, lineEnding: LineEnding) => void; + setDocumentEncoding: (documentKey: string, encoding: DocumentEncoding) => void; + markDocumentDirty: (documentKey: string) => void; /** Records what the file on disk holds without replacing the document or invalidating its decisions. */ - setActiveDocumentFileState: (documentKey: string, fileState: ActiveDocumentFileState) => void; - setActiveDocumentSession: ( - folderContext: FolderContextState | null, - activeDocument: ActiveDocumentState, - options?: ActiveDocumentUpdateOptions, - ) => void; - setActiveDocumentWithFolderContextLoad: ( - folderPath: string, - activeDocument: ActiveDocumentState, - options?: ActiveDocumentUpdateOptions, - ) => FolderContextLoad; + setDocumentFileState: (documentKey: string, fileState: DocumentFileState) => void; /** Applies a finished scan unless another folder context or load has replaced it. */ finishFolderContextLoad: (loadId: number, folderContext: FolderContextState) => boolean; abandonFolderContextLoad: (loadId: number) => boolean; @@ -73,10 +88,9 @@ export interface SessionStore extends SessionState { const INITIAL_SESSION_STATE: SessionState = { folderContext: null, folderContextLoad: null, + tabs: [], + activeTabId: null, activeDocument: null, - activeDocumentGeneration: 0, - activeDocumentLoadId: 0, - activeDocumentViewState: null, }; export const getSessionMode = ( @@ -88,94 +102,193 @@ export const getSessionFolderPath = ( state: Pick, ) => state.folderContext?.path ?? state.folderContextLoad?.path ?? null; +export const getActiveTab = (state: Pick) => + state.tabs.find((tab) => tab.id === state.activeTabId) ?? null; + +export const findTab = (state: Pick, tabId: string) => + state.tabs.find((tab) => tab.id === tabId) ?? null; + +export const findTabByDocumentKey = (state: Pick, documentKey: string) => + state.tabs.find((tab) => matchesActiveDocumentKey(tab.document, documentKey)) ?? null; + +export const findTabByPath = (state: Pick, path: string) => + state.tabs.find( + (tab) => tab.document.status === "saved" && isSamePath(tab.document.path, path), + ) ?? null; + +/** Returns the tab only while it still holds the document version a decision was made about. */ +export const findCurrentTab = ( + state: Pick, + tabId: string, + generation: number, +) => { + const tab = findTab(state, tabId); + + return tab?.generation === generation ? tab : null; +}; + +export const getTabDocumentKey = (tab: DocumentTab) => getActiveDocumentKey(tab.document); + +let nextTabId = 0; +let nextGeneration = 0; let nextFolderContextLoadId = 0; +const createTab = (document: ActiveDocumentState): DocumentTab => { + nextTabId += 1; + nextGeneration += 1; + + return { + id: `tab:${nextTabId}`, + document, + generation: nextGeneration, + loadId: 0, + viewState: null, + }; +}; + +const withTabs = ( + tabs: DocumentTab[], + activeTabId: string | null, +): Pick => ({ + tabs, + activeTabId, + activeDocument: tabs.find((tab) => tab.id === activeTabId)?.document ?? null, +}); + +const insertAfterActive = (state: SessionState, tab: DocumentTab) => { + const activeIndex = state.tabs.findIndex((candidate) => candidate.id === state.activeTabId); + const tabs = [...state.tabs]; + + tabs.splice(activeIndex === -1 ? tabs.length : activeIndex + 1, 0, tab); + + return withTabs(tabs, tab.id); +}; + export const useSessionStore = create()((set, get) => ({ ...INITIAL_SESSION_STATE, setFolderContext: (folderContext) => set({ folderContext, folderContextLoad: null }), - setFolderOnlySession: (folderContext) => + openTab: (document, options) => { + const tab = createTab(document); + set((state) => ({ - activeDocument: null, - activeDocumentGeneration: state.activeDocumentGeneration + 1, - activeDocumentViewState: null, - folderContext, - folderContextLoad: null, - })), - setActiveDocument: (activeDocument, options) => + ...insertAfterActive(state, tab), + ...(options?.folderContext && { + folderContext: options.folderContext, + folderContextLoad: null, + }), + })); + + return tab; + }, + openTabWithFolderContextLoad: (folderPath, document) => { + const tab = createTab(document); + + nextFolderContextLoadId += 1; + const folderContextLoad = { id: nextFolderContextLoadId, path: folderPath }; + set((state) => ({ - activeDocument, - activeDocumentGeneration: state.activeDocumentGeneration + 1, - ...getActiveDocumentLoad(state, options), - })), - setActiveDocumentContent: (documentKey, content) => + ...insertAfterActive(state, tab), + folderContext: null, + folderContextLoad, + })); + + return { tab, folderContextLoad }; + }, + activateTab: (tabId) => + set((state) => + state.activeTabId === tabId || !findTab(state, tabId) ? state : withTabs(state.tabs, tabId), + ), + closeTabs: (tabIds) => + set((state) => { + const closing = new Set(tabIds); + const tabs = state.tabs.filter((tab) => !closing.has(tab.id)); + + if (tabs.length === state.tabs.length) { + return state; + } + + if (!state.activeTabId || !closing.has(state.activeTabId)) { + return withTabs(tabs, state.activeTabId); + } + + const activeIndex = state.tabs.findIndex((tab) => tab.id === state.activeTabId); + const nextActive = + state.tabs.slice(activeIndex + 1).find((tab) => !closing.has(tab.id)) ?? + state.tabs.slice(0, activeIndex).findLast((tab) => !closing.has(tab.id)); + + return withTabs(tabs, nextActive?.id ?? null); + }), + replaceTabDocument: (tabId, document, { folderContext, reload = false, viewState = null } = {}) => + set((state) => { + if (!findTab(state, tabId)) { + return state; + } + + nextGeneration += 1; + const tabs = state.tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + document, + generation: nextGeneration, + loadId: tab.loadId + (reload ? 1 : 0), + viewState: reload ? viewState : null, + } + : tab, + ); + + return { + ...withTabs(tabs, state.activeTabId), + ...(folderContext && { folderContext, folderContextLoad: null }), + }; + }), + setDocumentContent: (documentKey, content) => set((state) => - updateActiveDocumentByKey(state, documentKey, (activeDocument) => ({ - ...activeDocument, + updateDocumentByKey(state, documentKey, (document) => ({ + ...document, content, })), ), - setActiveDocumentLineEnding: (documentKey, lineEnding) => + setDocumentLineEnding: (documentKey, lineEnding) => set((state) => - updateActiveDocumentByKey(state, documentKey, (activeDocument) => - activeDocument.lineEnding === lineEnding - ? activeDocument + updateDocumentByKey(state, documentKey, (document) => + document.lineEnding === lineEnding + ? document : { - ...activeDocument, + ...document, isDirty: true, lineEnding, }, ), ), - setActiveDocumentEncoding: (documentKey, encoding) => + setDocumentEncoding: (documentKey, encoding) => set((state) => - updateActiveDocumentByKey(state, documentKey, (activeDocument) => - isSameEncoding(activeDocument.encoding, encoding) - ? activeDocument + updateDocumentByKey(state, documentKey, (document) => + isSameEncoding(document.encoding, encoding) + ? document : { - ...activeDocument, + ...document, isDirty: true, encoding, }, ), ), - markActiveDocumentDirty: (documentKey) => + markDocumentDirty: (documentKey) => set((state) => - updateActiveDocumentByKey(state, documentKey, (activeDocument) => - activeDocument.isDirty ? activeDocument : { ...activeDocument, isDirty: true }, + updateDocumentByKey(state, documentKey, (document) => + document.isDirty ? document : { ...document, isDirty: true }, ), ), - setActiveDocumentFileState: (documentKey, { externalChange, metadata }) => + setDocumentFileState: (documentKey, { externalChange, metadata }) => set((state) => - updateActiveDocumentByKey(state, documentKey, (activeDocument) => - activeDocument.status !== "saved" || - (activeDocument.metadata === metadata && activeDocument.externalChange === externalChange) - ? activeDocument - : { ...activeDocument, externalChange, metadata }, + updateDocumentByKey(state, documentKey, (document) => + document.status !== "saved" || + (document.metadata === metadata && document.externalChange === externalChange) + ? document + : { ...document, externalChange, metadata }, ), ), - setActiveDocumentSession: (folderContext, activeDocument, options) => - set((state) => ({ - folderContext, - folderContextLoad: null, - activeDocument, - activeDocumentGeneration: state.activeDocumentGeneration + 1, - ...getActiveDocumentLoad(state, options), - })), - setActiveDocumentWithFolderContextLoad: (folderPath, activeDocument, options) => { - nextFolderContextLoadId += 1; - const folderContextLoad = { id: nextFolderContextLoadId, path: folderPath }; - - set((state) => ({ - folderContext: null, - folderContextLoad, - activeDocument, - activeDocumentGeneration: state.activeDocumentGeneration + 1, - ...getActiveDocumentLoad(state, options), - })); - - return folderContextLoad; - }, finishFolderContextLoad: (loadId, folderContext) => { if (get().folderContextLoad?.id !== loadId) { return false; @@ -194,33 +307,28 @@ export const useSessionStore = create()((set, get) => ({ return true; }, - reset: () => - set((state) => ({ - ...INITIAL_SESSION_STATE, - activeDocumentGeneration: state.activeDocumentGeneration + 1, - })), + reset: () => set(INITIAL_SESSION_STATE), })); -const getActiveDocumentLoad = ( - state: SessionState, - { reload = false, viewState = null }: ActiveDocumentUpdateOptions = {}, -): Pick => ({ - activeDocumentLoadId: state.activeDocumentLoadId + (reload ? 1 : 0), - activeDocumentViewState: reload ? viewState : null, -}); - -const updateActiveDocumentByKey = ( +const updateDocumentByKey = ( state: SessionStore, documentKey: string, - update: (activeDocument: ActiveDocumentState) => ActiveDocumentState, + update: (document: ActiveDocumentState) => ActiveDocumentState, ) => { - const { activeDocument } = state; + const target = findTabByDocumentKey(state, documentKey); - if (!activeDocument || !matchesActiveDocumentKey(activeDocument, documentKey)) { + if (!target) { return state; } - const nextActiveDocument = update(activeDocument); + const document = update(target.document); + + if (document === target.document) { + return state; + } - return nextActiveDocument === activeDocument ? state : { activeDocument: nextActiveDocument }; + return withTabs( + state.tabs.map((tab) => (tab === target ? { ...tab, document } : tab)), + state.activeTabId, + ); }; diff --git a/src/features/session/utils/documentNames.ts b/src/features/session/utils/documentNames.ts new file mode 100644 index 00000000..76fc271f --- /dev/null +++ b/src/features/session/utils/documentNames.ts @@ -0,0 +1,34 @@ +import type { ActiveDocumentState } from "@/features/document"; +import type { Translate } from "@/lib/i18n"; +import { getPathParts } from "@/lib/path"; + +const UNTITLED_ID_PREFIX = "untitled:"; + +export const toUntitledDocumentId = (number: number) => `${UNTITLED_ID_PREFIX}${number}`; + +const getUntitledNumber = (documentId: string) => { + const number = Number(documentId.slice(UNTITLED_ID_PREFIX.length)); + + return Number.isInteger(number) && number > 0 ? number : 1; +}; + +/** The lowest number no open untitled document uses, so names stay short as documents close. */ +export const getNextUntitledNumber = (documents: readonly ActiveDocumentState[]) => { + const used = new Set( + documents.flatMap((document) => + document.status === "untitled" ? [getUntitledNumber(document.id)] : [], + ), + ); + let number = 1; + + while (used.has(number)) { + number += 1; + } + + return number; +}; + +export const getDocumentName = (document: ActiveDocumentState, t: Translate) => + document.status === "saved" + ? getPathParts(document.path).name + : t("session.untitledDocumentName", { number: getUntitledNumber(document.id) }); diff --git a/src/locales/en.json b/src/locales/en.json index edad5c23..1801d8cb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -190,6 +190,17 @@ "command.format.clearBlock": "Clear block formatting", "command.view.toggleSidebar": "Toggle sidebar", "command.view.commandPalette": "Command palette...", + "command.view.nextTab": "Next tab", + "command.view.previousTab": "Previous tab", + "command.view.tab1": "Go to tab 1", + "command.view.tab2": "Go to tab 2", + "command.view.tab3": "Go to tab 3", + "command.view.tab4": "Go to tab 4", + "command.view.tab5": "Go to tab 5", + "command.view.tab6": "Go to tab 6", + "command.view.tab7": "Go to tab 7", + "command.view.tab8": "Go to tab 8", + "command.view.lastTab": "Go to last tab", "command.view.toggleStatusBar": "Toggle status bar", "command.view.zoomIn": "Zoom in", "command.view.zoomOut": "Zoom out", @@ -360,6 +371,10 @@ "shell.resizeArticleNavigator": "Resize article navigator", "shell.folderSearch": "Folder search", "shell.documentSurface": "Document surface", + "shell.documentTabs": "Open documents", + "shell.documentTabs.tab": "{name}{folder, select, none {} other {, {folder}}}{unsaved, select, true {, unsaved} other {}}", + "shell.documentTabs.close": "Close {name}", + "shell.windowTitle": "{name} - Leafdown", "statusBar.label": "Status bar", "statusBar.words": "{count, plural, one {# word} other {# words}}", "statusBar.selectedWords": "{selected, number} of {count, plural, one {# word} other {# words}}", @@ -543,9 +558,14 @@ "folderSearch.report.notAttempted": "{count, plural, one {# file} other {# files}} not attempted", "folderSearch.report.dismiss": "Dismiss report", "session.unsavedChanges.title": "Unsaved changes", - "session.unsavedChanges.message": "The active document has unsaved changes. Discard them and continue?", + "session.unsavedChanges.message": "{name} has unsaved changes. Discard them and continue?", "session.unsavedChanges.confirm": "Discard changes", "session.unsavedChanges.cancel": "Keep editing", + "session.unsavedDocuments.message": "{count, plural, one {# document has} other {# documents have}} unsaved changes. Discard them and continue?", + "session.unsavedDocuments.confirm": "Discard all", + "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.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.", @@ -573,6 +593,7 @@ "session.externalChange.missing.title": "File missing", "session.externalChange.missing.description": "\"{name}\" was moved or deleted outside Leafdown. 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}}", "session.deleteEntry.folderMessage": "{trash, select, recycleBin {Move \"{name}\" and everything in it to the Recycle Bin?} other {Move \"{name}\" and everything in it to the Trash?}}", "session.deleteEntry.fileMessage": "{trash, select, recycleBin {Move \"{name}\" to the Recycle Bin?} other {Move \"{name}\" to the Trash?}}", diff --git a/src/test/factories/commands.ts b/src/test/factories/commands.ts index 1de07f60..0ee2bab8 100644 --- a/src/test/factories/commands.ts +++ b/src/test/factories/commands.ts @@ -38,6 +38,10 @@ export const createAppCommandContext = ( return { activeDocument: null, + tabs: { + count: overrides.activeDocument ? 1 : 0, + activeIndex: overrides.activeDocument ? 0 : -1, + }, editor: createEditorCommandState(), folderContext: null, folderContextLoading: false, diff --git a/src/test/mocks/tauri.ts b/src/test/mocks/tauri.ts index 36e6c65e..58ca298a 100644 --- a/src/test/mocks/tauri.ts +++ b/src/test/mocks/tauri.ts @@ -38,6 +38,7 @@ const currentWindowMock = { onThemeChanged: vi.fn(async () => vi.fn()), setAlwaysOnTop: vi.fn(async () => undefined), setFullscreen: vi.fn(async () => undefined), + setTitle: vi.fn(async () => undefined), show: vi.fn(async () => undefined), theme: vi.fn(async () => "light"), }; diff --git a/src/test/utils/appStores.ts b/src/test/utils/appStores.ts index 15477af3..3010467e 100644 --- a/src/test/utils/appStores.ts +++ b/src/test/utils/appStores.ts @@ -19,7 +19,12 @@ import { import { RELEASE_NOTES_STATE_VERSION, useReleaseNotesStore } from "@/features/release-notes"; // Deep import by design: `@/features/session` re-exports `documentEditorBridge`, and the // beforeEach in ../setup/common.ts loads this module for every test file. -import { useSessionStore, type SessionState } from "@/features/session/stores/session"; +import { + useSessionStore, + type DocumentReplaceOptions, + type DocumentTab, + type SessionState, +} from "@/features/session/stores/session"; export const setDefaultSettings = (settings: Partial = {}) => { useSettingsStore.setState({ @@ -47,33 +52,65 @@ type TestUntitledDocumentState = Omit & Partial>; type TestActiveDocumentState = TestSavedDocumentState | TestUntitledDocumentState; -interface TestSessionState extends Omit { +interface TestSessionState extends Pick { + /** Opens one tab holding this document. */ activeDocument: TestActiveDocumentState | null; + /** Opens a tab for each document instead, in this order. */ + documents: TestActiveDocumentState[]; + /** The position of the active tab among `documents`. */ + activeIndex: number; } const toTestActiveDocumentState = ( - activeDocument: TestActiveDocumentState | null | undefined, -): ActiveDocumentState | null => - activeDocument - ? { - isDirty: false, - ...activeDocument, - } - : null; + activeDocument: TestActiveDocumentState, +): ActiveDocumentState => ({ + isDirty: false, + ...activeDocument, +}); export const setDefaultSession = (session: Partial = {}) => { - const { activeDocument, ...sessionRest } = session; + const { activeDocument, activeIndex = 0, documents, ...sessionRest } = session; + const tabs: DocumentTab[] = (documents ?? (activeDocument ? [activeDocument] : [])).map( + (document, index) => ({ + id: `test-tab:${index + 1}`, + document: toTestActiveDocumentState(document), + generation: 0, + loadId: 0, + viewState: null, + }), + ); + const activeTab = tabs[activeIndex] ?? null; useSessionStore.setState({ folderContext: null, folderContextLoad: null, - activeDocument: toTestActiveDocumentState(activeDocument), - activeDocumentGeneration: 0, - activeDocumentLoadId: 0, + tabs, + activeTabId: activeTab?.id ?? null, + activeDocument: activeTab?.document ?? null, ...sessionRest, }); }; +/** Replaces the active tab's document, as a save, a reload, or a rename does. */ +export const replaceActiveDocument = ( + document: TestActiveDocumentState, + options?: DocumentReplaceOptions, +) => { + const { activeTabId, replaceTabDocument } = useSessionStore.getState(); + + if (activeTabId) { + replaceTabDocument(activeTabId, toTestActiveDocumentState(document), options); + } +}; + +export const closeActiveTab = () => { + const { activeTabId, closeTabs } = useSessionStore.getState(); + + if (activeTabId) { + closeTabs([activeTabId]); + } +}; + export const setDefaultUI = (ui: Partial = {}) => { useCommandUIStore.setState({ aboutOpen: false, From ed12dd3e4f2fc123b63d4f80dbae835b9b85f59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 11 Oct 2026 11:21:39 -0300 Subject: [PATCH 2/3] Keep the folder's spelling in resolved link targets --- docs/architecture.md | 1 + src-tauri/src/command_contract_tests.rs | 11 +-- src-tauri/src/folder.rs | 12 +-- src-tauri/src/link.rs | 104 +++++++++++++++++++----- src-tauri/src/test_utils.rs | 10 +++ 5 files changed, 101 insertions(+), 37 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6fd0944e..ffbd0c22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -196,6 +196,7 @@ The frontend owns all translated text. The backend returns typed error `kind`s a - Release notes own a separate persisted record of the last launched version and versions whose notes were shown. The current dialog and version are runtime state. - The `language` setting holds `system` or a BCP 47 tag. A tag Leafdown does not ship resolves as `system` without being rewritten. - Folder scans return a nested tree of directory, article (`file`), and `unsupportedFile` nodes, with `isEmpty` set when it holds no article. The Rust scan owns canonical child ordering; the frontend supplies the selected sort order and preserves returned order when rendering the article navigator. Only article nodes reach opening, the article count and filter, folder search and replace, and wiki-link completion. +- The frontend matches documents, tabs, and articles by path, so commands keep the spelling a folder was opened with rather than canonicalizing it: folder scans spell entries from the folder path they were given, and link resolution spells a target inside the folder context from that folder's path, taking the on-disk spelling only below it. A folder reached through a short name, a junction, or a symbolic link therefore yields the same path from the article navigator and from a link. ## Data Flow diff --git a/src-tauri/src/command_contract_tests.rs b/src-tauri/src/command_contract_tests.rs index a0d9ce02..ae9ce321 100644 --- a/src-tauri/src/command_contract_tests.rs +++ b/src-tauri/src/command_contract_tests.rs @@ -530,10 +530,7 @@ fn image_and_link_command_results_keep_frontend_shape_contracts() { let link_result = serialized(link_result); assert_eq!(json_string(&link_result, "kind"), "localMarkdown"); - assert_eq!( - json_string(&link_result, "path"), - canonical_path_string(link_path.as_path()) - ); + assert_eq!(Path::new(json_string(&link_result, "path")), link_path); } #[test] @@ -541,9 +538,9 @@ fn resolved_link_paths_match_folder_scan_paths() { let root = TestDirectory::new("command-contract-path-identity"); let document_path = root.markdown_document_path(); root.write_file("docs/linked.md"); - // The frontend supplies a folder path the OS picker produced, so start from the real path - // rather than whatever form `TEMP` happens to carry on the host. - let root_path_string = canonical_path_string(root.path.as_path()); + // A folder path reaches the frontend in whatever form it was opened by, which need not be the + // canonical one, so start from `TEMP` as the host spells it. + let root_path_string = path_string(root.path.as_path()); let scan_result = tauri::async_runtime::block_on(folder::scan_markdown_folder( root_path_string.clone(), diff --git a/src-tauri/src/folder.rs b/src-tauri/src/folder.rs index 306722e1..ec36151f 100644 --- a/src-tauri/src/folder.rs +++ b/src-tauri/src/folder.rs @@ -478,7 +478,7 @@ mod tests { use crate::{ document::OpenMarkdownFileError, file_utils::staging_path, - test_utils::{TestDirectory, create_file_symlink}, + test_utils::{TestDirectory, create_directory_symlink, create_file_symlink}, }; #[test] @@ -1088,14 +1088,4 @@ mod tests { MarkdownFolderTreeNode::File { .. } => false, }) } - - #[cfg(unix)] - fn create_directory_symlink(target: &Path, symlink_path: &Path) -> std::io::Result<()> { - std::os::unix::fs::symlink(target, symlink_path) - } - - #[cfg(windows)] - fn create_directory_symlink(target: &Path, symlink_path: &Path) -> std::io::Result<()> { - std::os::windows::fs::symlink_dir(target, symlink_path) - } } diff --git a/src-tauri/src/link.rs b/src-tauri/src/link.rs index ae2b0c83..d18f8b3b 100644 --- a/src-tauri/src/link.rs +++ b/src-tauri/src/link.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use serde::Serialize; @@ -8,7 +11,7 @@ use crate::{ }, path_utils::{ ExistingPathResolution, MarkdownReferencePathResolution, has_uri_scheme, - is_network_or_device_target, parse_file_url_path, resolve_existing_path, + is_network_or_device_target, parse_file_url_path, path_to_string, resolve_existing_path, resolve_markdown_reference_path, }, }; @@ -245,12 +248,19 @@ fn resolve_local_link_target( } }; - resolve_existing_link_target(resolved_path.as_path()) + let folder_path = folder_context_path.or_else(|| document_path.and_then(Path::parent)); + + resolve_existing_link_target(resolved_path.as_path(), folder_path) } -fn resolve_existing_link_target(resolved_path: &Path) -> ResolveMarkdownLinkTargetResult { +fn resolve_existing_link_target( + resolved_path: &Path, + folder_path: Option<&Path>, +) -> ResolveMarkdownLinkTargetResult { match resolve_existing_path(resolved_path) { ExistingPathResolution::Found { is_file, path } => { + let path = spell_from_folder(path, folder_path); + if is_file && is_supported_markdown_path(resolved_path) { ResolveMarkdownLinkTargetResult::LocalMarkdown { path } } else { @@ -272,6 +282,25 @@ fn resolve_existing_link_target(resolved_path: &Path) -> ResolveMarkdownLinkTarg } } +/// Spells a target inside the folder from the folder's own path, as folder scans spell their +/// entries, so a link reaches the path the article navigator and open documents hold even when the +/// folder path is a short name or passes through a junction or symbolic link. Below the folder the +/// path keeps its canonical, on-disk spelling. +fn spell_from_folder(canonical_path: String, folder_path: Option<&Path>) -> String { + let Some(folder_path) = folder_path else { + return canonical_path; + }; + let Ok(canonical_folder) = fs::canonicalize(folder_path) else { + return canonical_path; + }; + + match Path::new(&canonical_path).strip_prefix(dunce::simplified(&canonical_folder)) { + Ok(relative) if relative.as_os_str().is_empty() => path_to_string(folder_path), + Ok(relative) => path_to_string(&folder_path.join(relative)), + Err(_) => canonical_path, + } +} + fn parse_link_target(target: &str) -> ParsedLinkTarget { let trimmed_target = target.trim(); @@ -331,13 +360,15 @@ fn strip_file_url_reference_parts(target: &str) -> &str { #[cfg(test)] mod tests { - use std::path::Path; + use std::path::{Path, PathBuf}; use super::{ OpenMarkdownLinkTargetError, ResolveMarkdownLinkTargetResult, resolve_link_target, resolve_openable_local_file, resolve_wiki_target, }; - use crate::test_utils::{TestDirectory, canonical_path_string, pathdiff}; + use crate::test_utils::{ + TestDirectory, canonical_path_string, create_directory_symlink, pathdiff, + }; #[cfg(windows)] const NETWORK_TARGETS: [&str; 6] = [ @@ -349,6 +380,14 @@ mod tests { r"\\host.example.com\share\payload.exe", ]; + /// A target inside the test folder, spelled from the folder's path with native separators. + fn folder_path_string(path: &Path) -> String { + path.components() + .collect::() + .to_string_lossy() + .into_owned() + } + fn local_markdown_path(result: ResolveMarkdownLinkTargetResult) -> String { match result { ResolveMarkdownLinkTargetResult::LocalMarkdown { path } => path, @@ -401,7 +440,34 @@ mod tests { assert_eq!( local_markdown_path(result), - canonical_path_string(target_path.as_path()) + folder_path_string(target_path.as_path()) + ); + } + + #[test] + fn spells_targets_from_a_folder_reached_through_a_symbolic_link() { + let target = TestDirectory::new("linked-folder-target"); + let root = TestDirectory::new("linked-folder"); + let folder_path = root.path("notes"); + + if create_directory_symlink(&target.path, &folder_path).is_err() { + return; + } + + target.write_file("readme.md"); + target.write_file("guides/start.md"); + let document_path = folder_path.join("readme.md"); + + let result = resolve_link_target( + Some(document_path.as_path()), + Some(folder_path.as_path()), + "guides/start.md", + false, + ); + + assert_eq!( + local_markdown_path(result), + folder_path_string(folder_path.join("guides/start.md").as_path()) ); } @@ -420,7 +486,7 @@ mod tests { assert_eq!( local_markdown_path(result), - canonical_path_string(target_path.as_path()) + folder_path_string(target_path.as_path()) ); } @@ -449,7 +515,7 @@ mod tests { assert_eq!( result, ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(target_path.as_path()) + path: folder_path_string(target_path.as_path()) }, "{file_name}" ); @@ -465,7 +531,7 @@ mod tests { assert_eq!( local_file_path(text_result), - canonical_path_string(text_path.as_path()) + folder_path_string(text_path.as_path()) ); } @@ -491,11 +557,11 @@ mod tests { assert_eq!( local_file_path(file_result), - canonical_path_string(pdf_path.as_path()) + folder_path_string(pdf_path.as_path()) ); assert_eq!( local_file_path(directory_result), - canonical_path_string(directory_path.as_path()) + folder_path_string(directory_path.as_path()) ); } @@ -619,7 +685,7 @@ mod tests { false, ); - assert_eq!(result, Ok(canonical_path_string(pdf_path.as_path()))); + assert_eq!(result, Ok(folder_path_string(pdf_path.as_path()))); } #[test] @@ -635,7 +701,7 @@ mod tests { false ), ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(markdown.as_path()) + path: folder_path_string(markdown.as_path()) } ); let md = root.write_file("docs/guides/setup.md"); @@ -647,7 +713,7 @@ mod tests { false ), ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(md.as_path()) + path: folder_path_string(md.as_path()) } ); assert_eq!( @@ -658,7 +724,7 @@ mod tests { false ), ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(markdown.as_path()) + path: folder_path_string(markdown.as_path()) } ); let uppercase = root.write_file("docs/guides/uppercase.MD"); @@ -670,7 +736,7 @@ mod tests { false ), ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(uppercase.as_path()) + path: folder_path_string(uppercase.as_path()) } ); } @@ -688,7 +754,7 @@ mod tests { ) }; let local_markdown = |path: &Path| ResolveMarkdownLinkTargetResult::LocalMarkdown { - path: canonical_path_string(path), + path: folder_path_string(path), }; let mkd = root.write_file("docs/notes/plan.mkd"); @@ -796,7 +862,7 @@ mod tests { assert_eq!( local_markdown_path(result), - canonical_path_string(target_path.as_path()) + folder_path_string(target_path.as_path()) ); } } diff --git a/src-tauri/src/test_utils.rs b/src-tauri/src/test_utils.rs index 3b35e316..a36c2b88 100644 --- a/src-tauri/src/test_utils.rs +++ b/src-tauri/src/test_utils.rs @@ -72,6 +72,16 @@ pub(crate) fn create_file_symlink(target: &Path, symlink_path: &Path) -> std::io std::os::windows::fs::symlink_file(target, symlink_path) } +#[cfg(unix)] +pub(crate) fn create_directory_symlink(target: &Path, symlink_path: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, symlink_path) +} + +#[cfg(windows)] +pub(crate) fn create_directory_symlink(target: &Path, symlink_path: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, symlink_path) +} + /// Expectations built from a raw `canonicalize` would re-encode the verbatim `\\?\` prefix that /// #146 removed, so tests compare against the form the backend actually exposes. pub(crate) fn canonical_path_string(path: &Path) -> String { From 8aa752b20ff4109a34016108abdbf940e5f54cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 11 Oct 2026 11:34:53 -0300 Subject: [PATCH 3/3] Return focus to the folder search panel before closing it in the narrow-window scenario --- e2e/desktop/specs/folder-search.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/desktop/specs/folder-search.spec.ts b/e2e/desktop/specs/folder-search.spec.ts index ddd27f16..2ff5302f 100644 --- a/e2e/desktop/specs/folder-search.spec.ts +++ b/e2e/desktop/specs/folder-search.spec.ts @@ -273,6 +273,11 @@ describe("desktop folder search", () => { await browser.setWindowSize(originalWindowSize.width, originalWindowSize.height); } + // The previous case closed a tab from the File menu, which took focus out of the panel. + await findInFolder(); + await browser.waitUntil(() => isFocused('input[aria-label="Search in folder"]'), { + timeoutMsg: "The query field did not take focus.", + }); await pressKey("Escape"); await expect(panel()).not.toExist(); });