Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0

- Open several documents in one window as tabs. Opening a file, a recent file, an article in the navigator, a link, a search result, or a drop adds a tab after the one you are on, or switches to the tab already holding that file, so opening no longer replaces your document or asks about its unsaved changes. Each tab keeps its own unsaved edits, undo history, place in the document, and search. Switch with a click, `Ctrl+Tab` and `Ctrl+Shift+Tab`, `Ctrl+PageDown` and `Ctrl+PageUp`, or `Ctrl+1` to `Ctrl+9`; close one with `Ctrl+W`, its close button, or a middle click. Closing the window or the folder asks once about every tab with unsaved changes, Find in folder and Replace in folder include the unsaved text of every open tab, and the window's title in the taskbar names the active document.
- Open more than one Leafdown window with File > New window (`Ctrl+Shift+N`), to keep two documents side by side or two folders open at once. Each window has its own tabs, folder, navigator, folder search, and zoom, while settings, recent items, and Always on top apply to every window. A file is open in only one window at a time: opening it from another window switches to its tab and brings its window forward. Renaming a file moves its tabs in every window with their unsaved edits, deleting it leaves other windows' tabs open with the file shown as missing, Save as refuses a file another window has open, and Replace in folder leaves alone a file another window has unsaved edits in. Closing a window asks only about its own unsaved edits, and closing the last one quits Leafdown. File > Quit (`Ctrl+Q`, which no longer closes just the window) closes every window in turn, bringing each one with unsaved edits forward to ask; canceling there keeps that window and the rest open. The first window still reopens where you left it.
- Open files from File Explorer in the Leafdown that is already running. A file opened while Leafdown runs becomes a new tab in the window you used last, which comes to the front, or switches to its tab if it is already open; starting Leafdown again without a file opens a new window. Each file used to start another copy of Leafdown that could not see the other's open files, and whichever copy closed last overwrote the other's recent files and settings.
- Give Markdown files associated with Leafdown a dedicated folded-page icon with the green leaf and Markdown symbol, separate from the application icon. Both Windows installers include it without changing your default app.
- List a folder's other files, such as images and PDFs, in the article navigator, so a folder holding only those no longer looks empty. They are dimmed, hovering one says that Leafdown can only open Markdown files, and choosing one does nothing; its context menu can still reveal it in File Explorer or copy its path. The article count, the filter, Find in folder, and wiki link suggestions still cover Markdown files only.

Expand Down
11 changes: 8 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ The Rust backend manages:
- Decoding a Markdown file in the encoding its byte order mark names, otherwise in the encoding the user chose or as UTF-8, and refusing a chosen encoding whose re-encoded text would not reproduce the file's bytes. Encoding saved text back strictly in the document's encoding and byte order mark form, reporting each character that encoding cannot represent instead of substituting it. Legacy encodings go through `encoding_rs`'s non-replacing encoder; UTF-8 and UTF-16 use the standard library.
- Classifying native dropped paths as folders, supported Markdown files, or unsupported items.
- Owning the built-in Markdown file extension registry, `src-tauri/markdown-file-extensions.json`, listed in omitted-extension resolution order. The build script validates it and generates the Rust constant, and the frontend imports the same file for native picker filters, so opening, saving, scanning, watching, drops, folder entries, index and wiki-link resolution, and pickers accept one set. The build script also writes the extension lists the Windows installers register file associations from, as described in [Packaging](#packaging). The new-document extension preference is a separate, narrower list.
- Recording the process's first command-line argument as the launch document's absolute path, which the frontend takes once.
- Holding, for each window, the documents launches asked it to open, each resolved to an absolute path against its launch's working directory: the process's first command-line argument for the first window, and the files later launches hand over. A window's frontend takes them once.
- Routing a later launch, which the single-instance plugin delivers from a second process that then exits. A document goes to the window holding it, otherwise to the most recently focused window; a launch without one opens a new window. A visible receiving window is brought forward while the launch is delivered, since Windows grants the foreground for that moment, and a window still hidden comes forward when its frontend takes its documents.
- File metadata reads and existence checks.
- Resolving Markdown link and image targets, and handing confirmed local link targets to the system default application.
- Reading an image for HTML export through the same resolution the editor renders with, bounded in size and checked for a PNG, JPEG, GIF, WebP, or SVG signature, and writing exported HTML atomically to an `.html` or `.htm` path that is not the source document.
Expand Down Expand Up @@ -175,7 +176,7 @@ The React frontend manages:
- Presenting a fetched remote image through a `blob:` object URL owned by its image node view, which revokes it when the target changes or the view is destroyed and discards results that arrive after either.
- Marker visibility rules, thematic styling, and error presentation.
- Mirroring shared unexpected-error reports and feature-owned operational diagnostics into local logs as event-specific payloads, and exposing the Help diagnostics dialog.
- Showing the window once startup initialization finishes or fails, then opening the launch document, and answering intercepted close requests by destroying the window or declining the request. A window with unsaved documents comes to the front before it asks, since `Quit` asks each window in turn.
- Showing the window once startup initialization finishes or fails, then opening the documents launches routed to it, one at a time so a later open does not cancel an earlier one, and answering intercepted close requests by destroying the window or declining the request. A window with unsaved documents comes to the front before it asks, since `Quit` asks each window in turn.
- Reporting the window's saved documents and their unsaved state to the backend, together with documents it has claimed but not yet opened, and following what other windows report: a document to show, a rename to follow, a deletion to check, and documents with unsaved changes that Replace in folder leaves alone.
- Applying the shared always-on-top setting to the window whenever another window changes it.
- Suppressing standard window-level drag-and-drop navigation and routing native file and folder drops through session workflows.
Expand Down Expand Up @@ -213,7 +214,11 @@ Session activates the tab already holding the file -> Otherwise session claims t

### Launch Workflow

Backend records the launch document path -> Frontend starts persisted stores and shows the window -> Session takes the path once and runs the Open workflow. A window opened later starts the same way and finds no launch document.
Backend records the first launch's document for the first window -> Frontend starts persisted stores, shows the window, and listens for routed launches -> Session takes the window's documents and runs the Open workflow for each in turn. A window opened later starts the same way and finds none.

A later launch: the single-instance plugin, registered before every other plugin, finds the running process, hands it the arguments and working directory, and exits -> Backend resolves the path and adds it to the documents of the window holding that file, or else of the most recently focused window, brings that window forward, and tells it -> Session takes the documents and runs the Open workflow, which activates a tab that already holds the file. A launch without a path opens a new window instead, which comes forward once it has shown itself and taken its documents.

The listener is registered before the first take, so a document routed during startup is either taken then or announced afterwards. Development runs leave the plugin out: they share the installed application's identifier, from which the plugin names the instance it looks for.

### Quit Workflow

Expand Down
2 changes: 1 addition & 1 deletion docs/help/file-and-folder-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Renaming a file or folder in the navigator moves its tabs in every window, keepi

**File > Close window** (`Alt+F4`) or the window's close button asks only about that window's unsaved edits, and closing the last window quits Leafdown. **File > Quit** (`Mod+Q`) closes every window in turn. A window with unsaved edits comes to the front to ask about them, and canceling there stops quitting with that window and those not yet asked still open. The first window reopens where you left it; other windows open offset from the one you opened them from.

On Windows, installing Leafdown adds it to File Explorer's **Open with** menu for these files and to **Settings > Apps > Default apps**, without changing which app opens them by default. Choose Leafdown there to open Markdown files from File Explorer. Files associated with Leafdown show a folded-page icon with the green leaf and Markdown symbol. Each file opened this way starts another instance of Leafdown in its own window, with the file's folder as context; that instance does not know which files the other one has open. **File > Open** works either way.
On Windows, installing Leafdown adds it to File Explorer's **Open with** menu for these files and to **Settings > Apps > Default apps**, without changing which app opens them by default. Choose Leafdown there to open Markdown files from File Explorer. Files associated with Leafdown show a folded-page icon with the green leaf and Markdown symbol. If Leafdown is not running, it starts with the file, using the file's folder as context. If it is, the file opens as a new tab in the Leafdown window you used last, which comes to the front; a file that is already open switches to its tab and window instead. Starting Leafdown again without a file opens a new window. **File > Open** works either way.

The welcome screen and **File > Open recent** show previously opened files and folders when **Record recent files and folders** is on. You can remove one recent item or clear both lists without deleting files. See [Settings reference](settings-reference.md) for the setting.

Expand Down
6 changes: 4 additions & 2 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,10 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c

- The Windows installers register Leafdown as able to open each supported Markdown extension, so File Explorer offers it under Open with and Settings lists it under Default apps. Installing, upgrading, repairing, or reinstalling never makes Leafdown the default application: each extension's default and the user's choice stay as they were, and choosing Leafdown is the user's step in Windows.
- Files associated with Leafdown use a document icon showing the green leaf above the Markdown M and down arrow on a folded page. The application and its shortcuts keep the leaf icon.
- Opening a file through that registration, or starting Leafdown with a path as its first command-line argument, starts another instance of Leafdown with that file in its own window, even while Leafdown is running. That instance does not know which files the other instance has open. A relative path resolves against the directory Leafdown started in; later arguments are ignored.
- The file opens through [Open File](#open-file) once preferences have loaded, so it sets the folder context and records recents as File > Open does. A missing, unreadable, oversized, unsupported, or undecodable file shows its open error, as described in [Error Handling](#error-handling), and leaves the welcome screen.
- Opening a file through that registration, or starting Leafdown with a path as its first command-line argument, opens that file. A relative path resolves against the directory the launch started in; later arguments are ignored.
- While Leafdown is not running, the launch starts it with the file in its first window.
- While Leafdown is running, the launch starts no second instance. It hands its arguments and working directory to the running Leafdown and exits without showing a window. A file already open in any window activates its tab, and that window comes to the foreground. Any other file opens as a new tab in the most recently focused window, which comes to the foreground. A launch without a path opens a new window on the welcome screen, which comes to the foreground.
- The file opens through [Open File](#open-file) once the receiving window's preferences have loaded, so it sets a folder context the window lacks and records recents as File > Open does. Files from several launches open one after another in the order they arrived. A missing, unreadable, oversized, unsupported, or undecodable file shows its open error in that window, as described in [Error Handling](#error-handling), and leaves the window as it was.
- Upgrading, reinstalling, or repairing points the registration at the installed executable. Uninstalling removes the registration that installation made and nothing else. The NSIS installation registers for the current user and the MSI installation for all users; while both are installed, Windows uses the current user's.
- Opening files and folders from within Leafdown does not depend on the registration.

Expand Down
71 changes: 69 additions & 2 deletions e2e/desktop/specs/launch-document.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
import { $, browser, expect } from "@wdio/globals";
import { spawn } from "node:child_process";
import { once } from "node:events";
import path from "node:path";

import { getDesktopE2ERunContext } from "../support/runContext.js";
import { findMenuItem, findTreeItem, openMenu } from "../support/ui.js";
import { APP_BINARY_PATH } from "../support/suite.js";
import {
countDocumentTabs,
findDocumentTab,
findMenuItem,
findTreeItem,
openMenu,
} from "../support/ui.js";

const ACTIVE_EDITOR = '[data-testid=active-document-host] [contenteditable="true"]';

/**
* Launches Leafdown again as the operating system would, and waits for that launch to hand its
* arguments to the running instance and exit.
*/
const launchAgain = async (args: string[], cwd: string) => {
const launch = spawn(APP_BINARY_PATH, args, { cwd, env: process.env, stdio: "ignore" });
const [code] = (await Promise.race([
once(launch, "exit"),
new Promise((_, reject) => {
setTimeout(() => {
launch.kill();
reject(new Error("The second launch did not exit."));
}, 15_000).unref();
}),
])) as [number | null];

expect(code).toBe(0);
};

describe("desktop launch document", () => {
it("opens the document named on the command line through the open workflow", async () => {
const { launchDocument } = await getDesktopE2ERunContext();

const editor = $('[data-testid=active-document-host] [contenteditable="true"]');
const editor = $(ACTIVE_EDITOR);
await expect(editor).toHaveText(expect.stringContaining(launchDocument.marker));

await expect(await findTreeItem(path.basename(launchDocument.path))).toHaveAttribute(
Expand All @@ -23,4 +53,41 @@ describe("desktop launch document", () => {
await expect(await findMenuItem((text) => text === launchDocument.folderPath)).toBeDisplayed();
await browser.keys("Escape");
});

it("routes a later launch into the running window instead of starting another instance", async () => {
const { launchDocument } = await getDesktopE2ERunContext();
const launchName = path.basename(launchDocument.path);
const mainWindow = await browser.getWindowHandle();

await launchAgain([launchDocument.siblingFileName], launchDocument.folderPath);

await expect(await findDocumentTab(launchDocument.siblingFileName)).toHaveAttribute(
"aria-selected",
"true",
);
await expect($(ACTIVE_EDITOR)).toHaveText(expect.stringContaining("Sibling fixture marker."));
expect(await countDocumentTabs()).toBe(2);

await launchAgain([launchDocument.path], launchDocument.folderPath);

await expect(await findDocumentTab(launchName)).toHaveAttribute("aria-selected", "true");
expect(await countDocumentTabs()).toBe(2);

await launchAgain([], launchDocument.folderPath);

let opened: string | undefined;

await browser.waitUntil(
async () => {
opened = (await browser.getWindowHandles()).find((handle) => handle !== mainWindow);
return opened !== undefined;
},
{ timeoutMsg: "A launch without a document did not open a window." },
);

await browser.switchToWindow(opened!);
await expect($("aria/New document")).toBeDisplayed();
expect(await countDocumentTabs()).toBe(0);
await browser.switchToWindow(mainWindow);
});
});
9 changes: 9 additions & 0 deletions e2e/desktop/support/suite.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

export const RUN_LABEL =
process.env.LEAFDOWN_E2E_ARTIFACT_RUN ??
`${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`;
Expand All @@ -18,3 +21,9 @@ if (
}

export const WEBDRIVER_PORT = parsedWebDriverPort;

const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url));

export const APP_BINARY_PATH =
process.env.LEAFDOWN_E2E_RELEASE_BINARY ??
path.join(repositoryRoot, "src-tauri", "target", "desktop-e2e", "debug", "leafdown-e2e.exe");
12 changes: 2 additions & 10 deletions e2e/desktop/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";

import { ARTIFACTS_DIR, captureFailureArtifacts } from "./support/artifacts.js";
import { WEBDRIVER_PORT } from "./support/suite.js";
import { APP_BINARY_PATH, WEBDRIVER_PORT } from "./support/suite.js";

const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url));
const defaultAppBinaryPath = path.join(
repositoryRoot,
"src-tauri",
"target",
"desktop-e2e",
"debug",
"leafdown-e2e.exe",
);
const appBinaryPath = process.env.LEAFDOWN_E2E_RELEASE_BINARY ?? defaultAppBinaryPath;
const appBinaryPath = APP_BINARY_PATH;
const requestedSpec = process.env.LEAFDOWN_E2E_SPEC;

if (!requestedSpec) {
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ trash = { version = "5.2.9", default-features = false, features = ["coinit_apart
encoding_rs = "0.8.42"

[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.4.5"
tauri-plugin-window-state = "2.4.1"
Loading