Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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 apps/client/src/components/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export type CommandMappings = {
collapseSubtree: ContextMenuCommandData;
sortChildNotes: ContextMenuCommandData;
copyNotePathToClipboard: ContextMenuCommandData;
copyNoteUrlToClipboard: ContextMenuCommandData;
recentChangesInSubtree: ContextMenuCommandData;
cutNotesToClipboard: ContextMenuCommandData;
copyNotesToClipboard: ContextMenuCommandData;
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/menus/tree_context_menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
{ kind: "separator" },

{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
{ title: t("tree-context-menu.copy-note-url-to-clipboard"), command: "copyNoteUrlToClipboard", uiIcon: "bx bx-link", enabled: true },
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
].filter(Boolean) as MenuItem<TreeCommandNames>[]
},
Expand Down Expand Up @@ -350,6 +351,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
toastService.showMessage(t("tree-context-menu.converted-to-attachments", { count: converted }));
} else if (command === "copyNotePathToClipboard") {
navigator.clipboard.writeText(`#${ notePath}`);
} else if (command === "copyNoteUrlToClipboard") {
navigator.clipboard.writeText(`trilium://note/${this.node.data.noteId}`);
} else if (command) {
this.treeWidget.triggerCommand<TreeCommandNames>(command, {
node: this.node,
Expand Down
1 change: 1 addition & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,7 @@
"recent-changes-in-subtree": "Recent changes in subtree",
"convert-to-attachment": "Convert to attachment",
"copy-note-path-to-clipboard": "Copy note path to clipboard",
"copy-note-url-to-clipboard": "Copy note URL to clipboard",
"protect-subtree": "Protect subtree",
"unprotect-subtree": "Unprotect subtree",
"copy-clone": "Copy / clone",
Expand Down
11 changes: 11 additions & 0 deletions apps/client/src/widgets/layout/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ function buildEmptyAreaContextMenu(parentComponent: Component | null, notePath:
uiIcon: "bx bx-directions",
handler: () => copyTextWithToast(`#${notePath}`)
},
{
title: t("tree-context-menu.copy-note-url-to-clipboard"),
command: "copyNoteUrlToClipboard",
uiIcon: "bx bx-link",
handler: () => {
const noteId = notePath?.split("/").pop();
if (noteId) {
copyTextWithToast(`trilium://note/${noteId}`);
}
}
},
],
x: e.pageX,
y: e.pageY,
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PRODUCT_NAME } from "./app-info";
import port from "@triliumnext/server/src/services/port.js";
import { join } from "path";
import { deferred, LOCALES } from "../../../packages/commons/src";
import protocolHandler from "./protocol-handler.js";

async function main() {
const userDataPath = getUserData();
Expand All @@ -24,6 +25,11 @@ async function main() {
process.exit(0);
}

// Register trilium:// as a custom URI scheme so external apps and the OS
// can launch Trilium and navigate directly to a note.
// Must be called before app.requestSingleInstanceLock().
app.setAsDefaultProtocolClient(protocolHandler.PROTOCOL);

// Adds debug features like hotkeys for triggering dev tools and reload
electronDebug();
electronDl({ saveAs: true });
Expand Down Expand Up @@ -63,13 +69,41 @@ async function main() {
await serverInitializedPromise;
console.log("Starting Electron...");
await onReady();

// Process protocol URL from first-launch argv (Windows/Linux only;
// macOS delivers protocol URLs via the open-url event).
if (process.platform !== "darwin") {
const noteId = protocolHandler.extractNoteIdFromArgs(process.argv);
if (noteId) {
protocolHandler.navigateToNote(noteId);
}
}

// Handle protocol URL that arrived via open-url before the window was
// ready (macOS cold-start from a trilium:// link).
protocolHandler.processPendingNavigation();
});

app.on("will-quit", () => {
globalShortcut.unregisterAll();
});

// macOS delivers protocol URLs for a *running* instance via open-url
// instead of second-instance. When the app is cold-started from a
// trilium:// link the event fires before "ready", so we queue the note ID.
app.on("open-url", (event, url) => {
event.preventDefault();
protocolHandler.handleProtocolUrl(url);
});

app.on("second-instance", (event, commandLine) => {
// Check for a trilium:// URL or --open-note flag first.
const noteId = protocolHandler.extractNoteIdFromArgs(commandLine);
if (noteId) {
protocolHandler.navigateToNote(noteId);
return;
}

const lastFocusedWindow = windowService.getLastFocusedWindow();
if (commandLine.includes("--new-window")) {
windowService.createExtraWindow("");
Expand Down
123 changes: 123 additions & 0 deletions apps/desktop/src/protocol-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import windowService from "@triliumnext/server/src/services/window.js";

const PROTOCOL = "trilium";

/** Note ID to navigate to once the main window finishes loading. */
let pendingNoteId: string | null = null;

/**
* Parses a `trilium://` URL and returns the note ID, or `null` if the URL
* cannot be parsed.
*
* Supported formats:
* trilium://note/<noteId> (canonical)
* trilium://<noteId> (shorthand)
*/
function parseTriliumUrl(rawUrl: string): string | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}

if (parsed.protocol !== `${PROTOCOL}:`) {
return null;
}

// trilium://note/<noteId> → hostname = "note", pathname = "/<noteId>"
if (parsed.hostname === "note") {
const noteId = parsed.pathname.replace(/^\/+/, "").trim();
return noteId || null;
}

// trilium://<noteId> → hostname = "<noteId>"
const noteId = parsed.hostname.trim();
return noteId || null;
Comment on lines +28 to +38
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseTriliumUrl() currently returns the full pathname (minus leading slashes) for canonical URLs. If the URL has a trailing slash (e.g. trilium://note/<id>/) or additional path segments, the returned noteId will include / and won’t match Trilium note IDs (expected length 12). Consider normalizing by stripping trailing slashes and/or taking only the first path segment, and optionally validating against the expected noteId format before returning it.

Copilot uses AI. Check for mistakes.
}
Comment on lines +16 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add unit tests. Make sure to cover the case indicated by the Copilot comment from two eweeks ago.


/**
* Scans process arguments for a `trilium://` URL or `--open-note=<noteId>`
* flag and returns the target note ID.
*/
function extractNoteIdFromArgs(args: string[]): string | null {
for (const arg of args) {
if (arg.startsWith(`${PROTOCOL}://`)) {
return parseTriliumUrl(arg);
}

const match = arg.match(/^--open-note=(.+)$/);
if (match) {
return match[1].trim() || null;
}
}
Comment on lines +45 to +60
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractNoteIdFromArgs returns immediately when it encounters an arg starting with trilium://, even if parseTriliumUrl() returns null. This means later args (e.g. a valid --open-note=...) will never be checked. Consider only returning when a non-null noteId was parsed, otherwise continue scanning the remaining args.

Copilot uses AI. Check for mistakes.
return null;
}
Comment on lines +41 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, write tests + check case from Copilot.


/**
* Focuses the appropriate window and sends navigation IPC to the renderer.
* Waits for `did-finish-load` if the page is still loading (first launch).
*
* Returns `false` when no usable window exists yet.
*/
function navigateToNote(noteId: string): boolean {
const win =
windowService.getLastFocusedWindow() ?? windowService.getMainWindow();
if (!win || win.isDestroyed()) {
return false;
}

if (win.isMinimized()) {
win.restore();
}
win.show();
win.focus();

if (win.webContents.isLoading()) {
win.webContents.once("did-finish-load", () => {
win.webContents.send("openInSameTab", noteId);
});
} else {
win.webContents.send("openInSameTab", noteId);
}

return true;
}

/**
* Handles an incoming protocol URL (from `open-url`, `second-instance`, or
* first-launch argv). If the main window is not yet available the note ID is
* queued for {@link processPendingNavigation}.
*/
function handleProtocolUrl(url: string): void {
const noteId = parseTriliumUrl(url);
if (!noteId) {
return;
}

if (!navigateToNote(noteId)) {
// Window not created yet — defer until after onReady().
pendingNoteId = noteId;
}
}

/**
* Navigates to the note that was requested before the window was ready, then
* clears the pending state. Safe to call when there is nothing pending.
*/
function processPendingNavigation(): void {
if (pendingNoteId) {
const noteId = pendingNoteId;
pendingNoteId = null;
navigateToNote(noteId);
}
}

export default {
PROTOCOL,
parseTriliumUrl,
extractNoteIdFromArgs,
navigateToNote,
handleProtocolUrl,
processPendingNavigation,
};
Loading