diff --git a/CHANGELOG.md b/CHANGELOG.md index c835940e..67ae9b61 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 +- Keep named folders with their own tabs and unsaved drafts. Switch from the navigator, welcome, File menu, or command palette; an active context opens in its existing window. Named folders remain available when session restoration or recent history is off, and closing their windows parks their drafts without writing document files. - Search the keyboard shortcuts. `Help > Keyboard shortcuts` opens on a search field that narrows the list by command, menu, or key name, such as `heading` or `ctrl shift f`, and `Record keys` finds what a combination does when you press it. - Add a File icons setting to Preferences > Appearance. Colored draws folders in amber and gives articles and each other type of file a color of its own, the same under every accent color. Neutral, the default, keeps the icons gray. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f91d4e57..c8c1ef02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ This suite is separate from `pnpm check` and runs as its own CI job on pull requ pnpm test:e2e:desktop:run -- --workers 2 ``` -The runner accepts one to four workers on consecutive ports starting at 4445. Each worker gets its own application identity, persisted data, WebView2 root, fixtures, and artifacts. Independent scenarios can overlap; the `persistence` scenario keeps its write and restart sequence ordered on one worker. +The runner accepts one to four workers on consecutive ports starting at 4445. Each worker gets its own application identity, persisted data, WebView2 root, fixtures, and artifacts. Independent scenarios can overlap; the `persistence` and `named-folders` scenarios keep their write and restart sequences ordered on one worker. To run one scenario against an already built binary: diff --git a/docs/architecture.md b/docs/architecture.md index aae2fde6..04f17306 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,8 @@ Session and editor workflows request yes/no decisions through the shared confirm - **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. +- **Named folder:** an optional application-managed context containing one ordinary root, a display name, ordered tabs, and document snapshots. It may be active in one window or parked, with no metadata in its root. +- **Workspace:** not an application domain term; named folders require no workspace initialization or folder-local metadata. ## Runtime Model @@ -231,9 +232,13 @@ The listener is registered before the first take, so a document routed during st ### Kept Session Workflow -A window's frontend starts after its persisted stores -> With `Restore session` on, it takes its kept state from the backend, the first window opening the other kept windows -> Session claims each saved document, reads clean ones through the open path and builds dirty ones from their snapshots and kept file version, adds the tabs, checks each dirty document's file as the External Change workflow does, and loads the folder context -> The window then reports its state every 5 seconds after a change, reading each edited dirty document's settled Markdown as HTML export does -> Launch documents open after the restored tabs. With `Restore session` off, the first window clears the kept session instead. +A window's frontend starts after its persisted stores -> With `Restore session` on, it takes its kept state from the backend, the first window opening the other kept windows -> Session claims each saved document, reads clean ones through the open path and builds dirty ones from their snapshots and kept file version, adds the tabs, checks each dirty document's file as the External Change workflow does, and loads the folder context -> The window then reports its state every 5 seconds after a change, reading each edited dirty document's settled Markdown as HTML export does -> Launch documents open after the restored tabs. With restoration off, the first window clears automatic restart state and unnamed snapshots while preserving named contexts. A new window explicitly assigned a named context restores it independently of that setting. -Each report rewrites the session record from the process's own windows and removes the snapshots they no longer refer to, so the record assumes one process. Development runs therefore keep their session in a directory of their own: they share the installed application's identifier and can run beside it, since they leave out the single-instance plugin. +Session owns named-folder orchestration, presentation, and its revision-ordered shared list. Rust's `kept_session` owns context claims, parked state, restart eligibility, and snapshot files under one lock. Its version-2 record stores each named context's document state once and references it for restart; version-1 unnamed windows and snapshots remain readable. A report carries its expected context ID, so a delayed report cannot overwrite a newly selected context. Snapshot revisions use new IDs, and atomic record replacement precedes removal of unreferenced snapshots. A failed record write rolls back the in-memory context change. + +Switching waits for pending saves, takes a final snapshot, claims the destination, and only then replaces the frontend tabs. Editing and application command dispatch pause during that transition. The backend checks and reserves the destination's saved paths while holding the context lock; document claims consult parked named contexts before the active-window registry. The lock order is kept-session state followed by window-document state. Low-level document open, folder options, and session task queues remain separate from context orchestration to preserve dependency direction. A context restored in the same window shares the existing kept-tab recovery path and never runs ordinary folder index opening. + +Each report rewrites the session record from the process's active and parked contexts and removes snapshots they no longer refer to, so the record assumes one process. Development runs therefore keep their session in a directory of their own: they share the installed application's identifier and can run beside it, since they leave out the single-instance plugin. ### Drop Workflow diff --git a/docs/decisions.md b/docs/decisions.md index 656fb4af..7fb8ecae 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -90,7 +90,7 @@ ### No vault or workspace model -**Decision:** Opening a folder does not create a vault, workspace, import process, database, or metadata files in that folder. +**Decision:** Opening a folder does not create a vault, workspace, import process, database, or metadata files in that folder. A writer may optionally keep one ordinary folder as a named working context, with its display name, tabs, and unsaved drafts stored entirely in application data. **Rationale:** Leafdown is designed as a document-centric editor rather than a personal knowledge management system with custom workspace setups. @@ -99,6 +99,7 @@ - Existing folder structures remain unmodified. - Application metadata is stored externally from the opened folders. - Folder workflows execute without initialization steps. +- Named folders remain separate from recent history. A context can be active in one window, and a saved file has one owner across active and parked contexts. Switching parks the current named context before restoring another; a failed park retains the current work. ### Wiki links use document-relative paths @@ -137,7 +138,7 @@ ### Keep the session and its unsaved text across restarts -**Decision:** While `Restore session` is on, Leafdown keeps each window's folder context and tabs, and the unsaved text of every document they hold, from one run to the next. It snapshots each document whose unsaved text changed, untitled documents included, into its application data every few seconds. Quitting takes a final snapshot instead of asking about unsaved changes, and the next launch reopens the windows with that text still unsaved. Only Save writes a document's file. The model follows the session snapshot and periodic backup of Notepad++. +**Decision:** Leafdown snapshots each named folder's tabs and unsaved text in application data independently of `Restore session`. That setting controls automatic reopening of windows left at quit or crash and keeps the existing unnamed-window session behavior. Switching or closing a named context takes a final snapshot and parks it; failed persistence stops the transition. Restart assignments reference the same context state and snapshots rather than a second copy. Only Save writes a document's file. The snapshot and periodic backup model follows Notepad++. **Rationale:** Asking on quit treats an unsaved document as something to be disposed of, when the author usually means to return to it. Keeping it lets quitting, restarting for an update, or a crash cost at most a few seconds of typing, and it covers untitled documents, which have no file to write. Saving to the file automatically was the alternative ([issue #730](https://github.com/Azganoth/leafdown/issues/730)). It would write half-finished text into files that version control, sync, and other tools read, and it still could not keep an untitled document. Snapshots outside the file keep each file as the author last chose to write it, as [Markdown files remain the source of truth](#markdown-files-remain-the-source-of-truth) requires. @@ -147,7 +148,7 @@ - Snapshots and the session record live in Leafdown's application data, never in opened folders. A snapshot holds only unsaved text: saving or discarding a document deletes it. Snapshots are not revisions and are not kept as history. - Closing a tab, the folder, or a window while Leafdown keeps running still asks, since what it closes leaves the session. - A file that changed or went missing between runs restores its snapshot as an unsaved document with that change recorded, so Save warns before overwriting. -- Turning `Restore session` off deletes stored snapshots and the session record, and quitting asks as before. +- Turning `Restore session` off clears automatic restart state and unnamed snapshots, while named contexts and their drafts remain available. Unnamed windows then ask about unsaved changes before closing; named windows still park. - Recovery after a crash reaches back only to the last snapshot. ### Use one hybrid document surface diff --git a/docs/help/file-and-folder-workflows.md b/docs/help/file-and-folder-workflows.md index 5152bef2..cd6c1201 100644 --- a/docs/help/file-and-folder-workflows.md +++ b/docs/help/file-and-folder-workflows.md @@ -11,6 +11,18 @@ Browse notes without losing your place, keep the documents you need, and search On Windows, Leafdown is available in File Explorer's **Open with** menu and **Settings > Apps > Default apps** after installation. Installing does not change your default app. Opening an associated file starts Leafdown or brings it forward; starting Leafdown again without a file opens a new window. +## Keep named folders + +Open **File > Named folders…**, enter a display name for the current folder, and choose **Keep this folder**. Use **Choose folder…** first if you want another folder. The folder remains ordinary: its name on disk and its files stay unchanged, and Leafdown keeps the entry and unsaved drafts in application data. + +Choose a named folder above the navigator, on welcome, in **File > Named folders…**, or by searching its name in the command palette. Switching parks the current named folder and restores the selected folder's own tabs and drafts, including untitled notes and documents outside its root. Restoring starts a fresh undo history. If keeping the latest text fails, the current work stays open. Entering from an unnamed session asks about its unsaved changes before replacing its tabs. + +**Open in new window** opens a parked context separately; choosing one already active brings its window forward. Opening a document owned by another context brings that context forward too, so there is only one editable copy. **Park this folder**, **Close folder**, and opening an ordinary folder leave a named context's drafts available for later. + +Use **Rename** to change the display name. Park the context before using **Locate folder…** to find a moved root, or **Remove** to forget the entry. Locating updates document paths inside the old root and retains outside tabs. A missing or unreadable root still leaves its entry and recoverable drafts available. Removing an entry does not delete its directory or saved files, and disposing of its unsaved drafts requires confirmation. + +Named folders are separate from recent history and survive clearing it. They also survive turning **Restore session** off: that setting controls which windows reopen automatically. Closing a named context's window parks it, even while other windows remain open or restoration is off. Only **Save** writes a Markdown file. + ## Work with tabs Opening a file through Open, recent items, a drop, or the operating system gives it an ordinary tab after the active one. Opening an already open file switches to its tab. @@ -40,7 +52,7 @@ A file is open in only one window at a time. Opening it again brings its existin Renaming a file or folder moves its open tabs in every window and preserves edits. Deleting closes its tabs in the deleting window; other windows keep their text and mark the file missing. Folder replacement skips a file with unsaved edits in another window and reports it. -**File > Close window** (`Alt+F4`) closes one window. **File > Quit** (`Mod+Q`) closes them all. With **Restore session** on, quitting or closing the last window keeps the windows, tabs, and unsaved edits for next launch without asking, unless saving that session fails. Closing a window while others remain open asks about its unsaved changes as usual. With restoration off, unsaved changes prompt before closing. Canceling a quit stops there, leaving that window and those not yet asked open. +**File > Close window** (`Alt+F4`) closes one window. **File > Quit** (`Mod+Q`) closes them all. Named contexts always park their tabs and drafts; failed persistence keeps the window open. For unnamed sessions, **Restore session** keeps windows and unsaved edits at quit or when closing the last window, and closing a window while others remain asks as usual. With restoration off, unnamed unsaved changes prompt before closing. Canceling a quit stops there, leaving that window and those not yet asked open. ## Work in the article navigator diff --git a/docs/help/getting-started.md b/docs/help/getting-started.md index c499229c..a1763d08 100644 --- a/docs/help/getting-started.md +++ b/docs/help/getting-started.md @@ -49,10 +49,12 @@ The tab's dot disappears when its changes have been saved. Session restoration k ## Leave and come back -Close one tab with **File > Close document** (`Mod+W`). Leafdown asks before discarding that tab's unsaved changes. Closing a folder or a window while other Leafdown windows remain open also asks about unsaved work. Closing the last window keeps its session instead when restoration is on. +Close one tab with **File > Close document** (`Mod+W`). Leafdown asks before discarding that tab's unsaved changes. A named folder parks its tabs and drafts when you leave it or close its window. An unnamed folder or a window closed while others remain asks about unsaved work. Closing the last window keeps its session instead when restoration is on. **File > Quit** (`Mod+Q`) keeps your windows, tabs, and unsaved edits for the next launch when **Preferences > Files > Restore session** is on, as it is by default. Turn it off to have quitting ask about unsaved changes instead. +To keep distinct sets of tabs and drafts, use **File > Named folders…**. Named folders remain available with restoration off, and closing their windows parks them. See [File and folder workflows](file-and-folder-workflows.md). + If another program changed a file, Leafdown warns before overwriting it. If the file went missing, Save offers Save as. ## Choose your next step diff --git a/docs/help/settings-reference.md b/docs/help/settings-reference.md index 212be98c..c69c69cc 100644 --- a/docs/help/settings-reference.md +++ b/docs/help/settings-reference.md @@ -27,7 +27,7 @@ Two related settings live in the workspace itself: **View > Always on top** keep ### Return to unfinished work -Leave **Restore session** on to reopen windows, tabs, and unsaved text after quitting. It is on by default. Turn it off to have quitting ask about unsaved changes and the next launch start at the welcome screen. +Leave **Restore session** on to reopen the windows left open at quit or crash, with their assigned contexts, tabs, and unsaved text. It is on by default. Turning it off clears automatic restart state and unnamed sessions, so the next launch starts at welcome and unnamed windows ask about unsaved changes before closing. Named folders and their drafts remain available; their windows still park on close. Parked contexts stay parked until you select them. > **Tip:** Restoring a session does not save your Markdown files. Use **File > Save** (`Mod+S`) when you want the file on disk to contain your changes. diff --git a/docs/reference.md b/docs/reference.md index 9a2895eb..e494cc83 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -44,7 +44,7 @@ Global settings persist across application launches unless specified otherwise. - **Ignored directories for folder scans:** directory name list. Default: `.git`, `.hg`, `.svn`, `node_modules`, `target`, `dist`, `build`, `.cache`. - Directory name matching is case-sensitive on Unix-like systems and case-insensitive on Windows. Matching directories and their contents are recursively skipped. - **Restore session:** On or Off. Default: On. - - Keeps each window's folder context and tabs, and the unsaved text of the documents in them, for the next launch, so quitting does not ask about unsaved changes. See [Kept Session](./specification.md#kept-session). + - Automatically reopens windows left at quit or crash, including their assigned named contexts. Turning it off clears automatic restart state and unnamed sessions; named folders and their unsaved drafts remain available. See [Kept Session](./specification.md#kept-session). - **When dropping a folder:** Open or Insert folder link. Default: Open. - **When dropping a Markdown file:** Open or Insert file link. Default: Open. @@ -88,6 +88,7 @@ Shortcuts use `Mod` as the primary platform modifier (`Ctrl` on Windows/Linux, ` - **New window** (`Mod+Shift+N`) - **Open...** (`Mod+O`) - **Open folder...** (`Mod+Shift+O`) +- **Named folders…**: select, keep, rename, park, locate, or remove a named folder, or open it in a new window. Also available above the navigator and on welcome; individual named folders appear in the command palette. No default shortcut. - **Open recent** - **Open last closed** (Deferred) - **Recent files** @@ -390,7 +391,7 @@ A submenu trigger carries the same state as the commands behind it: it is disabl - `Open file location` requires an active saved document path. If the native file reveal fails because the path is missing or inaccessible, Leafdown shows an error. - `Reveal in sidebar` requires a folder context and an active saved article in the article navigator. -- `Close folder` requires a folder context. +- `Close folder` requires a folder context. In a named context it parks that context, retaining its tabs and drafts. - `Open last closed` requires a last-closed item. - `Clear recent items` requires at least one recent file or folder. - `Toggle sidebar`, `Sort articles by`, `Show all files`, `Collapse all folders`, and `Expand all folders` require a folder context and an available article navigator. diff --git a/docs/specification.md b/docs/specification.md index 0c1cee74..6026169a 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -124,13 +124,13 @@ Every open document has its own state, kept while its tab is inactive: its edito ### Session Lifecycle - `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 folder` closes an unnamed folder context and every tab, returning to the welcome screen. A named context is parked instead, as [Named Folders](#named-folders) describes. - `Close window` closes the current window and its tabs, asking only about that window's unsaved documents. Closing the last window quits Leafdown. - `Quit` closes every window in turn, starting with the window it was chosen in. A window with unsaved documents comes to the front and shows its unsaved-documents dialog; a window without them closes without asking. `Cancel` stops quitting and leaves that window and every window not yet asked open, while windows already closed stay closed. - While `Restore session` is on, quitting and closing the last window keep each window's unsaved documents for the next launch instead of asking about them, as [Kept Session](#kept-session) describes. - `New window` opens a window on the welcome screen, offset down and to the right of the window it was opened from, or at the top left of the screen when it would not fit there. The first window opens at the size and position it was last closed at; windows opened later do not keep theirs. -- A saved file is open in at most one tab across all windows. Opening a file another window holds makes its tab active there and brings that window to the front, instead of opening it again. -- Opening a file or folder, creating a document, and switching tabs never ask about unsaved changes, since the only document an open replaces is the preview tab's, which has none. Closing a tab, the folder, or the window asks first when a document it closes has unsaved changes, unless the window's session is kept. 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. +- A saved file has one owner across active and parked contexts, including unnamed sessions. Opening a file another window holds makes its tab active there and brings that window to the front. Opening one in a parked named context restores its owner instead of creating another editable copy. +- Ordinary opening, creating a document, and switching tabs retain unsaved work. Entering a named context from an unnamed session resolves that session's unsaved changes before replacing its tabs. Closing a tab, an unnamed folder, or an unnamed window asks first when a document it closes has unsaved changes, unless its session is kept. 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. ### Kept Session @@ -140,13 +140,25 @@ While `Restore session` is on, Leafdown keeps each window's folder context and t - Every 5 seconds after a change, a window records its folder context, its tabs in order with their caret, the active tab, and the preview tab. Each document whose unsaved text changed since its last snapshot, untitled documents included, is snapshotted with it. A snapshot reads the text a save would write without finalizing source projection or committing a footnote or reference definition label, so an edit in progress goes on. - A snapshot is not a save. The document stays dirty, its file is untouched, and Save still reaches the external-change, missing-file, and unrepresentable-character prompts. A document saved, discarded, or closed loses its snapshot. - Quitting, and closing the last window, take a final snapshot of each window and close it without the unsaved-documents dialog. A window whose final snapshot cannot be written asks as usual, and declining keeps it open. -- Closing a tab, the folder, or a window while Leafdown keeps running asks as usual, since what it closes leaves the session. Windows already closed by a quit that is later cancelled stay kept. +- Closing an individual tab still asks about unsaved changes and removes it from its context. Closing a named context's window parks it even while other windows remain; a failed final snapshot keeps that window open. Unnamed folders and windows retain their ordinary confirmation behavior. Windows already closed by a quit that is later cancelled stay kept. - A failed snapshot leaves the documents dirty and warns once, until a snapshot succeeds. - The next launch reopens the first kept window's tabs in the first window and opens a window for each other kept window, placed as `New window` places it. Clean saved documents read their files again, in the encoding they were read in; one whose file is gone is not restored. Dirty saved documents and untitled documents take their kept text and stay dirty, with `Undo` history starting over. A saved document whose file changed or went missing since its snapshot keeps its text and records the external change or missing file with the usual warning; a file only touched is accepted without one. A document whose kept text cannot be read is reopened from its file, with an error saying so. The folder context loads after the tabs appear, and restoring does not change recent files or folders. - A file the launch names opens after the restored tabs, in the first window. -- The kept session lives in Leafdown's application data, never in an opened folder. Turning `Restore session` off lets it go, and quitting then asks as it does without it. +- The kept session lives in Leafdown's application data, never in an opened folder. Turning `Restore session` off clears automatic restart assignments and unnamed session data, but retains named contexts and their drafts. Launch then starts on the welcome screen, from which named contexts can be selected. - After a crash, the next launch restores the session as of the last snapshot. +### Named Folders + +A named folder is one ordinary root folder with an editable display name, ordered tabs, an active tab, and each document's kept state and unsaved text. Tabs outside its root and untitled drafts are allowed. Keeping or renaming an entry never renames its directory or writes metadata there. All metadata and snapshots live in application data, separately from automatic recent history; disabling or clearing history does not remove named folders. + +- Use the selector above the article navigator, the welcome screen, **File > Named folders…**, or a named folder's command-palette entry. Choose a folder, enter a display name, and choose **Keep this folder** to register the current folder and tabs. Ordinary Open Folder remains available without registration. +- Selecting another context first persists the latest settled unsaved text, then releases the current named context's editors and restores the selected context's own tabs. Failed parking stops the switch and retains current work. Restoration retains tab order and the active tab, without opening the root's index over them. Undo history starts over after restoration. +- Entering from an unnamed session asks about its unsaved changes before replacing its tabs. Leaving a named context through Open Folder, Close Folder, or **Park this folder** parks it and begins an unnamed session, without rebinding the named entry to another root. +- A context is active in at most one window. Selecting one active elsewhere focuses that window. **Open in new window** claims a parked context for a new window. Context claims and saved-file ownership are coordinated across concurrent requests using platform path identity. +- Closing a named window parks it, even with restoration off or with other windows still open. With restoration on, the next launch automatically reopens only the contexts assigned to windows left at quit or crash. Already parked contexts stay parked. Restart state refers to the same document snapshots as the context. +- A missing or unreadable root reports its scan failure while retaining the named entry and recoverable drafts. Park the context to use **Locate folder…** or **Remove**. Locating a moved root updates paths for tabs inside it and leaves outside tabs unchanged; a conflicting file owner prevents relocation. Removing an entry never deletes its folder or document files and requires confirmation before disposing of unsaved drafts. +- Named contexts use the kept-session timing and file recovery rules above. Dirty documents remain dirty, changed or missing files retain their warnings, and only Save writes document files. Existing window-based kept sessions migrate without replacing their snapshots. + ### Marker Visibility State - **Editable marker:** raw Markdown syntax is exposed as editable text for the active object. diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 6d4ed6fb..b2e6ae83 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -290,6 +290,8 @@ const createWorkerContext = async (workerIndex: number): Promise { name: "launch-document", launchArguments: [launchDocumentPath] }, { name: "persistence-write", recentFolders: [folderPath] }, { name: "persistence-restart", continues: "persistence-write" }, + { name: "named-folders-switch", recentFiles: [calloutsPath, citationsPath] }, + { name: "named-folders-restore", continues: "named-folders-switch" }, { name: "kept-session-quit", recentFiles: [citationsPath] }, { name: "kept-session-restore", continues: "kept-session-quit" }, { name: "release-notes-first-install" }, diff --git a/e2e/desktop/specs/named-folders-restore.spec.ts b/e2e/desktop/specs/named-folders-restore.spec.ts new file mode 100644 index 00000000..621f4f02 --- /dev/null +++ b/e2e/desktop/specs/named-folders-restore.spec.ts @@ -0,0 +1,25 @@ +import { $, browser, expect } from "@wdio/globals"; +import path from "node:path"; + +import { ACTIVE_EDITOR, chooseNamedFolder } from "../support/namedFolders.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { countDocumentTabs, findDocumentTab } from "../support/ui.js"; + +describe("desktop named folders after restart", () => { + it("reopens the window left at quit and restores a parked folder only when selected", async () => { + const { callouts } = await getDesktopE2ERunContext(); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Studies draft marker")); + expect(await browser.getWindowHandles()).toHaveLength(1); + expect(await countDocumentTabs()).toBe(1); + await chooseNamedFolder("Personal notes"); + expect(await countDocumentTabs()).toBe(2); + await (await findDocumentTab("Untitled")).click(); + await expect($(ACTIVE_EDITOR)).toHaveText( + expect.stringContaining("Personal untitled draft marker"), + ); + await (await findDocumentTab(path.basename(callouts.path))).click(); + await expect($(ACTIVE_EDITOR)).toHaveText( + expect.stringContaining("Personal saved draft marker"), + ); + }); +}); diff --git a/e2e/desktop/specs/named-folders-switch.spec.ts b/e2e/desktop/specs/named-folders-switch.spec.ts new file mode 100644 index 00000000..7e89c6bb --- /dev/null +++ b/e2e/desktop/specs/named-folders-switch.spec.ts @@ -0,0 +1,95 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { getDiagnosticsSummary } from "../support/diagnostics.js"; +import { + ACTIVE_EDITOR, + NAMED_FOLDERS_DIALOG, + appendToNamedDraft, + chooseNamedFolder, + closeNamedFolders, + keepCurrentFolder, + openNamedFolders, +} from "../support/namedFolders.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { + countDocumentTabs, + dismissToasts, + findDocumentTab, + openRecentPath, + selectFileMenuItem, +} from "../support/ui.js"; + +describe("desktop named folder switching", () => { + it("parks distinct tabs and drafts, activates an owner in another window, and parks a closed window", async () => { + const { callouts, citations } = await getDesktopE2ERunContext(); + const onDisk = await readFile(callouts.path, "utf8"); + const diagnostics = await getDiagnosticsSummary(); + const processId = Number(diagnostics.runId.slice(diagnostics.runId.lastIndexOf("-") + 1)); + const firstWindow = await browser.getWindowHandle(); + await openRecentPath(callouts.path); + await dismissToasts(); + await appendToNamedDraft("Personal saved draft marker"); + await selectFileMenuItem("New"); + await appendToNamedDraft("Personal untitled draft marker"); + await keepCurrentFolder("Personal notes"); + await openNamedFolders(); + await $(NAMED_FOLDERS_DIALOG).$("button=Park this folder").click(); + await closeNamedFolders(); + expect(await countDocumentTabs()).toBe(0); + + await openRecentPath(citations.path); + await dismissToasts(); + await appendToNamedDraft("Studies draft marker"); + await keepCurrentFolder("Studies"); + await chooseNamedFolder("Personal notes"); + await expect($(ACTIVE_EDITOR)).toHaveText( + expect.stringContaining("Personal untitled draft marker"), + ); + expect(await countDocumentTabs()).toBe(2); + await (await findDocumentTab(path.basename(callouts.path))).click(); + await expect($(ACTIVE_EDITOR)).toHaveText( + expect.stringContaining("Personal saved draft marker"), + ); + await chooseNamedFolder("Studies"); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Studies draft marker")); + expect(await countDocumentTabs()).toBe(1); + + const before = await browser.getWindowHandles(); + await openNamedFolders(); + const personal = $(NAMED_FOLDERS_DIALOG).$('section[aria-label="Personal notes"]'); + await personal.$("button=Open in new window").click(); + await browser.waitUntil( + async () => (await browser.getWindowHandles()).length === before.length + 1, + ); + const secondWindow = (await browser.getWindowHandles()).find( + (handle) => !before.includes(handle), + )!; + await closeNamedFolders(); + await browser.switchToWindow(firstWindow); + await openRecentPath(callouts.path); + await browser.switchToWindow(secondWindow); + await expect($(ACTIVE_EDITOR)).toHaveText( + expect.stringContaining("Personal saved draft marker"), + ); + expect(await countDocumentTabs()).toBe(2); + await selectFileMenuItem("Close window"); + await browser.switchToWindow(firstWindow); + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Studies draft marker")); + expect(await readFile(callouts.path, "utf8")).toBe(onDisk); + await selectFileMenuItem("Quit"); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + process.kill(processId, 0); + } catch { + return; + } + await delay(100); + } + throw new Error("Named folder windows did not finish quitting."); + }); +}); diff --git a/e2e/desktop/specs/rendered-math.spec.ts b/e2e/desktop/specs/rendered-math.spec.ts index 217a9fc6..1fc773ac 100644 --- a/e2e/desktop/specs/rendered-math.spec.ts +++ b/e2e/desktop/specs/rendered-math.spec.ts @@ -9,6 +9,10 @@ const math = (value: string) => $(`[data-type="math"][data-value="${value.replaceAll("\\", "\\\\")}"]`); const preview = () => $(".leafdown-math-preview"); +type MathTimingWindow = Window & { + mathFontTiming: { observer: PerformanceObserver; requests: string[] }; +}; + // Projected math source is split into pieces by its marker and command styling, so its text, the // caret's offset in it, and its bounds are read across every piece. const readProjection = () => @@ -55,6 +59,22 @@ const pressMath = (element: ReturnType) => ); describe("desktop rendered math", () => { + before(async () => { + await browser.execute(() => { + const requests: string[] = []; + const observer = new PerformanceObserver((entries) => { + requests.push( + ...entries + .getEntries() + .map(({ name }) => name) + .filter((name) => name.includes("KaTeX_")), + ); + }); + observer.observe({ type: "resource", buffered: true }); + (window as unknown as MathTimingWindow).mathFontTiming = { observer, requests }; + }); + }); + it("edits and writes the math corpus byte for byte", async () => { const { mathCorpus } = await getDesktopE2ERunContext(); const original = await readFile(mathCorpus.path, "utf8"); @@ -151,14 +171,13 @@ describe("desktop rendered math", () => { await browser.execute(async () => { await document.fonts.ready; const variable = document.querySelector('[data-type="math"] .mathnormal')!; + const { observer, requests } = (window as unknown as MathTimingWindow).mathFontTiming; return { family: getComputedStyle(variable).fontFamily, loaded: [...document.fonts].some( (font) => font.family.includes("KaTeX_Math") && font.status === "loaded", ), - requests: performance - .getEntriesByType("resource") - .map(({ name }) => name) + requests: [...requests, ...observer.takeRecords().map(({ name }) => name)] .filter((name) => name.includes("KaTeX_")) .map((name) => ({ sameOrigin: name.startsWith(location.origin), diff --git a/e2e/desktop/support/namedFolders.ts b/e2e/desktop/support/namedFolders.ts new file mode 100644 index 00000000..1455ad35 --- /dev/null +++ b/e2e/desktop/support/namedFolders.ts @@ -0,0 +1,53 @@ +import { $, browser, expect } from "@wdio/globals"; + +import { selectFileMenuItem } from "./ui.js"; + +export const ACTIVE_EDITOR = "[data-testid=active-document-host] .ProseMirror"; +export const NAMED_FOLDERS_DIALOG = '[data-slot="dialog-content"][data-open]'; + +export const openNamedFolders = async () => { + await selectFileMenuItem("Named folders"); + await expect($(NAMED_FOLDERS_DIALOG)).toBeDisplayed(); +}; + +export const closeNamedFolders = async () => { + await expect($(NAMED_FOLDERS_DIALOG).$("[aria-busy]")).toHaveAttribute("aria-busy", "false"); + await $(NAMED_FOLDERS_DIALOG).$("aria/Close").click(); + await expect($(NAMED_FOLDERS_DIALOG)).not.toBeDisplayed(); +}; + +export const keepCurrentFolder = async (name: string) => { + await openNamedFolders(); + await $(NAMED_FOLDERS_DIALOG).$("input").setValue(name); + await $(NAMED_FOLDERS_DIALOG).$("button=Keep this folder").click(); + await expect($(NAMED_FOLDERS_DIALOG).$("button=Park this folder")).toBeDisplayed(); + await closeNamedFolders(); +}; + +export const chooseNamedFolder = async (name: string) => { + await openNamedFolders(); + await $(NAMED_FOLDERS_DIALOG) + .$("button=" + name) + .click(); + await expect($(NAMED_FOLDERS_DIALOG)).not.toBeDisplayed(); +}; + +export const appendToNamedDraft = async (text: string) => { + await browser.execute((selector) => { + const editor = document.querySelector(selector)!; + const paragraph = [...editor.querySelectorAll("p")].find( + (element) => + element.getClientRects().length > 0 && !element.closest('[contenteditable="false"]'), + ); + if (!paragraph) throw new Error("No visible editable paragraph is available for the draft."); + const range = document.createRange(); + range.selectNodeContents(paragraph); + range.collapse(false); + const selection = document.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + editor.focus(); + }, ACTIVE_EDITOR); + await $(ACTIVE_EDITOR).addValue(text); + await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining(text)); +}; diff --git a/e2e/desktop/support/scenarioSelection.test.ts b/e2e/desktop/support/scenarioSelection.test.ts index 2f114d71..cdb818a7 100644 --- a/e2e/desktop/support/scenarioSelection.test.ts +++ b/e2e/desktop/support/scenarioSelection.test.ts @@ -18,6 +18,7 @@ void test("runs every scenario when no focused target is requested", () => { ["find-and-replace"], ["keyboard-shortcuts"], ["multiple-windows"], + ["named-folders-switch", "named-folders-restore"], ["heading-outline"], ["folder-watcher"], ["folder-actions"], diff --git a/e2e/desktop/support/scenarioSelection.ts b/e2e/desktop/support/scenarioSelection.ts index 6b7352f0..de8fbbbe 100644 --- a/e2e/desktop/support/scenarioSelection.ts +++ b/e2e/desktop/support/scenarioSelection.ts @@ -11,6 +11,7 @@ const scenarioTargets = { "find-and-replace": ["find-and-replace"], "keyboard-shortcuts": ["keyboard-shortcuts"], "multiple-windows": ["multiple-windows"], + "named-folders": ["named-folders-switch", "named-folders-restore"], "heading-outline": ["heading-outline"], "folder-watcher": ["folder-watcher"], "folder-actions": ["folder-actions"], diff --git a/src-tauri/src/command_contract_tests.rs b/src-tauri/src/command_contract_tests.rs index ae9ce321..c4d1fe84 100644 --- a/src-tauri/src/command_contract_tests.rs +++ b/src-tauri/src/command_contract_tests.rs @@ -8,6 +8,25 @@ use crate::{ text_encoding::{DocumentEncoding, TextEncoding}, }; +#[test] +fn named_folder_boundary_uses_camel_case_payloads_and_stable_error_tags() { + use crate::kept_session::{KeptSessionError, named_folders::NamedFolderChanges}; + + let changes = serde_json::from_value::(serde_json::json!({ + "name": "Studies", "path": "C:/Moved", "remove": false, + "discardUnsaved": true, "expectedRevision": 7 + })); + assert!(changes.is_ok()); + assert_eq!( + serde_json::to_value(KeptSessionError::ContextBusy).unwrap(), + serde_json::json!({ "kind": "contextBusy" }) + ); + assert_eq!( + serde_json::to_value(KeptSessionError::UnsavedDrafts).unwrap(), + serde_json::json!({ "kind": "unsavedDrafts" }) + ); +} + #[test] fn saves_scans_and_opens_markdown_documents_through_command_functions() { let root = TestDirectory::new("command-contract-flow"); diff --git a/src-tauri/src/kept_session.rs b/src-tauri/src/kept_session.rs index b56f6d67..4042f2a5 100644 --- a/src-tauri/src/kept_session.rs +++ b/src-tauri/src/kept_session.rs @@ -13,12 +13,14 @@ use crate::{ window::{self, MAIN_WINDOW_LABEL, QuitSequence, WindowNumbering}, }; +pub(crate) mod named_folders; + const SESSION_DIRECTORY: &str = "session"; const DEV_SESSION_DIRECTORY: &str = "session.dev"; const SESSION_RECORD_FILE: &str = "session.json"; const SNAPSHOTS_DIRECTORY: &str = "snapshots"; const SNAPSHOT_EXTENSION: &str = "md"; -const SESSION_RECORD_VERSION: u32 = 1; +const SESSION_RECORD_VERSION: u32 = 2; const MAX_SNAPSHOT_ID_LENGTH: usize = 64; /// One window as the session keeps it. The frontend owns the shape of `state`; the backend only @@ -35,6 +37,8 @@ pub(crate) struct KeptWindow { struct SessionRecord { version: u32, windows: Vec, + #[serde(default)] + named_folders: Vec, } #[derive(Debug, Deserialize)] @@ -48,6 +52,8 @@ pub(crate) struct SnapshotText { #[serde(rename_all = "camelCase")] pub(crate) struct RestoredWindow { state: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + context_id: Option, /// The text of each snapshot that could be read, by id. snapshots: HashMap, } @@ -62,6 +68,9 @@ pub(crate) enum KeptSessionError { Unavailable, InvalidSnapshotId { id: String }, WriteFailed { message: String }, + InvalidContext, + ContextBusy, + UnsavedDrafts, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -71,12 +80,14 @@ enum Owner { Window(String), /// Its window closed in a way that keeps it for the next launch. Closed, + Parked, } #[derive(Debug, Clone)] struct Entry { owner: Owner, window: KeptWindow, + named: Option, } /// The windows, tabs, and unsaved text kept for the next launch, and the files in the application @@ -87,36 +98,48 @@ pub(crate) struct KeptSession { inner: Mutex, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct Inner { directory: Option, entries: Vec, /// Windows already closed in this run. A report one sent before closing can arrive after it /// closed, and must not bring it back; labels are never reused within a run. closed_windows: HashSet, + revision: u64, + restore_session: bool, } impl KeptSession { fn load(&self, directory: PathBuf) { let mut inner = self.inner(); - inner.entries = read_session_record(directory.as_path()) - .into_iter() - .map(|window| Entry { - owner: Owner::Loaded, - window, - }) - .collect(); + inner.entries = read_session_record(directory.as_path()); + inner.restore_session = true; inner.directory = Some(directory); } /// Records the window's current state after writing the snapshots it refers to that changed. + #[cfg(test)] fn report( &self, window_label: &str, window: KeptWindow, snapshots: Vec, ) -> Result<(), KeptSessionError> { + self.report_context(window_label, None, true, window, snapshots) + } + + fn report_context( + &self, + window_label: &str, + context_id: Option<&str>, + restore_session: bool, + window: KeptWindow, + snapshots: Vec, + ) -> Result<(), KeptSessionError> { + if !window.state.is_object() { + return Err(KeptSessionError::InvalidContext); + } for id in window .snapshot_ids .iter() @@ -130,6 +153,13 @@ impl KeptSession { if inner.closed_windows.contains(window_label) { return Ok(()); } + if inner.context_id(window_label) != context_id { + return Err(KeptSessionError::InvalidContext); + } + if context_id.is_none() && !inner.restore_session && restore_session { + return Ok(()); + } + let previous = inner.clone(); let snapshots_directory = inner.directory()?.join(SNAPSHOTS_DIRECTORY); @@ -146,25 +176,48 @@ impl KeptSession { let owner = Owner::Window(window_label.to_owned()); match inner.entries.iter_mut().find(|entry| entry.owner == owner) { - Some(entry) => entry.window = window, - None => inner.entries.push(Entry { owner, window }), + Some(entry) => { + entry.window = window; + if let Some(named) = &entry.named { + entry.window.state["folderPath"] = named.path.clone().into(); + } + } + None => inner.entries.push(Entry { + owner, + window, + named: None, + }), } - inner.persist() + inner.commit(previous) } /// Hands the window its part of the kept session. The first window takes the first window kept /// from the previous run, and each of the others is given to a new window, whose labels are /// returned for the caller to open; each takes its own when it starts. + #[cfg(test)] fn take( + &self, + window_label: &str, + next_label: impl FnMut() -> String, + ) -> (Option, Vec) { + self.take_with_documents(window_label, next_label, None) + } + + fn take_with_documents( &self, window_label: &str, mut next_label: impl FnMut() -> String, + documents: Option<&crate::window_documents::WindowDocuments>, ) -> (Option, Vec) { let mut inner = self.inner(); let mut new_window_labels = Vec::new(); if window_label == MAIN_WINDOW_LABEL { + let had_loaded = inner + .entries + .iter() + .any(|entry| entry.owner == Owner::Loaded); let mut main_has_entry = inner.owns_entry(window_label); for entry in inner @@ -173,16 +226,27 @@ impl KeptSession { .filter(|entry| entry.owner == Owner::Loaded) { let label = if main_has_entry { - let label = next_label(); - new_window_labels.push(label.clone()); - label + next_label() } else { - main_has_entry = true; window_label.to_owned() }; - + if let Some(documents) = documents { + let paths = named_folders::saved_paths(&entry.window); + if !documents.can_assign(&label, &paths) { + entry.owner = Owner::Parked; + continue; + } + documents.assign_context(&label, paths); + } + if main_has_entry { + new_window_labels.push(label.clone()); + } + main_has_entry = true; entry.owner = Owner::Window(label); } + if had_loaded { + inner.revision += 1; + } } let owner = Owner::Window(window_label.to_owned()); @@ -193,6 +257,7 @@ impl KeptSession { .find(|entry| entry.owner == owner) .map(|entry| RestoredWindow { state: entry.window.state.clone(), + context_id: entry.named.as_ref().map(|named| named.id.clone()), snapshots: read_snapshots(directory, entry.window.snapshot_ids.as_slice()), }) }); @@ -208,6 +273,21 @@ impl KeptSession { inner.closed_windows.insert(window_label.to_owned()); + let restore_session = inner.restore_session; + if let Some(entry) = inner + .entries + .iter_mut() + .find(|entry| entry.owner == owner && entry.named.is_some()) + { + entry.owner = if keep && restore_session { + Owner::Closed + } else { + Owner::Parked + }; + inner.revision += 1; + return inner.persist(); + } + if keep { for entry in inner .entries @@ -233,13 +313,25 @@ impl KeptSession { fn clear(&self) -> Result<(), KeptSessionError> { let mut inner = self.inner(); + let previous = inner.clone(); - inner.entries.clear(); - - match fs::remove_dir_all(inner.directory()?) { - Err(error) if error.kind() != io::ErrorKind::NotFound => Err(write_failed(error)), - _ => Ok(()), + inner.restore_session = false; + inner.entries.retain(|entry| entry.named.is_some()); + for entry in &mut inner.entries { + if matches!(entry.owner, Owner::Loaded | Owner::Closed) { + entry.owner = Owner::Parked; + } } + if inner.entries.is_empty() { + if let Err(error) = fs::remove_dir_all(inner.directory()?) + && error.kind() != io::ErrorKind::NotFound + { + *inner = previous; + return Err(write_failed(error)); + } + return Ok(()); + } + inner.commit(previous) } // Each change leaves whole entries behind, so a poisoned lock still holds a usable session. @@ -267,8 +359,23 @@ impl Inner { windows: self .entries .iter() + .filter(|entry| entry.named.is_none() && self.restore_session) .map(|entry| entry.window.clone()) .collect(), + named_folders: self + .entries + .iter() + .filter_map(|entry| { + entry + .named + .as_ref() + .map(|named| named_folders::StoredNamedFolder { + folder: named.clone(), + session: entry.window.clone(), + reopen: self.restore_session && entry.owner != Owner::Parked, + }) + }) + .collect(), }; let bytes = serde_json::to_vec(&record).map_err(|error| KeptSessionError::WriteFailed { message: error.to_string(), @@ -290,7 +397,7 @@ impl Inner { } } -fn read_session_record(directory: &Path) -> Vec { +fn read_session_record(directory: &Path) -> Vec { let bytes = match fs::read(directory.join(SESSION_RECORD_FILE)) { Ok(bytes) => bytes, Err(error) if error.kind() == io::ErrorKind::NotFound => return Vec::new(), @@ -301,7 +408,27 @@ fn read_session_record(directory: &Path) -> Vec { }; match serde_json::from_slice::(&bytes) { - Ok(record) if record.version == SESSION_RECORD_VERSION => record.windows, + Ok(record) if record.version == 1 || record.version == SESSION_RECORD_VERSION => { + let mut entries: Vec = record + .windows + .into_iter() + .map(|window| Entry { + owner: Owner::Loaded, + window, + named: None, + }) + .collect(); + entries.extend(record.named_folders.into_iter().map(|stored| Entry { + owner: if stored.reopen { + Owner::Loaded + } else { + Owner::Parked + }, + window: stored.session, + named: Some(stored.folder), + })); + entries + } Ok(record) => { log::warn!( "ignored a kept session of unknown version {}", @@ -423,10 +550,17 @@ pub(crate) fn forget_window_session(window: &Window, keep: bool) let app = window.app_handle().clone(); let label = window.label().to_owned(); - if keep { - let _ = app + let named = KeptSession::inner(&app.state::()) + .context_id(&label) + .is_some(); + if keep || named { + if let Err(error) = app .state::() - .window_closed(label.as_str(), true); + .window_closed(label.as_str(), keep) + { + log::error!("failed to keep closed window {label}: {error:?}"); + } + named_folders::publish(&app); return; } @@ -437,6 +571,7 @@ pub(crate) fn forget_window_session(window: &Window, keep: bool) { log::error!("failed to drop window {label} from the kept session: {error:?}"); } + named_folders::publish(&app); }); } @@ -446,12 +581,19 @@ pub(crate) async fn report_window_session( window: Window, session: KeptWindow, snapshots: Vec, + context_id: Option, + restore_session: bool, ) -> Result<(), KeptSessionError> { let label = window.label().to_owned(); tauri::async_runtime::spawn_blocking(move || { - app.state::() - .report(label.as_str(), session, snapshots) + app.state::().report_context( + label.as_str(), + context_id.as_deref(), + restore_session, + session, + snapshots, + ) }) .await .unwrap_or_else(|error| Err(join_failed(error))) @@ -469,14 +611,17 @@ pub(crate) async fn restore_window_session( let (restored, new_window_labels) = tauri::async_runtime::spawn_blocking(move || { let numbering = blocking_app.state::(); - blocking_app - .state::() - .take(label.as_str(), || numbering.next_label()) + blocking_app.state::().take_with_documents( + label.as_str(), + || numbering.next_label(), + Some(&blocking_app.state::()), + ) }) .await .map_err(join_failed)?; open_restored_windows(&app, &window, new_window_labels); + named_folders::publish(&app); Ok(restored) } @@ -488,6 +633,19 @@ pub(crate) async fn clear_kept_session(app: AppHandle) -> Result<(), KeptSession .unwrap_or_else(|error| Err(join_failed(error))) } +#[tauri::command] +pub(crate) async fn enable_kept_session(app: AppHandle) -> Result<(), KeptSessionError> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let mut inner = KeptSession::inner(&state); + let previous = inner.clone(); + inner.restore_session = true; + inner.commit(previous) + }) + .await + .map_err(join_failed)? +} + /// Opens the restored windows one after another, each placed as `New window` would place it from /// the window before. A window that fails to open keeps its part for the next launch. fn open_restored_windows(app: &AppHandle, first: &Window, labels: Vec) { @@ -593,6 +751,7 @@ mod tests { restored, Some(RestoredWindow { state: json!({ "name": "main" }), + context_id: None, snapshots: HashMap::from([("a".to_owned(), "# Draft\n".to_owned())]), }) ); diff --git a/src-tauri/src/kept_session/named_folders.rs b/src-tauri/src/kept_session/named_folders.rs new file mode 100644 index 00000000..8a25dc28 --- /dev/null +++ b/src-tauri/src/kept_session/named_folders.rs @@ -0,0 +1,493 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, Runtime, Window}; + +use super::{ + Inner, KeptSession, KeptSessionError, KeptWindow, Owner, RestoredWindow, read_snapshots, + validate_snapshot_id, +}; +use crate::{ + path_utils::path_identity_key, + window::{self, WindowNumbering}, + window_documents::WindowDocuments, +}; + +const NAMED_FOLDERS_CHANGED_EVENT: &str = "leafdown://named-folders-changed"; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct NamedFolder { + pub(super) id: String, + pub(super) name: String, + pub(super) path: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct StoredNamedFolder { + pub(super) folder: NamedFolder, + pub(super) session: KeptWindow, + pub(super) reopen: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct NamedFolderEntry { + #[serde(flatten)] + folder: NamedFolder, + window_label: Option, + has_unsaved_drafts: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NamedFoldersSnapshot { + revision: u64, + folders: Vec, +} + +#[derive(Debug, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum NamedFolderSelection { + Selected { session: Option }, + Focused, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NamedFolderChanges { + name: Option, + path: Option, + #[serde(default)] + remove: bool, + #[serde(default)] + discard_unsaved: bool, + expected_revision: Option, +} + +impl Inner { + fn named_snapshot(&self) -> NamedFoldersSnapshot { + NamedFoldersSnapshot { + revision: self.revision, + folders: self + .entries + .iter() + .filter_map(|entry| { + entry.named.as_ref().map(|folder| NamedFolderEntry { + folder: folder.clone(), + window_label: match &entry.owner { + Owner::Window(label) => Some(label.clone()), + _ => None, + }, + has_unsaved_drafts: entry.window.state["tabs"] + .as_array() + .is_some_and(|tabs| tabs.iter().any(|tab| tab["isDirty"] == true)), + }) + }) + .collect(), + } + } + + pub(super) fn context_id(&self, label: &str) -> Option<&str> { + self.entries + .iter() + .find(|entry| entry.owner == Owner::Window(label.to_owned())) + .and_then(|entry| entry.named.as_ref()) + .map(|named| named.id.as_str()) + } + + pub(super) fn commit(&mut self, previous: Self) -> Result<(), KeptSessionError> { + self.revision += 1; + if let Err(error) = self.persist() { + *self = previous; + return Err(error); + } + Ok(()) + } +} + +impl KeptSession { + pub(crate) fn with_current_context( + &self, + label: &str, + id: Option<&str>, + operation: impl FnOnce() -> T, + ) -> Option { + let inner = self.inner(); + (inner.context_id(label) == id && !inner.closed_windows.contains(label)).then(operation) + } + + pub(crate) fn with_document_owner( + &self, + _label: &str, + path: &str, + operation: impl FnOnce(Option) -> T, + ) -> T { + let inner = self.inner(); + let key = path_identity_key(path); + let owner = inner + .entries + .iter() + .find(|entry| { + entry.named.is_some() + && !matches!(entry.owner, Owner::Window(_)) + && entry.window.state["tabs"].as_array().is_some_and(|tabs| { + tabs.iter().any(|tab| { + tab["kind"] == "saved" + && tab["path"] + .as_str() + .is_some_and(|path| path_identity_key(path) == key) + }) + }) + }) + .and_then(|entry| entry.named.as_ref()) + .map(|named| named.id.clone()); + operation(owner) + } + + fn create_named( + &self, + label: &str, + id: String, + name: String, + path: String, + ) -> Result<(), KeptSessionError> { + validate_snapshot_id(&id)?; + validate_name(&name)?; + validate_root(&path)?; + let mut inner = self.inner(); + if inner.context_id(label).is_some() + || inner + .entries + .iter() + .any(|entry| entry.named.as_ref().is_some_and(|folder| folder.id == id)) + { + return Err(KeptSessionError::InvalidContext); + } + let previous = inner.clone(); + let entry = inner + .entries + .iter_mut() + .find(|entry| entry.owner == Owner::Window(label.to_owned())) + .ok_or(KeptSessionError::InvalidContext)?; + if entry.window.state["folderPath"] + .as_str() + .is_none_or(|root| path_identity_key(root) != path_identity_key(&path)) + { + return Err(KeptSessionError::InvalidContext); + } + entry.named = Some(NamedFolder { + id, + name: name.trim().to_owned(), + path: path.clone(), + }); + entry.window.state["folderPath"] = path.into(); + inner.commit(previous) + } + + fn edit_named( + &self, + id: &str, + changes: NamedFolderChanges, + documents: &WindowDocuments, + ) -> Result<(), KeptSessionError> { + let NamedFolderChanges { + name, + path, + remove, + discard_unsaved, + expected_revision, + } = changes; + if let Some(name) = &name { + validate_name(name)?; + } + if let Some(path) = &path { + validate_root(path)?; + } + let mut inner = self.inner(); + if remove && expected_revision.is_some_and(|revision| revision != inner.revision) { + return Err(KeptSessionError::InvalidContext); + } + let previous = inner.clone(); + let index = inner + .entries + .iter() + .position(|entry| entry.named.as_ref().is_some_and(|folder| folder.id == id)) + .ok_or(KeptSessionError::InvalidContext)?; + if let Some(path) = &path { + let entry = &inner.entries[index]; + let Some(folder) = &entry.named else { + return Err(KeptSessionError::InvalidContext); + }; + let relocated = relocate_window(&entry.window, &folder.path, path); + let paths = saved_paths(&relocated); + if !documents.can_assign("", &paths) + || inner + .entries + .iter() + .enumerate() + .any(|(other_index, entry)| { + other_index != index + && saved_paths(&entry.window).iter().any(|(other_path, _)| { + paths.iter().any(|(path, _)| { + path_identity_key(path) == path_identity_key(other_path) + }) + }) + }) + { + return Err(KeptSessionError::ContextBusy); + } + } + let entry = &mut inner.entries[index]; + if (remove || path.is_some()) && matches!(entry.owner, Owner::Window(_)) { + return Err(KeptSessionError::ContextBusy); + } + if remove { + if !discard_unsaved + && entry.window.state["tabs"] + .as_array() + .is_some_and(|tabs| tabs.iter().any(|tab| tab["isDirty"] == true)) + { + return Err(KeptSessionError::UnsavedDrafts); + } + inner.entries.remove(index); + } else if let Some(folder) = &mut entry.named { + if let Some(name) = name { + folder.name = name.trim().to_owned(); + } + if let Some(path) = path { + entry.window = relocate_window(&entry.window, &folder.path, &path); + folder.path.clone_from(&path); + entry.window.state["folderPath"] = path.into(); + } + } + inner.commit(previous) + } + + fn select_named( + &self, + label: &str, + id: Option<&str>, + documents: &WindowDocuments, + ) -> Result<(Option, Option), KeptSessionError> { + let mut inner = self.inner(); + let target = if let Some(id) = id { + let index = inner + .entries + .iter() + .position(|entry| entry.named.as_ref().is_some_and(|folder| folder.id == id)) + .ok_or(KeptSessionError::InvalidContext)?; + if let Owner::Window(holder) = &inner.entries[index].owner { + return Ok((None, Some(holder.clone()))); + } + Some(index) + } else { + None + }; + let paths = target + .map(|index| saved_paths(&inner.entries[index].window)) + .unwrap_or_default(); + if !documents.can_assign(label, &paths) { + return Err(KeptSessionError::ContextBusy); + } + let previous = inner.clone(); + for entry in &mut inner.entries { + if entry.owner == Owner::Window(label.to_owned()) { + entry.owner = Owner::Parked; + } + } + if let Some(index) = target { + inner.entries[index].owner = Owner::Window(label.to_owned()); + } + inner + .entries + .retain(|entry| entry.owner != Owner::Parked || entry.named.is_some()); + inner.commit(previous)?; + documents.assign_context(label, paths); + let restored = inner + .entries + .iter() + .find(|entry| entry.owner == Owner::Window(label.to_owned())) + .map(|entry| RestoredWindow { + state: entry.window.state.clone(), + context_id: entry.named.as_ref().map(|folder| folder.id.clone()), + snapshots: inner + .directory + .as_deref() + .map(|directory| read_snapshots(directory, &entry.window.snapshot_ids)) + .unwrap_or_default(), + }); + Ok((restored, None)) + } +} + +fn relocate_window(window: &KeptWindow, old_root: &str, new_root: &str) -> KeptWindow { + let mut relocated = window.clone(); + let root = old_root.replace('\\', "/"); + let root = root.trim_end_matches('/'); + let prefix = format!("{}/", path_identity_key(root)); + if let Some(tabs) = relocated.state["tabs"].as_array_mut() { + for tab in tabs { + if let Some(path) = tab["path"].as_str() + && path_identity_key(path).starts_with(&prefix) + { + let normalized = path.replace('\\', "/"); + let suffix = normalized + .split('/') + .skip(root.split('/').count()) + .collect::>() + .join("/"); + tab["path"] = + format!("{}/{}", new_root.trim_end_matches(['/', '\\']), suffix).into(); + } + } + } + relocated.state["folderPath"] = new_root.into(); + relocated +} + +pub(super) fn saved_paths(window: &KeptWindow) -> Vec<(String, bool)> { + window.state["tabs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|tab| { + (tab["kind"] == "saved") + .then(|| { + tab["path"] + .as_str() + .map(|path| (path.to_owned(), tab["isDirty"] == true)) + }) + .flatten() + }) + .collect() +} + +fn validate_name(name: &str) -> Result<(), KeptSessionError> { + if name.trim().is_empty() || name.chars().count() > 200 { + Err(KeptSessionError::InvalidContext) + } else { + Ok(()) + } +} + +fn validate_root(path: &str) -> Result<(), KeptSessionError> { + if Path::new(path).is_absolute() { + Ok(()) + } else { + Err(KeptSessionError::InvalidContext) + } +} + +pub(super) fn publish(app: &AppHandle) { + let snapshot = KeptSession::inner(&app.state::()).named_snapshot(); + if let Err(error) = app.emit(NAMED_FOLDERS_CHANGED_EVENT, snapshot) { + log::error!("failed to publish named folders: {error}"); + } +} + +#[tauri::command] +pub(crate) fn list_named_folders(state: tauri::State<'_, KeptSession>) -> NamedFoldersSnapshot { + KeptSession::inner(&state).named_snapshot() +} + +#[tauri::command] +pub(crate) async fn create_named_folder( + app: AppHandle, + window: Window, + id: String, + name: String, + path: String, +) -> Result<(), KeptSessionError> { + let blocking = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + blocking + .state::() + .create_named(window.label(), id, name, path) + }) + .await + .map_err(super::join_failed)??; + publish(&app); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn edit_named_folder( + app: AppHandle, + id: String, + changes: NamedFolderChanges, +) -> Result<(), KeptSessionError> { + let blocking = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + blocking.state::().edit_named( + &id, + changes, + &blocking.state::(), + ) + }) + .await + .map_err(super::join_failed)??; + publish(&app); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn select_named_folder( + app: AppHandle, + window: Window, + id: Option, + new_window: bool, + document_path: Option, +) -> Result { + if new_window && id.is_none() { + return Err(KeptSessionError::InvalidContext); + } + let label = if new_window { + app.state::().next_label() + } else { + window.label().to_owned() + }; + let blocking = app.clone(); + let destination = label.clone(); + let (session, focus) = tauri::async_runtime::spawn_blocking(move || { + blocking.state::().select_named( + &destination, + id.as_deref(), + &blocking.state::(), + ) + }) + .await + .map_err(super::join_failed)??; + if let Some(holder) = focus { + if let Some(path) = document_path { + let _ = app.emit_to( + &holder, + "leafdown://activate-document", + serde_json::json!({ "path": path }), + ); + } + if let Some(window) = app.get_webview_window(&holder) { + window::bring_forward(&window); + } + return Ok(NamedFolderSelection::Focused); + } + if new_window && let Err(error) = window::create_window(&app, label.clone(), Some(&window)) { + let parked = app.state::().window_closed(&label, false); + crate::window_documents::abandon_context(&app, &label); + publish(&app); + parked?; + return Err(KeptSessionError::WriteFailed { + message: format!("{error:?}"), + }); + } + publish(&app); + Ok(NamedFolderSelection::Selected { session }) +} diff --git a/src-tauri/src/kept_session/named_folders/tests.rs b/src-tauri/src/kept_session/named_folders/tests.rs new file mode 100644 index 00000000..16cff1fe --- /dev/null +++ b/src-tauri/src/kept_session/named_folders/tests.rs @@ -0,0 +1,433 @@ +use std::{ + fs, + sync::{Arc, Barrier}, + thread, +}; + +use serde_json::json; + +use super::*; +use crate::test_utils::TestDirectory; + +fn setup() -> (TestDirectory, KeptSession, WindowDocuments) { + let directory = TestDirectory::new("named-folders"); + let session = KeptSession::default(); + session.load(directory.path.clone()); + (directory, session, WindowDocuments::default()) +} + +fn draft_window(root: &str) -> KeptWindow { + KeptWindow { + state: json!({ "version": 1, "folderPath": root, "activeTabIndex": 1, "tabs": [ + { "kind": "saved", "path": format!("{root}/note.md"), "isDirty": true, "snapshotId": "saved", "preview": false }, + { "kind": "untitled", "id": "untitled:1", "isDirty": true, "snapshotId": "draft" } + ] }), + snapshot_ids: vec!["saved".into(), "draft".into()], + } +} + +fn keep(session: &KeptSession, label: &str, id: &str, root: &str) { + session + .report( + label, + draft_window(root), + vec![ + super::super::SnapshotText { + id: "saved".into(), + text: "Saved edits".into(), + }, + super::super::SnapshotText { + id: "draft".into(), + text: "Untitled edits".into(), + }, + ], + ) + .unwrap(); + session + .create_named(label, id.into(), id.into(), root.into()) + .unwrap(); +} + +#[test] +fn switching_and_restart_share_one_state_and_restore_tab_order_and_drafts() { + let (directory, session, documents) = setup(); + keep(&session, "main", "personal", r"C:\Notes"); + let original = session.inner().entries[0].window.clone(); + session.select_named("main", None, &documents).unwrap(); + let restarted = KeptSession::default(); + restarted.load(directory.path.clone()); + assert!(restarted.take("main", || "unexpected".into()).0.is_none()); + let (restored, focused) = restarted + .select_named("main", Some("personal"), &documents) + .unwrap(); + let restored = restored.unwrap(); + assert!(focused.is_none()); + assert_eq!(restored.state, original.state); + assert_eq!(restored.snapshots["draft"], "Untitled edits"); + assert_eq!(restored.snapshots["saved"], "Saved edits"); + let record: serde_json::Value = + serde_json::from_slice(&fs::read(directory.path("session.json")).unwrap()).unwrap(); + assert_eq!(record["windows"], json!([])); + assert_eq!(record["namedFolders"].as_array().unwrap().len(), 1); +} + +#[test] +fn failed_parking_retains_current_ownership_and_persisted_drafts() { + let (directory, session, documents) = setup(); + keep(&session, "main", "personal", r"C:\Notes"); + let previous = fs::read(directory.path("session.json")).unwrap(); + fs::remove_file(directory.path("session.json")).unwrap(); + fs::create_dir(directory.path("session.json")).unwrap(); + assert!(session.select_named("main", None, &documents).is_err()); + assert_eq!(session.inner().context_id("main"), Some("personal")); + assert_eq!(session.inner().entries[0].window.state["activeTabIndex"], 1); + assert_eq!( + fs::read_to_string(directory.path("snapshots/draft.md")).unwrap(), + "Untitled edits" + ); + fs::remove_dir(directory.path("session.json")).unwrap(); + fs::write(directory.path("session.json"), previous).unwrap(); +} + +#[test] +fn a_failed_snapshot_report_never_replaces_the_current_record() { + let (directory, session, _) = setup(); + keep(&session, "main", "personal", r"C:\Notes"); + let before = session.inner().entries[0].window.clone(); + fs::remove_file(directory.path("session.json")).unwrap(); + fs::create_dir(directory.path("session.json")).unwrap(); + let mut after = before.clone(); + after.state["tabs"][0]["snapshotId"] = "new-saved".into(); + after.snapshot_ids[0] = "new-saved".into(); + assert!( + session + .report_context( + "main", + Some("personal"), + true, + after, + vec![super::super::SnapshotText { + id: "new-saved".into(), + text: "Latest text".into() + }] + ) + .is_err() + ); + assert_eq!(session.inner().entries[0].window, before); + assert_eq!( + fs::read_to_string(directory.path("snapshots/saved.md")).unwrap(), + "Saved edits" + ); +} + +#[test] +fn only_one_concurrent_selection_claims_a_parked_folder() { + let (_directory, session, documents) = setup(); + keep(&session, "main", "personal", r"C:\Notes"); + session.select_named("main", None, &documents).unwrap(); + let session = Arc::new(session); + let documents = Arc::new(documents); + let barrier = Arc::new(Barrier::new(2)); + let workers: Vec<_> = ["window-2", "window-3"] + .into_iter() + .map(|label| { + let session = session.clone(); + let documents = documents.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + session + .select_named(label, Some("personal"), &documents) + .unwrap() + .1 + }) + }) + .collect(); + let focused: Vec<_> = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect(); + assert_eq!(focused.iter().filter(|label| label.is_none()).count(), 1); + assert_eq!( + session + .inner() + .entries + .iter() + .filter(|entry| matches!(entry.owner, Owner::Window(_))) + .count(), + 1 + ); +} + +#[test] +fn parked_and_active_files_route_to_their_context_under_platform_path_identity() { + let (_directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + session.select_named("main", None, &documents).unwrap(); + assert_eq!( + session.with_document_owner("main", r"c:\NOTES\NOTE.md", |owner| owner), + Some("personal".into()) + ); + session + .select_named("window-2", Some("personal"), &documents) + .unwrap(); + assert_eq!( + session.with_document_owner("main", "C:/Notes/note.md", |owner| owner), + None + ); + assert_eq!( + documents.window_holding(r"c:\notes\NOTE.md").as_deref(), + Some("window-2") + ); + assert_eq!( + session.with_document_owner("window-2", "C:/Notes/note.md", |owner| owner), + None + ); + assert!( + session + .with_current_context("window-2", None, || ()) + .is_none() + ); +} + +#[test] +fn selection_respects_files_claimed_by_an_unnamed_window() { + let (_directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + session.select_named("main", None, &documents).unwrap(); + documents.assign_context("other", vec![(r"c:\notes\NOTE.md".into(), true)]); + assert!(matches!( + session.select_named("main", Some("personal"), &documents), + Err(KeptSessionError::ContextBusy) + )); + assert!(session.inner().context_id("main").is_none()); +} + +#[test] +fn disabling_restart_keeps_named_drafts_but_opens_no_context_next_launch() { + let (directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + session.clear().unwrap(); + let restarted = KeptSession::default(); + restarted.load(directory.path.clone()); + assert!(restarted.take("main", || "extra".into()).0.is_none()); + assert_eq!(restarted.inner().named_snapshot().folders.len(), 1); + let restored = restarted + .select_named("main", Some("personal"), &documents) + .unwrap() + .0 + .unwrap(); + assert_eq!(restored.snapshots["draft"], "Untitled edits"); +} + +#[test] +fn closing_a_secondary_named_window_parks_it_while_quitting_reopens_it() { + let (directory, session, documents) = setup(); + keep(&session, "window-2", "personal", "C:/Notes"); + session.window_closed("window-2", false).unwrap(); + let restarted = KeptSession::default(); + restarted.load(directory.path.clone()); + assert!(restarted.take("main", || "extra".into()).0.is_none()); + restarted + .select_named("main", Some("personal"), &documents) + .unwrap(); + restarted.window_closed("main", true).unwrap(); + let quit_restored = KeptSession::default(); + quit_restored.load(directory.path.clone()); + assert_eq!( + quit_restored + .take("main", || "extra".into()) + .0 + .unwrap() + .context_id + .as_deref(), + Some("personal") + ); +} + +#[test] +fn crash_reopens_active_contexts_and_leaves_parked_contexts_available() { + let (directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + session.select_named("main", None, &documents).unwrap(); + keep(&session, "window-2", "studies", "C:/Studies"); + let restarted = KeptSession::default(); + restarted.load(directory.path.clone()); + let (restored, windows) = restarted.take("main", || "unexpected".into()); + assert_eq!(restored.unwrap().context_id.as_deref(), Some("studies")); + assert!(windows.is_empty()); + assert_eq!(restarted.inner().named_snapshot().folders.len(), 2); +} + +#[test] +fn removal_requires_parking_and_confirmation_and_never_deletes_document_files() { + let (directory, session, documents) = setup(); + directory.write_file_with_content("note.md", "On disk"); + let root = directory.path.to_string_lossy().into_owned(); + keep(&session, "main", "personal", &root); + let removal = || NamedFolderChanges { + remove: true, + ..Default::default() + }; + assert!(matches!( + session.edit_named("personal", removal(), &documents), + Err(KeptSessionError::ContextBusy) + )); + session.select_named("main", None, &documents).unwrap(); + assert!(matches!( + session.edit_named("personal", removal(), &documents), + Err(KeptSessionError::UnsavedDrafts) + )); + session + .edit_named( + "personal", + NamedFolderChanges { + discard_unsaved: true, + ..removal() + }, + &documents, + ) + .unwrap(); + assert_eq!( + fs::read_to_string(directory.path("note.md")).unwrap(), + "On disk" + ); + assert!(session.inner().entries.is_empty()); +} + +#[test] +fn locating_a_moved_root_rebases_its_tabs_and_preserves_external_tabs_and_drafts() { + let (_directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + session.select_named("main", None, &documents).unwrap(); + session + .edit_named( + "personal", + NamedFolderChanges { + name: Some("Personal notes".into()), + path: Some("C:/Moved".into()), + ..Default::default() + }, + &documents, + ) + .unwrap(); + let restored = session + .select_named("main", Some("personal"), &documents) + .unwrap() + .0 + .unwrap(); + assert_eq!(restored.state["tabs"][0]["path"], "C:/Moved/note.md"); + assert_eq!(restored.snapshots["draft"], "Untitled edits"); + let external = KeptWindow { + state: json!({ "tabs": [{ "path": "C:/Elsewhere/a.md" }] }), + snapshot_ids: vec![], + }; + assert_eq!( + relocate_window(&external, "C:/Notes", "C:/Moved").state["tabs"], + external.state["tabs"] + ); +} + +#[test] +fn migrates_version_one_windows_and_snapshots_without_losing_unsaved_text() { + let (directory, session, _) = setup(); + fs::create_dir(directory.path("snapshots")).unwrap(); + fs::write(directory.path("snapshots/draft.md"), "Legacy draft").unwrap(); + fs::write( + directory.path("session.json"), + serde_json::to_vec(&json!({ "version": 1, "windows": [draft_window("C:/Notes")] })) + .unwrap(), + ) + .unwrap(); + session.load(directory.path.clone()); + let restored = session.take("main", || "extra".into()).0.unwrap(); + assert_eq!(restored.snapshots["draft"], "Legacy draft"); + assert!(restored.context_id.is_none()); +} + +#[test] +fn a_late_report_cannot_reenable_restart_after_it_was_disabled() { + let (directory, session, _) = setup(); + session + .report("main", draft_window("C:/Notes"), vec![]) + .unwrap(); + session.clear().unwrap(); + session + .report("main", draft_window("C:/Notes"), vec![]) + .unwrap(); + assert!(!directory.path("session.json").exists()); + assert!(session.inner().entries.is_empty()); +} + +#[test] +fn startup_reserves_named_files_before_their_windows_have_restored_editors() { + let (directory, session, documents) = setup(); + keep(&session, "main", "personal", "C:/Notes"); + let restarted = KeptSession::default(); + restarted.load(directory.path.clone()); + restarted.take_with_documents("main", || "window-2".into(), Some(&documents)); + assert_eq!( + documents.window_holding(r"c:\NOTES\NOTE.md").as_deref(), + Some("main") + ); +} + +#[test] +fn invalid_context_payloads_and_stale_removal_are_rejected_without_mutation() { + let (_directory, session, documents) = setup(); + assert!(matches!( + session.report( + "main", + KeptWindow { + state: serde_json::Value::Null, + snapshot_ids: vec![] + }, + vec![] + ), + Err(KeptSessionError::InvalidContext) + )); + keep(&session, "main", "personal", "C:/Notes"); + session.select_named("main", None, &documents).unwrap(); + assert!(matches!( + session.edit_named( + "personal", + NamedFolderChanges { + remove: true, + discard_unsaved: true, + expected_revision: Some(0), + ..Default::default() + }, + &documents + ), + Err(KeptSessionError::InvalidContext) + )); + assert_eq!(session.inner().entries.len(), 1); +} + +#[test] +fn parked_file_ownership_matches_unc_paths_and_preserves_unix_case() { + let (_directory, session, documents) = setup(); + keep(&session, "main", "personal", r"\\Server\Share"); + session.select_named("main", None, &documents).unwrap(); + assert_eq!( + session.with_document_owner("main", "//server/share/NOTE.md", |owner| owner), + Some("personal".into()) + ); + session + .select_named("main", Some("personal"), &documents) + .unwrap(); + let mut state = draft_window(r"\\Server\Share"); + state.state["tabs"][0]["path"] = "/Users/Ada/Note.md".into(); + session + .report_context("main", Some("personal"), true, state, vec![]) + .unwrap(); + session.select_named("main", None, &documents).unwrap(); + assert_eq!( + session.with_document_owner("main", "/Users/Ada/Note.md", |owner| owner), + Some("personal".into()) + ); + assert_eq!( + session.with_document_owner("main", "/users/ada/note.md", |owner| owner), + None + ); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bf2ee20..5963d5bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,8 +160,13 @@ pub fn run() { diagnostics::get_diagnostics_summary, image::resolve_markdown_image_target, kept_session::clear_kept_session, + kept_session::enable_kept_session, kept_session::report_window_session, kept_session::restore_window_session, + kept_session::named_folders::list_named_folders, + kept_session::named_folders::create_named_folder, + kept_session::named_folders::edit_named_folder, + kept_session::named_folders::select_named_folder, launch::take_launch_document_paths, remote_image::fetch_remote_image, update_check::check_for_updates, diff --git a/src-tauri/src/window_documents.rs b/src-tauri/src/window_documents.rs index 7324bc33..1834671e 100644 --- a/src-tauri/src/window_documents.rs +++ b/src-tauri/src/window_documents.rs @@ -6,7 +6,7 @@ use std::{ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, Runtime, State, Window}; -use crate::{path_utils::path_identity_key, window}; +use crate::{kept_session::KeptSession, path_utils::path_identity_key, window}; const WINDOW_DOCUMENTS_CHANGED_EVENT: &str = "leafdown://window-documents-changed"; const ACTIVATE_DOCUMENT_EVENT: &str = "leafdown://activate-document"; @@ -50,10 +50,15 @@ struct DocumentsMovedPayload { } #[derive(Debug, Serialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub(crate) enum ClaimWindowDocumentResult { Claimed, OpenElsewhere, + OwnedByContext { context_id: String }, } #[derive(Debug, Serialize, PartialEq, Eq)] @@ -86,6 +91,24 @@ enum Claim { } impl WindowDocuments { + pub(crate) fn can_assign(&self, label: &str, paths: &[(String, bool)]) -> bool { + let registry = self.registry(); + paths.iter().all(|(path, _)| { + registry + .holder(path) + .is_none_or(|(holder, _)| holder == label) + }) + } + + pub(crate) fn assign_context(&self, label: &str, paths: Vec<(String, bool)>) { + self.set_documents( + label, + paths + .into_iter() + .map(|(path, unsaved)| WindowDocument { path, unsaved }) + .collect(), + ); + } /// Replaces a window's documents, returning the snapshot to publish when they changed. fn set_documents( &self, @@ -246,12 +269,19 @@ pub(crate) fn set_window_documents( window: Window, state: State<'_, WindowDocuments>, documents: Vec, + context_id: Option, ) -> Result<(), WindowDocumentsError> { for document in &documents { validate_path(document.path.as_str())?; } - publish(&app, state.set_documents(window.label(), documents)); + let snapshot = app + .state::() + .with_current_context(window.label(), context_id.as_deref(), || { + state.set_documents(window.label(), documents) + }) + .flatten(); + publish(&app, snapshot); Ok(()) } @@ -268,7 +298,21 @@ pub(crate) fn claim_window_document( ) -> Result { validate_path(path.as_str())?; - let (claim, snapshot) = state.claim(window.label(), path.as_str()); + let result = app + .state::() + .with_document_owner(window.label(), &path, |owner| { + owner.map_or_else(|| Ok(state.claim(window.label(), path.as_str())), Err) + }); + let (claim, snapshot) = match result { + Ok(result) => result, + Err(context_id) => { + return Ok(if activate { + ClaimWindowDocumentResult::OwnedByContext { context_id } + } else { + ClaimWindowDocumentResult::OpenElsewhere + }); + } + }; publish(&app, snapshot); @@ -343,6 +387,10 @@ pub(crate) fn forget_window_documents(window: &Window) { publish(window.app_handle(), snapshot); } +pub(crate) fn abandon_context(app: &AppHandle, label: &str) { + publish(app, app.state::().forget_window(label)); +} + #[cfg(test)] mod tests { use super::{ diff --git a/src/commands/actions/file.ts b/src/commands/actions/file.ts index b54a0c62..43c56d93 100644 --- a/src/commands/actions/file.ts +++ b/src/commands/actions/file.ts @@ -26,7 +26,12 @@ import { pickAndOpenMarkdownFile, saveActiveMarkdownDocument, saveActiveMarkdownDocumentAs, + showNamedFolders, } from "@/features/session"; + +export const openNamedFolders = () => { + showNamedFolders(); +}; import { notifyOperationFailure } from "@/lib/errors"; import { t, type MessageId } from "@/lib/i18n"; import { notifyError, notifySuccess, notifyWarning } from "@/lib/toast"; diff --git a/src/commands/application.ts b/src/commands/application.ts index 52359ce7..dc55de5d 100644 --- a/src/commands/application.ts +++ b/src/commands/application.ts @@ -25,6 +25,7 @@ export const APPLICATION_COMMANDS = { "file.newWindow": appCommand(file.openNewWindow), "file.open": appCommand(file.openMarkdownFile), "file.openFolder": appCommand(file.openFolderContext), + "file.namedFolders": appCommand(file.openNamedFolders), "file.clearRecentItems": appCommand(file.clearRecentItems, file.getClearRecentItemsState), "file.save": appCommand(file.saveDocument, file.getSaveDocumentState), "file.saveAs": appCommand(file.saveDocumentAs, file.getSaveDocumentAsState), diff --git a/src/commands/dispatch.ts b/src/commands/dispatch.ts index 4fe69170..e597fd51 100644 --- a/src/commands/dispatch.ts +++ b/src/commands/dispatch.ts @@ -1,6 +1,6 @@ import { getActiveDocumentKey } from "@/features/document"; import { EDITOR_COMMAND_IDS, isEditorCommandId } from "@/features/editor/commands/contract"; -import { documentEditorBridge } from "@/features/session"; +import { documentEditorBridge, useNamedFoldersStore } from "@/features/session"; import { APPLICATION_COMMAND_IDS, @@ -18,6 +18,9 @@ export const dispatchAppCommand = async ( commandId: AppCommandId, context: AppCommandContext, ): AppCommandDispatchResult => { + if (useNamedFoldersStore.getState().transitioning) { + return false; + } if (isApplicationCommandId(commandId)) { await runApplicationCommand(context, commandId); return true; diff --git a/src/commands/metadata.ts b/src/commands/metadata.ts index 0fbdbfec..639aa1e3 100644 --- a/src/commands/metadata.ts +++ b/src/commands/metadata.ts @@ -34,6 +34,7 @@ export const COMMAND_DEFINITIONS: Record = { "file.newWindow": commandDef({ key: "n", mod: true, shift: true }), "file.open": commandDef({ key: "o", mod: true }), "file.openFolder": commandDef({ key: "o", mod: true, shift: true }), + "file.namedFolders": commandDef(), "file.clearRecentItems": commandDef(), "file.save": commandDef({ key: "s", mod: true }), "file.saveAs": commandDef({ key: "s", mod: true, shift: true }), diff --git a/src/commands/paletteSearch.ts b/src/commands/paletteSearch.ts index fd397890..17d1390e 100644 --- a/src/commands/paletteSearch.ts +++ b/src/commands/paletteSearch.ts @@ -1,12 +1,15 @@ import type { AppCommandId } from "./dispatch"; -export interface PaletteCommand { - id: AppCommandId; +export interface PaletteCommand { + id: TId; label: string; menuPath: string; } -export const searchPaletteCommands = (commands: PaletteCommand[], query: string) => { +export const searchPaletteCommands = >( + commands: T[], + query: string, +) => { const normalizedQuery = query.trim().replace(/\s+/gu, " ").toLocaleLowerCase(); if (!normalizedQuery) { return commands; diff --git a/src/components/layout/command-menubar.tsx b/src/components/layout/command-menubar.tsx index e253c361..81665d32 100644 --- a/src/components/layout/command-menubar.tsx +++ b/src/components/layout/command-menubar.tsx @@ -134,7 +134,7 @@ export function CommandMenubar({ - + ({ enabled: true }), onExecute = vi.fn() describe("CommandPalette", () => { beforeEach(() => setDefaultUI()); + it("finds and selects a named folder directly from the keyboard", async () => { + const activate = vi.spyOn(namedFolderWorkflows, "activateNamedFolder").mockResolvedValue(true); + const onExecute = vi.fn(); + useNamedFoldersStore.setState({ + folders: [ + { + id: "personal", + name: "Personal notes", + path: "C:/Notes", + windowLabel: null, + hasUnsavedDrafts: true, + }, + ], + }); + const { user } = renderWithUser(); + await user.click(screen.getByRole("button", { name: "Open palette" })); + await user.type(screen.getByRole("combobox", { name: "Search commands" }), "personal notes"); + expect(screen.getByRole("option", { name: /Personal notes/u })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.keyboard("{Enter}"); + await waitFor(() => expect(activate).toHaveBeenCalledWith("personal")); + expect(onExecute).not.toHaveBeenCalled(); + }); + it("shows macOS keycaps and gives the Run button a single readable key hint", async () => { await withMacUserAgent(async () => { const { user } = renderWithUser(); diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 9c6f6f68..51048bc7 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -27,6 +27,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { ShortcutKeys } from "@/components/ui/shortcut-keys"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + activateNamedFolder, + runNamedFolderAction, + useNamedFoldersStore, +} from "@/features/session"; import { useLocalization } from "@/lib/i18n"; interface CommandPaletteProps { @@ -67,26 +72,45 @@ function PaletteContent({ commandState, onExecute, onOpenChange }: CommandPalett const inputRef = useRef(null); const executingRef = useRef(false); const [query, setQuery] = useState(""); - const [selectedId, setSelectedId] = useState(); + const [selectedId, setSelectedId] = useState(); + const namedFolders = useNamedFoldersStore((state) => state.folders); const commands = PALETTE_COMMAND_IDS.map((id) => { const label = t(getCommandLabelId(id)); const menuPath = [...getCommandMenuPathIds(id).map((pathId) => t(pathId)), label].join(" › "); - return { id, label, menuPath }; + return { id, label, menuPath, kind: "command" as const, commandId: id }; }); - const results = searchPaletteCommands(commands, query); + const items = [ + ...commands, + ...namedFolders.map((folder) => ({ + id: `namedFolder:${folder.id}`, + label: folder.name, + menuPath: `${t("session.namedFolders.title")} › ${folder.name}`, + kind: "folder" as const, + folderId: folder.id, + })), + ]; + const results = searchPaletteCommands(items, query); const activeCommand = - results.find(({ id }) => id === selectedId && commandState(id).enabled) ?? - results.find(({ id }) => commandState(id).enabled); + results.find( + (item) => + item.id === selectedId && (item.kind === "folder" || commandState(item.commandId).enabled), + ) ?? results.find((item) => item.kind === "folder" || commandState(item.commandId).enabled); - const execute = (commandId: AppCommandId) => { - if (!commandState(commandId).enabled) { + const execute = (item: (typeof items)[number]) => { + if (item.kind === "command" && !commandState(item.commandId).enabled) { return; } executingRef.current = true; flushSync(() => onOpenChange(false)); returnFocusToOpener(); - requestAnimationFrame(() => onExecute(commandId)); + requestAnimationFrame(() => { + if (item.kind === "command") { + onExecute(item.commandId); + } else { + void runNamedFolderAction(() => activateNamedFolder(item.folderId)); + } + }); }; return ( @@ -101,7 +125,7 @@ function PaletteContent({ commandState, onExecute, onOpenChange }: CommandPalett className="h-auto min-h-0 rounded-none bg-transparent" label={t("commandPalette.search")} loop - onValueChange={(value) => setSelectedId(value as AppCommandId)} + onValueChange={setSelectedId} shouldFilter={false} value={activeCommand?.id ?? ""} > @@ -131,16 +155,21 @@ function PaletteContent({ commandState, onExecute, onOpenChange }: CommandPalett {t("commandPalette.noResults")} {results.length > 0 && ( - {results.map(({ id, label, menuPath }) => { - const state = commandState(id); - const shortcut = COMMAND_DEFINITIONS[id].shortcuts?.[0]; + {results.map((item) => { + const { id, label, menuPath } = item; + const state: CommandState = + item.kind === "command" ? commandState(item.commandId) : { enabled: true }; + const shortcut = + item.kind === "command" + ? COMMAND_DEFINITIONS[item.commandId].shortcuts?.[0] + : undefined; return ( execute(id)} + onSelect={() => execute(item)} value={id} > @@ -175,7 +204,7 @@ function PaletteContent({ commandState, onExecute, onOpenChange }: CommandPalett + ); +} + +export function NamedFoldersList() { + const { t } = useLocalization(); + const folders = useNamedFoldersStore((state) => state.folders); + return ( +
+ + {folders.map((folder) => ( + + ))} +
+ ); +} + +export function NamedFoldersDialog() { + const { t } = useLocalization(); + const open = useNamedFoldersStore((state) => state.dialogOpen); + const folders = useNamedFoldersStore((state) => state.folders); + const activeId = useNamedFoldersStore((state) => state.activeId); + const busy = useNamedFoldersStore((state) => state.transitioning); + const folderPath = useSessionStore(getSessionFolderPath); + const [name, setName] = useState(""); + const nameId = useId(); + return ( + { + if (!busy) { + useNamedFoldersStore.setState({ dialogOpen }); + } + }} + > + + + {t("session.namedFolders.title")} + {t("session.namedFolders.description")} + +
+ {folders.map((folder) => ( + + ))} + {activeId ? ( + + ) : ( +
{ + event.preventDefault(); + void runNamedFolderAction(async () => { + if (await keepNamedFolder(name)) { + setName(""); + } + }); + }} + > + + + {t("session.namedFolders.name")} + setName(event.target.value)} + /> + +

+ {folderPath ?? t("session.namedFolders.chooseFirst")} +

+ + +
+
+ )} +
+
+
+ ); +} + +function NamedFolderRow({ folder, active }: { folder: NamedFolder; active: boolean }) { + const { t } = useLocalization(); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(folder.name); + const nameId = useId(); + return ( +
+ {editing ? ( +
{ + event.preventDefault(); + void runNamedFolderAction(async () => { + await renameNamedFolder(folder.id, name); + setEditing(false); + }); + }} + > + + + {t("session.namedFolders.name")} + setName(event.target.value)} + /> + + + + +
+ ) : ( + + )} +

{folder.path}

+
+ + + + +
+
+ ); +} diff --git a/src/features/session/hooks/useNamedFolders.ts b/src/features/session/hooks/useNamedFolders.ts new file mode 100644 index 00000000..e68c1537 --- /dev/null +++ b/src/features/session/hooks/useNamedFolders.ts @@ -0,0 +1,24 @@ +import { useEffect } from "react"; + +import { handleUnexpectedError } from "@/lib/errors"; +import { useTauriEvent } from "@/lib/tauriEvent"; + +import { + NAMED_FOLDERS_CHANGED_EVENT, + type NamedFoldersSnapshot, +} from "../services/namedFoldersApi"; +import { refreshNamedFolders } from "../services/namedFolderWorkflows"; +import { useNamedFoldersStore } from "../stores/namedFolders"; + +export const useNamedFolders = () => { + useEffect(() => { + void refreshNamedFolders().catch((error) => handleUnexpectedError(error, "listNamedFolders")); + }, []); + useTauriEvent( + NAMED_FOLDERS_CHANGED_EVENT, + ({ payload }) => { + useNamedFoldersStore.getState().applySnapshot(payload); + }, + "namedFoldersChanged", + ); +}; diff --git a/src/features/session/index.ts b/src/features/session/index.ts index 874cd166..2d961bf8 100644 --- a/src/features/session/index.ts +++ b/src/features/session/index.ts @@ -1,4 +1,16 @@ export { DroppedPathOverlay } from "./components/dropped-path-overlay"; +export { + NamedFolderSelector, + NamedFoldersDialog, + NamedFoldersList, +} from "./components/named-folders"; +export { useNamedFolders } from "./hooks/useNamedFolders"; +export { useNamedFoldersStore } from "./stores/namedFolders"; +export { + showNamedFolders, + activateNamedFolder, + runNamedFolderAction, +} from "./services/namedFolderWorkflows"; export { useDocumentWatchers } from "./hooks/useDocumentWatchers"; export { useFolderContextWatcher } from "./hooks/useFolderContextWatcher"; export { useFolderSearchSession } from "./hooks/useFolderSearchSession"; diff --git a/src/features/session/services/documentOpenWorkflows.ts b/src/features/session/services/documentOpenWorkflows.ts new file mode 100644 index 00000000..8f69a6cc --- /dev/null +++ b/src/features/session/services/documentOpenWorkflows.ts @@ -0,0 +1,282 @@ +import { + ENCODING_CHOICES, + getOpenMarkdownFileErrorMessage, + isEncodingOpenError, + matchesActiveDocumentKey, + openMarkdownDocument, + toSavedDocument, + type TextEncodingName, +} from "@/features/document"; +import { getScanFolderContextErrorMessage, scanFolderContext } from "@/features/folder-context"; +import { useRecentItemsStore, useSettingsStore } from "@/features/preferences"; +import { + type CancellationToken, + isCancellationError, + runWithCancellation, +} from "@/lib/cancellation"; +import { t } from "@/lib/i18n"; +import type { MessageData } from "@/lib/messages"; +import { notifyError, notifyErrorWithActionMenu, notifySuccess } from "@/lib/toast"; + +import { + findTab, + findTabByPath, + useSessionStore, + type DocumentTab, + type FolderContextLoad, +} from "../stores/session"; +import { getSessionFolderScanOptions } from "./folderContextOptions"; +import { openTransitionRunner } from "./sessionTasks"; +import { confirmDiscardTabChanges } from "./unsavedChanges"; +import { claimDocumentForWindow } from "./windowDocumentClaims"; + +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 the file to look at, in the window's preview tab. */ + preview?: boolean; +} + +/** + * Opens a file in a new tab after the active one, or activates the tab already holding it. Only a + * preview replaces another document, and only the clean one in the window's preview tab. Any + * other open of the file in the preview tab keeps that tab. + */ +export const openMarkdownFileAtPath = ( + path: string, + { + activate = true, + discardConfirmed = false, + encoding, + preview = false, + reload = false, + }: OpenMarkdownFileOptions = {}, +) => + runLatestOpenTransition(async (cancellationToken) => { + const openTab = findTabByPath(useSessionStore.getState(), path); + + if (openTab && !reload) { + activateOpenTab(openTab, preview); + recordRecentFile(openTab.document.status === "saved" ? openTab.document.path : path); + + return true; + } + + if (openTab) { + return reloadTab(openTab, { activate, discardConfirmed, encoding }, cancellationToken); + } + + const claim = await claimDocumentForWindow(path, { activate: true }); + + if (!claim) { + recordRecentFile(path); + + return true; + } + + try { + return await openClaimedMarkdownFile(path, { encoding, preview }, cancellationToken); + } finally { + claim.dispose(); + } + }); + +const activateOpenTab = (tab: DocumentTab, preview: boolean) => { + const session = useSessionStore.getState(); + + session.activateTab(tab.id); + + if (!preview) { + session.keepTab(tab.id); + } +}; + +const openClaimedMarkdownFile = async ( + path: string, + { encoding, preview }: { encoding?: TextEncodingName; preview: boolean }, + cancellationToken: CancellationToken, +) => { + const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); + const { parentFolderPath, ...documentFields } = openedDocument; + const session = useSessionStore.getState(); + const tabOpenedMeanwhile = findTabByPath(session, documentFields.path); + + if (tabOpenedMeanwhile) { + activateOpenTab(tabOpenedMeanwhile, preview); + recordRecentFile(documentFields.path); + + return true; + } + + const savedDocument = toSavedDocument(documentFields); + + if (session.folderContext || session.folderContextLoad) { + session.openTab(savedDocument, { preview }); + recordRecentFile(documentFields.path); + + return true; + } + + const { folderContextLoad } = session.openTabWithFolderContextLoad( + parentFolderPath, + savedDocument, + { preview }, + ); + recordRecentFile(documentFields.path); + void loadFolderContext(folderContextLoad); + + return true; +}; + +/** + * The editor keeps its state across a same-path update, so a reload must say when the text + * 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 { + const folderContext = await scanFolderContext(path, getSessionFolderScanOptions()); + + if (useSessionStore.getState().finishFolderContextLoad(id, folderContext)) { + recordRecentFolderSession(folderContext.path); + } + } catch (error) { + if (useSessionStore.getState().abandonFolderContextLoad(id)) { + notifyError(getScanFolderContextErrorMessage(error)); + } + } +}; + +export const notifyOpenMarkdownFileError = ( + error: unknown, + message: MessageData = getOpenMarkdownFileErrorMessage(error), +) => { + if (!isEncodingOpenError(error)) { + notifyError(message); + return; + } + + notifyErrorWithActionMenu(message, { + label: t("session.reopenWithEncoding"), + items: ENCODING_CHOICES.map((choice) => ({ + label: t(choice.labelId), + run: () => void reopenMarkdownFileWithChosenEncoding(error.path, choice.name), + })), + }); +}; + +export const reopenMarkdownFileWithChosenEncoding = async ( + path: string, + encoding: TextEncodingName, +) => { + try { + const opened = await openMarkdownFileAtPath(path, { encoding, reload: true }); + + if (opened) { + notifySuccess(t("session.documentOpened")); + } + + return opened; + } catch (error) { + notifyOpenMarkdownFileError(error); + return false; + } +}; + +export const runLatestOpenTransition = async ( + transition: (cancellationToken: CancellationToken) => Promise, +) => { + try { + return await openTransitionRunner.run(transition); + } catch (error) { + if (isCancellationError(error)) { + return false; + } + + throw error; + } +}; + +export const recordRecentFolderSession = (folderPath: string) => { + if (!useSettingsStore.getState().recordRecentItems) { + return; + } + + recordRecentFolder(folderPath); +}; + +const recordRecentFile = (filePath: string) => { + if (!useSettingsStore.getState().recordRecentItems) { + return; + } + + useRecentItemsStore.getState().recordRecentFile(filePath); +}; + +const recordRecentFolder = (folderPath: string) => { + useRecentItemsStore.getState().recordRecentFolder(folderPath); +}; + +export const notifyFolderIndexOpenFailure = (error: unknown) => { + if (!error) { + return; + } + + const indexError = getOpenMarkdownFileErrorMessage(error); + + notifyOpenMarkdownFileError(error, { + title: t("session.openFolderIndexFailed"), + description: indexError.description ?? indexError.title, + }); +}; diff --git a/src/features/session/services/documentWorkflows.ts b/src/features/session/services/documentWorkflows.ts index e83e791b..5d745106 100644 --- a/src/features/session/services/documentWorkflows.ts +++ b/src/features/session/services/documentWorkflows.ts @@ -23,7 +23,9 @@ import { } from "@/features/document"; import { scanFolderContext } from "@/features/folder-context"; import { useSettingsStore } from "@/features/preferences"; -import { SequentialTaskQueue } from "@/lib/async"; + +import { saveTaskQueue } from "./sessionTasks"; +export { runAfterPendingSaves } from "./sessionTasks"; import { requestConfirmation } from "@/lib/confirmation"; import { t } from "@/lib/i18n"; import { getPathParts, isSameOrParentPath, isSamePath } from "@/lib/path"; @@ -45,7 +47,7 @@ import { toUntitledDocumentId, } from "../utils/documentNames"; import { documentEditorBridge } from "./documentEditorBridge"; -import { getSessionFolderScanOptions } from "./folderContextWorkflows"; +import { getSessionFolderScanOptions } from "./folderContextOptions"; import { confirmDiscardTabChanges } from "./unsavedChanges"; import { claimDocumentForWindow } from "./windowDocumentClaims"; @@ -61,8 +63,6 @@ interface TabVersion { generation: number; } -const saveTaskQueue = new SequentialTaskQueue(); - const toTabVersion = ({ generation, id }: DocumentTab): TabVersion => ({ tabId: id, generation, @@ -116,8 +116,6 @@ export const closeActiveMarkdownDocument = async () => { return activeTabId ? closeDocumentTab(activeTabId) : false; }; -export const runAfterPendingSaves = (task: () => Promise) => saveTaskQueue.run(task); - export const saveDocumentTab = (tabId: string) => saveTaskQueue.run(async () => { const tab = findTab(useSessionStore.getState(), tabId); diff --git a/src/features/session/services/externalChanges.ts b/src/features/session/services/externalChanges.ts index 479de9ad..02b24165 100644 --- a/src/features/session/services/externalChanges.ts +++ b/src/features/session/services/externalChanges.ts @@ -16,8 +16,8 @@ import { notifySuccess, notifyWarning } from "@/lib/toast"; import { findCurrentTab, findTabByPath, useSessionStore } from "../stores/session"; import { documentEditorBridge } from "./documentEditorBridge"; +import { notifyOpenMarkdownFileError } from "./documentOpenWorkflows"; import { runAfterPendingSaves } from "./documentWorkflows"; -import { notifyOpenMarkdownFileError } from "./openSession"; type ExternalVersion = Extract; diff --git a/src/features/session/services/folderContextOptions.ts b/src/features/session/services/folderContextOptions.ts new file mode 100644 index 00000000..acb1fadf --- /dev/null +++ b/src/features/session/services/folderContextOptions.ts @@ -0,0 +1,11 @@ +import { useSettingsStore } from "@/features/preferences"; + +export const getSessionFolderScanOptions = () => { + const { articleSortOrder, ignoredDirectories } = useSettingsStore.getState(); + return { ignoredDirectories, sortOrder: articleSortOrder }; +}; + +export const getSessionFolderOpenOptions = () => { + const { articleSortOrder, ignoredDirectories, indexFileNames } = useSettingsStore.getState(); + return { ignoredDirectories, indexFileNames, sortOrder: articleSortOrder }; +}; diff --git a/src/features/session/services/folderContextWorkflows.ts b/src/features/session/services/folderContextWorkflows.ts index 5764976a..263ba927 100644 --- a/src/features/session/services/folderContextWorkflows.ts +++ b/src/features/session/services/folderContextWorkflows.ts @@ -2,11 +2,17 @@ import { scanFolderContext, type ArticleSortOrder } from "@/features/folder-cont import { useSettingsStore } from "@/features/preferences"; import { isSamePath } from "@/lib/path"; +import { useNamedFoldersStore } from "../stores/namedFolders"; import { useSessionStore } from "../stores/session"; +import { getSessionFolderScanOptions } from "./folderContextOptions"; +import { activateNamedFolder } from "./namedFolderWorkflows"; import { confirmDiscardAllDocumentChanges } from "./unsavedChanges"; /** Closes the folder context together with every tab, returning the window to the welcome screen. */ export const closeFolderContext = async () => { + if (useNamedFoldersStore.getState().activeId) { + return activateNamedFolder(null); + } if (!useSessionStore.getState().folderContext) { return false; } @@ -68,21 +74,4 @@ export const refreshFolderContext = async () => { } }; -export const getSessionFolderScanOptions = () => { - const { articleSortOrder, ignoredDirectories } = useSettingsStore.getState(); - - return { - ignoredDirectories, - sortOrder: articleSortOrder, - }; -}; - -export const getSessionFolderOpenOptions = () => { - const { articleSortOrder, ignoredDirectories, indexFileNames } = useSettingsStore.getState(); - - return { - ignoredDirectories, - indexFileNames, - sortOrder: articleSortOrder, - }; -}; +export { getSessionFolderScanOptions, getSessionFolderOpenOptions } from "./folderContextOptions"; diff --git a/src/features/session/services/keptSession.test.ts b/src/features/session/services/keptSession.test.ts index 2f8e9a2d..df63f28e 100644 --- a/src/features/session/services/keptSession.test.ts +++ b/src/features/session/services/keptSession.test.ts @@ -411,7 +411,8 @@ describe("kept session", () => { documentEditorBridge.fireContentChanged(TEST_MARKDOWN_FILE_PATH); await keepWindowSession(); - expect(reports[2].snapshots).toEqual([{ id: reports[0].snapshots[0].id, text: "Second" }]); + expect(reports[2].snapshots).toEqual([{ id: expect.any(String), text: "Second" }]); + expect(reports[2].snapshots[0].id).not.toBe(reports[0].snapshots[0].id); expect(getMarkdown).not.toHaveBeenCalled(); }); @@ -423,6 +424,8 @@ describe("kept session", () => { await vi.waitFor(() => expect(reports).toHaveLength(1)); expect(reports[0]).toEqual({ + contextId: null, + restoreSession: true, session: { state: expect.objectContaining({ tabs: [ diff --git a/src/features/session/services/keptSession.ts b/src/features/session/services/keptSession.ts index 644b8930..a9623500 100644 --- a/src/features/session/services/keptSession.ts +++ b/src/features/session/services/keptSession.ts @@ -16,6 +16,7 @@ import { DisposableStore, toDisposable, type Disposable } from "@/lib/lifecycle" import { getPathParts } from "@/lib/path"; import { notifyError, notifyWarning } from "@/lib/toast"; +import { useNamedFoldersStore } from "../stores/namedFolders"; import { getTabDocumentKey, useSessionStore, @@ -32,9 +33,10 @@ import { } from "../utils/keptWindowState"; import { documentEditorBridge } from "./documentEditorBridge"; import { checkDocumentFile } from "./externalChanges"; -import { getSessionFolderScanOptions } from "./folderContextWorkflows"; +import { getSessionFolderScanOptions } from "./folderContextOptions"; import { clearKeptSession, + enableKeptSession, reportWindowSession, restoreWindowSession, type SnapshotText, @@ -119,11 +121,14 @@ class KeptSessionReporter { flush() { this.changed = true; - return this.report(); + return this.report(true); } - private report() { + private report(force = false) { return this.queue.run(async () => { + if (!force && useNamedFoldersStore.getState().transitioning) { + return this.running !== null; + } if (!this.running || !this.changed) { return this.running !== null; } @@ -132,6 +137,7 @@ class KeptSessionReporter { const snapshots: SnapshotText[] = []; const snapshotTabIds: string[] = []; + const contextId = useNamedFoldersStore.getState().activeId; const session = useSessionStore.getState(); const openTabIds = new Set(session.tabs.map((tab) => tab.id)); @@ -157,7 +163,7 @@ class KeptSessionReporter { let snapshotId = this.snapshotIds.get(tab.id); if (!snapshotId || this.edited.has(tab.id)) { - snapshotId ??= crypto.randomUUID(); + snapshotId = crypto.randomUUID(); snapshotTabIds.push(tab.id); this.snapshotIds.set(tab.id, snapshotId); this.edited.delete(tab.id); @@ -170,6 +176,8 @@ class KeptSessionReporter { await reportWindowSession({ session: { state, snapshotIds: getKeptSnapshotIds(state) }, snapshots, + contextId, + restoreSession: useSettingsStore.getState().restoreSession, }); } catch (error) { this.changed = true; @@ -209,33 +217,55 @@ const reporter = new KeptSessionReporter(); const readUnsavedText = (tab: DocumentTab) => documentEditorBridge.getSettledMarkdown(getTabDocumentKey(tab)) ?? tab.document.content; -const applyRestoreSessionSetting = (restoreSession: boolean) => { - if (restoreSession) { +const applyRestoreSessionSetting = (restoreSession: boolean, clearStored = true) => { + if (restoreSession || useNamedFoldersStore.getState().activeId) { reporter.start(); - return; - } - - if (reporter.isRunning) { + } else { reporter.stop(); + } + if (!restoreSession && clearStored) { clearKeptSession().catch((error) => handleUnexpectedError(error, "clearKeptSession")); } }; /** - * Keeps the window's tabs and unsaved text while `Restore session` is on, and lets the kept session - * go when it is turned off. + * Named contexts keep reporting independently of automatic restart; disabling restart clears + * only automatic assignments and unnamed snapshots. */ export const followKeptSession = (): Disposable => { - applyRestoreSessionSetting(useSettingsStore.getState().restoreSession); + let disposed = false; + applyRestoreSessionSetting(useSettingsStore.getState().restoreSession, false); const unsubscribe = useSettingsStore.subscribe((state, previous) => { if (state.restoreSession !== previous.restoreSession) { - applyRestoreSessionSetting(state.restoreSession); + if (state.restoreSession) { + void enableKeptSession() + .then(() => { + if (!disposed && useSettingsStore.getState().restoreSession) { + reporter.start(); + void reporter.flush(); + } + }) + .catch((error) => handleUnexpectedError(error, "enableKeptSession")); + } else { + applyRestoreSessionSetting(false); + } + } + }); + const unsubscribeContext = useNamedFoldersStore.subscribe((state, previous) => { + if (state.activeId !== previous.activeId) { + if (state.activeId || useSettingsStore.getState().restoreSession) { + reporter.start(); + } else { + reporter.stop(); + } } }); return toDisposable(() => { + disposed = true; unsubscribe(); + unsubscribeContext(); reporter.stop(); }); }; @@ -245,10 +275,22 @@ export const followKeptSession = (): Disposable => { * asked about. Resolves `false` when nothing could be kept, and the window asks as usual. */ export const keepWindowSession = () => - useSettingsStore.getState().restoreSession && reporter.isRunning + (useSettingsStore.getState().restoreSession || useNamedFoldersStore.getState().activeId) && + reporter.isRunning ? reporter.flush() : Promise.resolve(false); +export const flushContextSession = () => { + reporter.start(); + return reporter.flush(); +}; +export const forgetContextSnapshots = () => { + reporter.stop(); + if (useSettingsStore.getState().restoreSession || useNamedFoldersStore.getState().activeId) { + reporter.start(); + } +}; + /** * Reopens the tabs this window held when the session was last kept. With `Restore session` off, * the first window lets any kept session go instead. @@ -257,37 +299,48 @@ export const restoreKeptWindowSession = async () => { if (!useSettingsStore.getState().restoreSession) { if (getCurrentWindow().label === MAIN_WINDOW_LABEL) { await clearKeptSession(); + return; } + } + const restored = await restoreWindowSession(); + if (!useSettingsStore.getState().restoreSession && !restored?.contextId) { return; } + if (restored) { + await restoreContextSession(restored); + } +}; - const restored = await restoreWindowSession(); +export const restoreContextSession = async ( + restored: import("./keptSessionApi").RestoredWindowSession, +) => { const state = restored && parseKeptWindowState(restored.state); if (!restored || !state) { return; } + useNamedFoldersStore.setState({ activeId: restored.contextId ?? null }); const restoredTabs: (RestoredTab & { snapshotId: string | null; claim: Disposable | null })[] = []; let activeIndex: number | null = null; - for (const [index, tab] of state.tabs.entries()) { - const restoredTab = await restoreTab(tab, restored.snapshots); + try { + for (const [index, tab] of state.tabs.entries()) { + const restoredTab = await restoreTab(tab, restored.snapshots, Boolean(restored.contextId)); + + if (!restoredTab) { + continue; + } - if (!restoredTab) { - continue; - } + if (index === state.activeTabIndex) { + activeIndex = restoredTabs.length; + } - if (index === state.activeTabIndex) { - activeIndex = restoredTabs.length; + restoredTabs.push(restoredTab); } - restoredTabs.push(restoredTab); - } - - try { const { tabs, folderContextLoad } = useSessionStore .getState() .restoreTabs(restoredTabs, activeIndex, state.folderPath); @@ -318,7 +371,11 @@ export const restoreKeptWindowSession = async () => { } }; -const restoreTab = async (tab: KeptTab, snapshots: Readonly>) => { +const restoreTab = async ( + tab: KeptTab, + snapshots: Readonly>, + reservedContext = false, +) => { const text = tab.snapshotId === null ? undefined : snapshots[tab.snapshotId]; if (tab.kind === "untitled") { @@ -341,9 +398,11 @@ const restoreTab = async (tab: KeptTab, snapshots: Readonly; } @@ -37,3 +40,4 @@ export const restoreWindowSession = () => invoke(RESTORE_WINDOW_SESSION_COMMAND); export const clearKeptSession = () => invoke(CLEAR_KEPT_SESSION_COMMAND); +export const enableKeptSession = () => invoke("enable_kept_session"); diff --git a/src/features/session/services/namedFolderWorkflows.test.ts b/src/features/session/services/namedFolderWorkflows.test.ts new file mode 100644 index 00000000..6aa6de8b --- /dev/null +++ b/src/features/session/services/namedFolderWorkflows.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { requestConfirmation } from "@/lib/confirmation"; +import type { Disposable } from "@/lib/lifecycle"; +import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; +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 { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; +import { countInvokeCalls, getLastInvokeArgs, mockInvokeCommands } from "@/test/utils/tauri"; + +import { useNamedFoldersStore } from "../stores/namedFolders"; +import { getActiveTab, useSessionStore } from "../stores/session"; +import { toKeptWindowState } from "../utils/keptWindowState"; +import { documentEditorBridge } from "./documentEditorBridge"; +import { closeFolderContext } from "./folderContextWorkflows"; +import { followKeptSession } from "./keptSession"; +import type { ReportWindowSessionArgs } from "./keptSessionApi"; +import type { NamedFolder } from "./namedFoldersApi"; +import { + activateNamedFolder, + keepNamedFolder, + locateNamedFolder, + removeNamedFolder, + renameNamedFolder, +} from "./namedFolderWorkflows"; +import { openFolderContextAtPath, openMarkdownFileAtPath } from "./openSession"; +import { confirmWindowClose } from "./windowClose"; + +vi.mock("@/lib/confirmation", () => ({ requestConfirmation: vi.fn(async () => false) })); + +const folder: NamedFolder = { + id: "personal", + name: "Personal notes", + path: TEST_NOTES_FOLDER_PATH, + windowLabel: null, + hasUnsavedDrafts: true, +}; +let following: Disposable | undefined; + +const mocks = (overrides: Record unknown> = {}) => + mockInvokeCommands({ + list_named_folders: () => ({ revision: 1, folders: [folder] }), + create_named_folder: () => undefined, + select_named_folder: () => ({ kind: "selected", session: null }), + edit_named_folder: () => undefined, + scan_markdown_folder: () => createFolderContext(), + inspect_markdown_file: () => ({ kind: "unchanged" }), + ...overrides, + }); + +beforeEach(() => { + documentEditorBridge.clear(); + vi.mocked(requestConfirmation).mockReset().mockResolvedValue(false); + mocks(); +}); +afterEach(() => { + following ??= followKeptSession(); + following.dispose(); + following = undefined; + documentEditorBridge.clear(); +}); + +describe("named folder workflows", () => { + it("registers the current root and flushes latest editor text before naming it", async () => { + setDefaultSession({ + folderContext: createFolderContext(), + activeDocument: createSavedDocument({ isDirty: true, content: "Old" }), + }); + documentEditorBridge.set( + TEST_MARKDOWN_FILE_PATH, + createMilkdownEditorBridge({ getSettledMarkdown: () => "Latest unsaved text" }), + ); + following = followKeptSession(); + expect(await keepNamedFolder(" Personal notes ")).toBe(true); + expect(getLastInvokeArgs("report_window_session")).toMatchObject({ + snapshots: expect.any(Array), + contextId: null, + }); + expect(getLastInvokeArgs("create_named_folder")).toEqual({ + id: expect.any(String), + name: "Personal notes", + path: TEST_NOTES_FOLDER_PATH, + }); + expect(useSessionStore.getState().activeDocument?.isDirty).toBe(true); + expect(documentEditorBridge.getSettledMarkdown(TEST_MARKDOWN_FILE_PATH)).toBe( + "Latest unsaved text", + ); + expect(useNamedFoldersStore.getState().activeId).toBeTruthy(); + expect(countInvokeCalls("save_markdown_file")).toBe(0); + }); + + it("restores distinct dirty saved and untitled tabs in order, retaining their active tab", async () => { + setDefaultSession({ + folderContext: createFolderContext(), + documents: [ + createSavedDocument({ isDirty: true, content: "Saved draft" }), + createUntitledDocument({ id: "untitled:42", isDirty: true, content: "Untitled draft" }), + ], + }); + const original = useSessionStore.getState(); + const state = toKeptWindowState(original, (tab) => ({ + snapshotId: tab.document.status === "saved" ? "saved" : "untitled", + viewState: null, + })); + state.activeTabIndex = 1; + documentEditorBridge.set( + "untitled:42", + createMilkdownEditorBridge({ getSettledMarkdown: () => "Previous context editor" }), + ); + useNamedFoldersStore.setState({ activeId: "studies", folders: [folder] }); + mocks({ + select_named_folder: () => ({ + kind: "selected", + session: { + contextId: folder.id, + state, + snapshots: { saved: "Restored saved", untitled: "Restored untitled" }, + }, + }), + }); + following = followKeptSession(); + expect(await activateNamedFolder(folder.id)).toBe(true); + expect(useSessionStore.getState().tabs.map((tab) => tab.document.content)).toEqual([ + "Restored saved", + "Restored untitled", + ]); + expect(getActiveTab(useSessionStore.getState())?.document.content).toBe("Restored untitled"); + expect(useSessionStore.getState().tabs.every((tab) => tab.document.isDirty)).toBe(true); + expect(countInvokeCalls("open_markdown_folder")).toBe(0); + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(await confirmWindowClose({ keepsSession: true })).toBe(true); + expect( + getLastInvokeArgs("report_window_session").snapshots.map( + (snapshot) => snapshot.text, + ), + ).toContain("Restored untitled"); + expect( + getLastInvokeArgs("report_window_session").snapshots.map( + (snapshot) => snapshot.text, + ), + ).not.toContain("Previous context editor"); + }); + + it("failed parking stops the switch and retains the current tabs and editors", async () => { + const silence = vi.spyOn(console, "error").mockImplementation(() => undefined); + setDefaultSession({ + activeDocument: createUntitledDocument({ isDirty: true, content: "Do not lose" }), + }); + const tabs = useSessionStore.getState().tabs; + useNamedFoldersStore.setState({ activeId: "studies" }); + mocks({ + report_window_session: () => { + throw { kind: "writeFailed", message: "Disk full" }; + }, + }); + following = followKeptSession(); + expect(await activateNamedFolder(folder.id)).toBe(false); + expect(countInvokeCalls("select_named_folder")).toBe(0); + expect(useSessionStore.getState().tabs).toBe(tabs); + expect(useNamedFoldersStore.getState()).toMatchObject({ + activeId: "studies", + transitioning: false, + }); + silence.mockRestore(); + }); + + it("resolves an unnamed session before entering a named one", async () => { + setDefaultSession({ activeDocument: createUntitledDocument({ isDirty: true }) }); + expect(await activateNamedFolder(folder.id)).toBe(false); + expect(countInvokeCalls("select_named_folder")).toBe(0); + vi.mocked(requestConfirmation).mockResolvedValue(true); + expect(await activateNamedFolder(folder.id)).toBe(true); + expect(useSessionStore.getState().tabs).toEqual([]); + }); + + it("focuses an active context elsewhere without resolving or releasing current work", async () => { + useNamedFoldersStore.setState({ folders: [{ ...folder, windowLabel: "window-2" }] }); + setDefaultSession({ activeDocument: createUntitledDocument({ isDirty: true }) }); + const tabs = useSessionStore.getState().tabs; + mocks({ select_named_folder: () => ({ kind: "focused" }) }); + expect(await activateNamedFolder(folder.id)).toBe(true); + expect(useSessionStore.getState().tabs).toBe(tabs); + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it("opens a parked context in a new window without parking this window", async () => { + setDefaultSession({ activeDocument: createUntitledDocument({ isDirty: true }) }); + expect(await activateNamedFolder(folder.id, true)).toBe(true); + expect(getLastInvokeArgs("select_named_folder")).toEqual({ + id: folder.id, + newWindow: true, + documentPath: null, + }); + expect(useSessionStore.getState().tabs).toHaveLength(1); + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it("keeps named drafts when closing a secondary window with Restore session off", async () => { + setDefaultSettings({ restoreSession: false }); + useNamedFoldersStore.setState({ activeId: folder.id }); + setDefaultSession({ + activeDocument: createUntitledDocument({ isDirty: true, content: "Named draft" }), + }); + following = followKeptSession(); + expect(await confirmWindowClose({ keepsSession: false })).toBe(true); + expect(getLastInvokeArgs("report_window_session")).toMatchObject({ + contextId: folder.id, + restoreSession: false, + }); + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it("retains a named window on failed close persistence and during a transition", async () => { + const silence = vi.spyOn(console, "error").mockImplementation(() => undefined); + useNamedFoldersStore.setState({ activeId: folder.id }); + mocks({ + report_window_session: () => { + throw { kind: "writeFailed" }; + }, + }); + following = followKeptSession(); + expect(await confirmWindowClose({ keepsSession: true })).toBe(false); + useNamedFoldersStore.setState({ transitioning: true }); + expect(await confirmWindowClose({ keepsSession: true })).toBe(false); + silence.mockRestore(); + }); + + it("Close Folder parks a named context without discarding its drafts", async () => { + useNamedFoldersStore.setState({ activeId: folder.id }); + setDefaultSession({ activeDocument: createUntitledDocument({ isDirty: true }) }); + expect(await closeFolderContext()).toBe(true); + expect(getLastInvokeArgs("select_named_folder")).toMatchObject({ id: null }); + expect(useNamedFoldersStore.getState().activeId).toBeNull(); + expect(useSessionStore.getState().tabs).toHaveLength(0); + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it("Open Folder leaves a named context parked and does not rebind it", async () => { + useNamedFoldersStore.setState({ activeId: folder.id }); + mocks({ + open_markdown_folder: () => ({ + folder: createFolderContext(), + indexDocument: null, + indexError: null, + }), + }); + expect(await openFolderContextAtPath(TEST_NOTES_FOLDER_PATH)).toBe(true); + expect(countInvokeCalls("select_named_folder")).toBe(1); + expect(countInvokeCalls("edit_named_folder")).toBe(0); + expect(useNamedFoldersStore.getState().activeId).toBeNull(); + }); + + it("opening a parked file restores its owner rather than reading a second editable copy", async () => { + mocks({ + claim_window_document: () => ({ kind: "ownedByContext", contextId: folder.id }), + select_named_folder: () => ({ kind: "selected", session: null }), + }); + expect(await openMarkdownFileAtPath(TEST_MARKDOWN_FILE_PATH)).toBe(true); + expect(getLastInvokeArgs("select_named_folder")).toMatchObject({ + id: folder.id, + documentPath: TEST_MARKDOWN_FILE_PATH, + }); + expect(countInvokeCalls("open_markdown_file")).toBe(0); + }); + + it("requires confirmation to remove drafts and passes the confirmed revision", async () => { + useNamedFoldersStore.setState({ revision: 12 }); + await removeNamedFolder(folder); + expect(countInvokeCalls("edit_named_folder")).toBe(0); + vi.mocked(requestConfirmation).mockResolvedValue(true); + await removeNamedFolder(folder); + expect(getLastInvokeArgs("edit_named_folder")).toEqual({ + id: folder.id, + changes: { remove: true, discardUnsaved: true, expectedRevision: 12 }, + }); + }); + + it("renames only the display label", async () => { + await renameNamedFolder(folder.id, "Studies"); + expect(getLastInvokeArgs("edit_named_folder")).toEqual({ + id: folder.id, + changes: { name: "Studies" }, + }); + }); + + it("locating a folder can be cancelled", async () => { + await locateNamedFolder(folder.id); + expect(countInvokeCalls("edit_named_folder")).toBe(0); + }); +}); diff --git a/src/features/session/services/namedFolderWorkflows.ts b/src/features/session/services/namedFolderWorkflows.ts new file mode 100644 index 00000000..9e35d1cb --- /dev/null +++ b/src/features/session/services/namedFolderWorkflows.ts @@ -0,0 +1,174 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + +import { selectFolderContextPath } from "@/features/folder-context"; +import { SequentialTaskQueue } from "@/lib/async"; +import { requestConfirmation } from "@/lib/confirmation"; +import { notifyOperationFailure } from "@/lib/errors"; +import { t } from "@/lib/i18n"; +import { notifyError } from "@/lib/toast"; + +import { useNamedFoldersStore } from "../stores/namedFolders"; +import { findTabByPath, getSessionFolderPath, useSessionStore } from "../stores/session"; +import { documentEditorBridge } from "./documentEditorBridge"; +import { flushContextSession, forgetContextSnapshots, restoreContextSession } from "./keptSession"; +import { + createNamedFolder, + editNamedFolder, + listNamedFolders, + selectNamedFolder, + type NamedFolder, +} from "./namedFoldersApi"; +import { cancelOpenSessionTransitions, runAfterPendingSaves } from "./sessionTasks"; +import { confirmDiscardAllDocumentChanges } from "./unsavedChanges"; + +const transitions = new SequentialTaskQueue(); + +export const refreshNamedFolders = async () => { + useNamedFoldersStore.getState().applySnapshot(await listNamedFolders()); +}; + +export const showNamedFolders = () => useNamedFoldersStore.setState({ dialogOpen: true }); + +export const runNamedFolderAction = async (action: () => Promise) => { + try { + await action(); + } catch (error) { + if (error && typeof error === "object" && "kind" in error) { + const kind = error.kind; + if (kind === "contextBusy" || kind === "unsavedDrafts" || kind === "invalidContext") { + notifyError({ + title: t( + kind === "contextBusy" + ? "session.namedFolders.busy" + : kind === "unsavedDrafts" + ? "session.namedFolders.unsaved" + : "session.namedFolders.failed", + ), + }); + return; + } + } + notifyOperationFailure(t("session.namedFolders.failed"), error, "namedFolderAction"); + } +}; + +export const keepNamedFolder = (name: string) => + transitions.run(async () => { + const path = getSessionFolderPath(useSessionStore.getState()); + if (!path || useNamedFoldersStore.getState().activeId || !name.trim()) { + return false; + } + useNamedFoldersStore.setState({ transitioning: true }); + cancelOpenSessionTransitions(); + try { + return await runAfterPendingSaves(async () => { + if (!(await flushContextSession())) { + return false; + } + const id = crypto.randomUUID(); + await createNamedFolder(id, name.trim(), path); + useNamedFoldersStore.setState({ activeId: id }); + await refreshNamedFolders(); + return true; + }); + } finally { + useNamedFoldersStore.setState({ transitioning: false }); + } + }); + +export const activateNamedFolder = ( + id: string | null, + newWindow = false, + documentPath?: string, +): Promise => + transitions.run(async () => { + const state = useNamedFoldersStore.getState(); + const target = state.folders.find((folder) => folder.id === id); + if (id === state.activeId && !newWindow) { + activateOwnedTab(documentPath); + return true; + } + const elsewhere = target?.windowLabel && target.windowLabel !== getCurrentWindow().label; + if ( + !newWindow && + !elsewhere && + !state.activeId && + !(await confirmDiscardAllDocumentChanges()) + ) { + return false; + } + useNamedFoldersStore.setState({ transitioning: true }); + cancelOpenSessionTransitions(); + try { + return await runAfterPendingSaves(async () => { + if (!newWindow && !elsewhere && !(await flushContextSession())) { + return false; + } + const selected = await selectNamedFolder(id, newWindow, documentPath); + if (selected.kind === "focused" || newWindow) { + return true; + } + documentEditorBridge.clear(); + useNamedFoldersStore.setState({ activeId: id }); + useSessionStore.getState().reset(); + if (selected.session) { + await restoreContextSession(selected.session); + } + forgetContextSnapshots(); + activateOwnedTab(documentPath); + await refreshNamedFolders(); + return true; + }); + } finally { + useNamedFoldersStore.setState({ transitioning: false }); + } + }); + +const activateOwnedTab = (path?: string) => { + if (!path) { + return; + } + const tab = findTabByPath(useSessionStore.getState(), path); + if (tab) { + useSessionStore.getState().activateTab(tab.id); + } +}; + +export const renameNamedFolder = async (id: string, name: string) => { + await editNamedFolder(id, { name }); + await refreshNamedFolders(); +}; + +export const locateNamedFolder = async (id: string) => { + const path = await selectFolderContextPath(); + if (!path) { + return; + } + await editNamedFolder(id, { path }); + await refreshNamedFolders(); +}; + +export const removeNamedFolder = async (folder: NamedFolder) => { + const expectedRevision = useNamedFoldersStore.getState().revision; + if ( + !(await requestConfirmation({ + title: t("session.namedFolders.removeTitle"), + message: t( + folder.hasUnsavedDrafts + ? "session.namedFolders.removeDrafts" + : "session.namedFolders.removeMessage", + { name: folder.name }, + ), + confirmLabel: t("session.namedFolders.remove"), + cancelLabel: t("session.namedFolders.cancel"), + })) + ) { + return; + } + await editNamedFolder(folder.id, { + remove: true, + discardUnsaved: folder.hasUnsavedDrafts, + expectedRevision, + }); + await refreshNamedFolders(); +}; diff --git a/src/features/session/services/namedFoldersApi.ts b/src/features/session/services/namedFoldersApi.ts new file mode 100644 index 00000000..f8fa3fc0 --- /dev/null +++ b/src/features/session/services/namedFoldersApi.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { RestoredWindowSession } from "./keptSessionApi"; + +export const NAMED_FOLDERS_CHANGED_EVENT = "leafdown://named-folders-changed"; + +export interface NamedFolder { + id: string; + name: string; + path: string; + windowLabel: string | null; + hasUnsavedDrafts: boolean; +} + +export interface NamedFoldersSnapshot { + revision: number; + folders: NamedFolder[]; +} + +export const listNamedFolders = () => invoke("list_named_folders"); +export const createNamedFolder = (id: string, name: string, path: string) => + invoke("create_named_folder", { id, name, path }); +export const editNamedFolder = ( + id: string, + changes: { + name?: string; + path?: string; + remove?: boolean; + discardUnsaved?: boolean; + expectedRevision?: number; + }, +) => + invoke("edit_named_folder", { + id, + changes: { ...changes }, + }); +export const selectNamedFolder = (id: string | null, newWindow = false, documentPath?: string) => + invoke<{ kind: "focused" } | { kind: "selected"; session: RestoredWindowSession | null }>( + "select_named_folder", + { id, newWindow, documentPath: documentPath ?? null }, + ); diff --git a/src/features/session/services/openSession.ts b/src/features/session/services/openSession.ts index 7d7f5035..1c4d1ef5 100644 --- a/src/features/session/services/openSession.ts +++ b/src/features/session/services/openSession.ts @@ -1,250 +1,38 @@ -import { - ENCODING_CHOICES, - getOpenMarkdownFileErrorMessage, - isEncodingOpenError, - matchesActiveDocumentKey, - openMarkdownDocument, - selectMarkdownFilePath, - toSavedDocument, - type TextEncodingName, -} from "@/features/document"; -import { - getScanFolderContextErrorMessage, - openFolderContext, - scanFolderContext, - selectFolderContextPath, -} from "@/features/folder-context"; -import { useRecentItemsStore, useSettingsStore } from "@/features/preferences"; -import { RestartableTaskRunner } from "@/lib/async"; -import { - type CancellationToken, - isCancellationError, - runWithCancellation, - throwIfCancelled, -} from "@/lib/cancellation"; -import { t } from "@/lib/i18n"; -import type { MessageData } from "@/lib/messages"; -import { notifyError, notifyErrorWithActionMenu, notifySuccess } from "@/lib/toast"; +import { selectMarkdownFilePath, toSavedDocument } from "@/features/document"; +import { openFolderContext, selectFolderContextPath } from "@/features/folder-context"; +import { throwIfCancelled } from "@/lib/cancellation"; +import { useNamedFoldersStore } from "../stores/namedFolders"; +import { findTabByPath, useSessionStore } from "../stores/session"; import { - findTab, - findTabByPath, - useSessionStore, - type DocumentTab, - type FolderContextLoad, -} from "../stores/session"; -import { getSessionFolderOpenOptions, getSessionFolderScanOptions } from "./folderContextWorkflows"; + notifyOpenMarkdownFileError, + notifyFolderIndexOpenFailure, + recordRecentFolderSession, + runLatestOpenTransition, + openMarkdownFileAtPath as openDocument, +} from "./documentOpenWorkflows"; +import { getSessionFolderOpenOptions } from "./folderContextOptions"; import { takeLaunchDocumentPaths } from "./launchDocumentApi"; -import { confirmDiscardTabChanges } from "./unsavedChanges"; -import { claimDocumentForWindow } from "./windowDocumentClaims"; - -const openTransitionRunner = new RestartableTaskRunner(); +import { activateNamedFolder } from "./namedFolderWorkflows"; +import { claimDocumentForWindow, DocumentOwnedByContext } from "./windowDocumentClaims"; +export { + notifyOpenMarkdownFileError, + reopenMarkdownFileWithChosenEncoding, +} from "./documentOpenWorkflows"; let launchDocumentsOpened: Promise = Promise.resolve(); - -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 the file to look at, in the window's preview tab. */ - preview?: boolean; -} - -/** - * Opens a file in a new tab after the active one, or activates the tab already holding it. Only a - * preview replaces another document, and only the clean one in the window's preview tab. Any - * other open of the file in the preview tab keeps that tab. - */ -export const openMarkdownFileAtPath = ( - path: string, - { - activate = true, - discardConfirmed = false, - encoding, - preview = false, - reload = false, - }: OpenMarkdownFileOptions = {}, -) => - runLatestOpenTransition(async (cancellationToken) => { - const openTab = findTabByPath(useSessionStore.getState(), path); - - if (openTab && !reload) { - activateOpenTab(openTab, preview); - recordRecentFile(openTab.document.status === "saved" ? openTab.document.path : path); - - return true; - } - - if (openTab) { - return reloadTab(openTab, { activate, discardConfirmed, encoding }, cancellationToken); - } - - const claim = await claimDocumentForWindow(path, { activate: true }); - - if (!claim) { - recordRecentFile(path); - - return true; - } - - try { - return await openClaimedMarkdownFile(path, { encoding, preview }, cancellationToken); - } finally { - claim.dispose(); - } - }); - -const activateOpenTab = (tab: DocumentTab, preview: boolean) => { - const session = useSessionStore.getState(); - - session.activateTab(tab.id); - - if (!preview) { - session.keepTab(tab.id); - } -}; - -const openClaimedMarkdownFile = async ( - path: string, - { encoding, preview }: { encoding?: TextEncodingName; preview: boolean }, - cancellationToken: CancellationToken, -) => { - const openedDocument = await openMarkdownDocument(path, cancellationToken, encoding); - const { parentFolderPath, ...documentFields } = openedDocument; - const session = useSessionStore.getState(); - const tabOpenedMeanwhile = findTabByPath(session, documentFields.path); - - if (tabOpenedMeanwhile) { - activateOpenTab(tabOpenedMeanwhile, preview); - recordRecentFile(documentFields.path); - - return true; - } - - const savedDocument = toSavedDocument(documentFields); - - if (session.folderContext || session.folderContextLoad) { - session.openTab(savedDocument, { preview }); - recordRecentFile(documentFields.path); - - return true; - } - - const { folderContextLoad } = session.openTabWithFolderContextLoad( - parentFolderPath, - savedDocument, - { preview }, - ); - recordRecentFile(documentFields.path); - void loadFolderContext(folderContextLoad); - - return true; -}; - -/** - * The editor keeps its state across a same-path update, so a reload must say when the text - * 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]))) - ) { +export const openMarkdownFileAtPath = async (...args: Parameters) => { + if (useNamedFoldersStore.getState().transitioning) { 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 { - const folderContext = await scanFolderContext(path, getSessionFolderScanOptions()); - - if (useSessionStore.getState().finishFolderContextLoad(id, folderContext)) { - recordRecentFolderSession(folderContext.path); - } + return await openDocument(...args); } catch (error) { - if (useSessionStore.getState().abandonFolderContextLoad(id)) { - notifyError(getScanFolderContextErrorMessage(error)); + if (error instanceof DocumentOwnedByContext) { + return activateNamedFolder(error.contextId, false, error.path); } + throw error; } }; - -export const notifyOpenMarkdownFileError = ( - error: unknown, - message: MessageData = getOpenMarkdownFileErrorMessage(error), -) => { - if (!isEncodingOpenError(error)) { - notifyError(message); - return; - } - - notifyErrorWithActionMenu(message, { - label: t("session.reopenWithEncoding"), - items: ENCODING_CHOICES.map((choice) => ({ - label: t(choice.labelId), - run: () => void reopenMarkdownFileWithChosenEncoding(error.path, choice.name), - })), - }); -}; - -export const reopenMarkdownFileWithChosenEncoding = async ( - path: string, - encoding: TextEncodingName, -) => { - try { - const opened = await openMarkdownFileAtPath(path, { encoding, reload: true }); - - if (opened) { - notifySuccess(t("session.documentOpened")); - } - - return opened; - } catch (error) { - notifyOpenMarkdownFileError(error); - return false; - } -}; - /** * Opens the files launches asked this window to open, once persisted stores are ready: the file * the process started with, then those later launches route here. Each waits for the one before @@ -290,8 +78,14 @@ export const pickAndOpenMarkdownFile = async () => { * preview tab. An index file open in another window stays there, and the folder opens here * without it. */ -export const openFolderContextAtPath = (path: string) => - runLatestOpenTransition(async (cancellationToken) => { +export const openFolderContextAtPath = async (path: string) => { + if (useNamedFoldersStore.getState().transitioning) { + return false; + } + if (useNamedFoldersStore.getState().activeId && !(await activateNamedFolder(null))) { + return false; + } + return runLatestOpenTransition(async (cancellationToken) => { const { folderContext, indexDocument, indexError } = await openFolderContext( path, getSessionFolderOpenOptions(), @@ -325,6 +119,7 @@ export const openFolderContextAtPath = (path: string) => return true; }); +}; export const pickAndOpenFolderContext = async () => { const selectedPath = await selectFolderContextPath(); @@ -335,50 +130,3 @@ export const pickAndOpenFolderContext = async () => { return openFolderContextAtPath(selectedPath); }; - -const runLatestOpenTransition = async ( - transition: (cancellationToken: CancellationToken) => Promise, -) => { - try { - return await openTransitionRunner.run(transition); - } catch (error) { - if (isCancellationError(error)) { - return false; - } - - throw error; - } -}; - -const recordRecentFolderSession = (folderPath: string) => { - if (!useSettingsStore.getState().recordRecentItems) { - return; - } - - recordRecentFolder(folderPath); -}; - -const recordRecentFile = (filePath: string) => { - if (!useSettingsStore.getState().recordRecentItems) { - return; - } - - useRecentItemsStore.getState().recordRecentFile(filePath); -}; - -const recordRecentFolder = (folderPath: string) => { - useRecentItemsStore.getState().recordRecentFolder(folderPath); -}; - -const notifyFolderIndexOpenFailure = (error: unknown) => { - if (!error) { - return; - } - - const indexError = getOpenMarkdownFileErrorMessage(error); - - notifyOpenMarkdownFileError(error, { - title: t("session.openFolderIndexFailed"), - description: indexError.description ?? indexError.title, - }); -}; diff --git a/src/features/session/services/sessionTasks.ts b/src/features/session/services/sessionTasks.ts new file mode 100644 index 00000000..ac8cf752 --- /dev/null +++ b/src/features/session/services/sessionTasks.ts @@ -0,0 +1,6 @@ +import { RestartableTaskRunner, SequentialTaskQueue } from "@/lib/async"; + +export const saveTaskQueue = new SequentialTaskQueue(); +export const runAfterPendingSaves = (task: () => Promise) => saveTaskQueue.run(task); +export const openTransitionRunner = new RestartableTaskRunner(); +export const cancelOpenSessionTransitions = () => openTransitionRunner.cancel(); diff --git a/src/features/session/services/windowClose.ts b/src/features/session/services/windowClose.ts index d3d28973..f7541ecf 100644 --- a/src/features/session/services/windowClose.ts +++ b/src/features/session/services/windowClose.ts @@ -2,6 +2,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { handleUnexpectedError } from "@/lib/errors"; +import { useNamedFoldersStore } from "../stores/namedFolders"; import { useSessionStore } from "../stores/session"; import { keepWindowSession } from "./keptSession"; import { confirmDiscardAllDocumentChanges } from "./unsavedChanges"; @@ -17,6 +18,12 @@ export interface WindowCloseRequest { * so a window with something to ask comes forward first; the others close unseen. */ export const confirmWindowClose = async ({ keepsSession }: WindowCloseRequest) => { + if (useNamedFoldersStore.getState().transitioning) { + return false; + } + if (useNamedFoldersStore.getState().activeId) { + return keepWindowSession(); + } if (keepsSession && (await keepWindowSession())) { return true; } diff --git a/src/features/session/services/windowDocumentClaims.ts b/src/features/session/services/windowDocumentClaims.ts index 6196edb4..e8ce08f4 100644 --- a/src/features/session/services/windowDocumentClaims.ts +++ b/src/features/session/services/windowDocumentClaims.ts @@ -3,6 +3,7 @@ import { DisposableStore, toDisposable, type Disposable } from "@/lib/lifecycle" import { PathSet } from "@/lib/path"; import { SignalSource } from "@/lib/signal"; +import { useNamedFoldersStore } from "../stores/namedFolders"; import { useSessionStore, type SessionState } from "../stores/session"; import { claimWindowDocument, setWindowDocuments, type WindowDocument } from "./windowDocumentsApi"; @@ -13,6 +14,15 @@ interface PendingClaim { const pendingClaims = new Set(); const claimReleased = new SignalSource(); +export class DocumentOwnedByContext extends Error { + constructor( + readonly contextId: string, + readonly path: string, + ) { + super("Document belongs to another context."); + } +} + /** * Claims a saved document for this window before it opens or saves to it, so another window cannot * open the same file meanwhile. Resolves `null` when another window holds the file, which then @@ -25,6 +35,13 @@ export const claimDocumentForWindow = async ( ): Promise => { const result = await claimWindowDocument(path, activate); + if (result.kind === "ownedByContext") { + if (activate) { + throw new DocumentOwnedByContext(result.contextId, path); + } + return null; + } + if (result.kind === "openElsewhere") { return null; } @@ -77,6 +94,16 @@ export class WindowDocumentsSync implements Disposable { private isDisposed = false; constructor() { + this.subscriptions.add( + toDisposable( + useNamedFoldersStore.subscribe((state, previous) => { + if (!state.transitioning && previous.transitioning) { + this.resendRequested = true; + void this.send(); + } + }), + ), + ); this.subscriptions.add( useSessionStore.subscribe((state, previous) => { if (state.tabs !== previous.tabs) { @@ -108,6 +135,9 @@ export class WindowDocumentsSync implements Disposable { try { while (!this.isDisposed) { + if (useNamedFoldersStore.getState().transitioning) { + return; + } const documents = collectWindowDocuments(useSessionStore.getState()); const key = JSON.stringify(documents); diff --git a/src/features/session/services/windowDocumentsApi.ts b/src/features/session/services/windowDocumentsApi.ts index bca8b8ce..61759c90 100644 --- a/src/features/session/services/windowDocumentsApi.ts +++ b/src/features/session/services/windowDocumentsApi.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; +import { useNamedFoldersStore } from "../stores/namedFolders"; + export const SET_WINDOW_DOCUMENTS_COMMAND = "set_window_documents"; export const CLAIM_WINDOW_DOCUMENT_COMMAND = "claim_window_document"; export const NOTIFY_DOCUMENTS_MOVED_COMMAND = "notify_documents_moved"; @@ -20,10 +22,14 @@ export interface WindowDocumentsSnapshot { windows: { windowLabel: string; documents: WindowDocument[] }[]; } -export type ClaimWindowDocumentResult = { kind: "claimed" } | { kind: "openElsewhere" }; +export type ClaimWindowDocumentResult = + | { kind: "claimed" } + | { kind: "openElsewhere" } + | { kind: "ownedByContext"; contextId: string }; export interface SetWindowDocumentsArgs { documents: WindowDocument[]; + contextId?: string | null; } export interface ClaimWindowDocumentArgs { @@ -43,6 +49,9 @@ export interface DocumentPathPayload { export const setWindowDocuments = (documents: WindowDocument[]) => invoke(SET_WINDOW_DOCUMENTS_COMMAND, { documents, + ...(useNamedFoldersStore.getState().activeId && { + contextId: useNamedFoldersStore.getState().activeId, + }), } satisfies SetWindowDocumentsArgs); export const claimWindowDocument = (path: string, activate: boolean) => diff --git a/src/features/session/stores/namedFolders.ts b/src/features/session/stores/namedFolders.ts new file mode 100644 index 00000000..6eea21c8 --- /dev/null +++ b/src/features/session/stores/namedFolders.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +import type { NamedFolder, NamedFoldersSnapshot } from "../services/namedFoldersApi"; + +interface NamedFoldersState { + folders: NamedFolder[]; + revision: number; + activeId: string | null; + transitioning: boolean; + dialogOpen: boolean; + applySnapshot: (snapshot: NamedFoldersSnapshot) => void; +} + +export const useNamedFoldersStore = create((set) => ({ + folders: [], + revision: -1, + activeId: null, + transitioning: false, + dialogOpen: false, + applySnapshot: (snapshot) => + set((state) => + snapshot.revision < state.revision + ? state + : { + folders: snapshot.folders, + revision: snapshot.revision, + }, + ), +})); diff --git a/src/locales/en.json b/src/locales/en.json index 34380485..b0258463 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -33,7 +33,7 @@ "preferences.insertFinalNewline.label": "Insert final newline on save", "preferences.insertFinalNewline.description": "Ends the saved file with a line break.", "preferences.restoreSession.label": "Restore session", - "preferences.restoreSession.description": "Reopens windows, tabs, and unsaved changes at the next launch, so quitting does not ask about them.", + "preferences.restoreSession.description": "Reopen the windows left open at quit or crash. Turning this off clears automatic restart state; named folders and their unsaved drafts remain available.", "preferences.whenDroppingFolder.label": "When dropping a folder", "preferences.whenDroppingMarkdownFile.label": "When dropping a Markdown file", "preferences.dropBehavior.open": "Open", @@ -830,5 +830,24 @@ "diagnostics.copySummary": "Copy summary", "diagnostics.summaryCopied": "Diagnostics summary copied.", "diagnostics.copySummaryFailed": "Could not copy diagnostics summary.", - "diagnostics.openLogsFolderFailed": "Could not open logs folder." + "diagnostics.openLogsFolderFailed": "Could not open logs folder.", + "command.file.namedFolders": "Named folders…", + "session.namedFolders.title": "Named folders", + "session.namedFolders.description": "Keep an ordinary folder with its own tabs and unsaved drafts. Switching parks your current named folder. To leave an unnamed session, resolve its unsaved changes first.", + "session.namedFolders.name": "Display name", + "session.namedFolders.chooseFirst": "Choose a folder to keep with its tabs.", + "session.namedFolders.choose": "Choose folder…", + "session.namedFolders.keep": "Keep this folder", + "session.namedFolders.park": "Park this folder", + "session.namedFolders.rename": "Rename", + "session.namedFolders.locate": "Locate folder…", + "session.namedFolders.remove": "Remove", + "session.namedFolders.newWindow": "Open in new window", + "session.namedFolders.removeTitle": "Remove named folder?", + "session.namedFolders.removeMessage": "Remove “{name}” from named folders? Its folder and files will remain on disk.", + "session.namedFolders.removeDrafts": "Remove “{name}” and discard its unsaved drafts? Its folder and saved files will remain on disk.", + "session.namedFolders.failed": "Could not update named folders.", + "session.namedFolders.busy": "This folder is active in a window. Park it before locating or removing it.", + "session.namedFolders.unsaved": "This folder has unsaved drafts. Review it before removing it.", + "session.namedFolders.cancel": "Cancel" } diff --git a/src/test/utils/appStores.ts b/src/test/utils/appStores.ts index 3b05d9ca..2286b8aa 100644 --- a/src/test/utils/appStores.ts +++ b/src/test/utils/appStores.ts @@ -17,6 +17,7 @@ import { type SettingsPersistedState, } from "@/features/preferences"; import { RELEASE_NOTES_STATE_VERSION, useReleaseNotesStore } from "@/features/release-notes"; +import { useNamedFoldersStore } from "@/features/session/stores/namedFolders"; import { useOtherWindowDocumentsStore } from "@/features/session/stores/otherWindowDocuments"; // Deep import by design: `@/features/session` re-exports `documentEditorBridge`, and the // beforeEach in ../setup/common.ts loads this module for every test file. @@ -131,6 +132,7 @@ export const setDefaultUI = (ui: Partial = {}) => { }; export const resetAppStores = () => { + useNamedFoldersStore.setState(useNamedFoldersStore.getInitialState()); setDefaultSettings(); setDefaultRecentItems(); setDefaultSession(); diff --git a/src/test/utils/tauri.ts b/src/test/utils/tauri.ts index cd9e2263..1f99b162 100644 --- a/src/test/utils/tauri.ts +++ b/src/test/utils/tauri.ts @@ -73,6 +73,8 @@ export const getWindowDragDropHandler = () => { // kept for the next launch, so a test of one window finds it the only window, with no session kept // before it, unless it replaces these. const ONLY_WINDOW_HANDLERS: Record = { + list_named_folders: () => ({ revision: 0, folders: [] }), + enable_kept_session: () => undefined, [SET_WINDOW_DOCUMENTS_COMMAND]: () => undefined, [CLAIM_WINDOW_DOCUMENT_COMMAND]: () => ({ kind: "claimed" }), [NOTIFY_DOCUMENTS_MOVED_COMMAND]: () => undefined,