diff --git a/apps/desktop/src/runtime/index.ts b/apps/desktop/src/runtime/index.ts index a4b87372..90870c57 100644 --- a/apps/desktop/src/runtime/index.ts +++ b/apps/desktop/src/runtime/index.ts @@ -184,6 +184,7 @@ export const desktopRuntime = { listenSettingsWindowTarget: windowRuntime.listenNativeSettingsWindowTarget, listenWindowCloseRequested: windowRuntime.listenNativeWindowCloseRequested, minimizeWindow: windowRuntime.minimizeNativeWindow, + openBlankEditorWindow: windowRuntime.openNativeBlankEditorWindow, openExternalUrl: windowRuntime.openNativeExternalUrl, openSettingsWindow: windowRuntime.openSettingsWindow, prewarmSettingsWindow: windowRuntime.prewarmSettingsWindow, diff --git a/apps/desktop/src/runtime/tauri/window.test.ts b/apps/desktop/src/runtime/tauri/window.test.ts index 4cabe7c8..15e5d79c 100644 --- a/apps/desktop/src/runtime/tauri/window.test.ts +++ b/apps/desktop/src/runtime/tauri/window.test.ts @@ -14,6 +14,7 @@ import { hideSettingsWindow, markSettingsWindowReady, minimizeNativeWindow, + openNativeBlankEditorWindow, openSettingsWindow, prewarmSettingsWindow, setNativeEditorWindowRestoreState, @@ -144,6 +145,14 @@ describe("native window actions", () => { expect(mockedGetCurrentWindow).not.toHaveBeenCalled(); }); + it("opens a blank editor window through the native command", async () => { + mockedInvoke.mockResolvedValue(undefined); + + await openNativeBlankEditorWindow(); + + expect(mockedInvoke).toHaveBeenCalledWith("open_blank_editor_window"); + }); + it("shows the current Tauri window", async () => { const isVisible = vi.fn().mockResolvedValue(false); const show = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/runtime/tauri/window.ts b/apps/desktop/src/runtime/tauri/window.ts index 4b88d3da..942d1375 100644 --- a/apps/desktop/src/runtime/tauri/window.ts +++ b/apps/desktop/src/runtime/tauri/window.ts @@ -48,6 +48,10 @@ export function hideSettingsWindow() { return invokeNative("hide_settings_window"); } +export function openNativeBlankEditorWindow() { + return invokeNative("open_blank_editor_window"); +} + export async function listenNativeSettingsWindowTarget(onTarget: (target: NativeSettingsWindowTarget) => unknown) { if (!("__TAURI_INTERNALS__" in window)) { return () => {}; diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 06bac517..5f26bee3 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -144,6 +144,7 @@ import type { import { closeNativeWindow, hideSettingsWindow, + openBlankEditorWindow, openNativeExternalUrl, openSettingsWindow, prewarmSettingsWindow, @@ -2857,6 +2858,9 @@ function WorkspaceApp() { const handleOpenSettings = useCallback(() => { openSettingsWindow().catch(() => {}); }, []); + const handleOpenBlankEditorWindow = useCallback(() => { + openBlankEditorWindow().catch(() => {}); + }, []); const handleShowAbout = useCallback(() => { showNativeAppAbout().catch(() => {}); }, []); @@ -3860,6 +3864,7 @@ function WorkspaceApp() { exportHtml: exportFeatureEnabled ? exportHtmlDocument : undefined, exportPdf: exportFeatureEnabled ? exportPdfDocument : undefined, markdownShortcuts: editorPreferences.preferences.markdownShortcuts, + openBlankEditorWindow: windowsSelfDrawnChromeEnabled ? handleOpenBlankEditorWindow : undefined, openDocument: handleOpenMarkdownFile, openDocumentReplace: handleDocumentReplaceOpen, openDocumentSearch: handleDocumentSearchOpen, @@ -4439,6 +4444,7 @@ function WorkspaceApp() { onSelectViewMode={handleViewModeSelect} onCreateMarkdownFile={handleQuickCreateMarkdownTreeFile} onExitApp={handleExitApp} + onOpenBlankEditorWindow={windowsSelfDrawnChromeEnabled ? handleOpenBlankEditorWindow : undefined} onOpenMarkdown={handleOpenMarkdownFile} onOpenMarkdownFolder={handleOpenMarkdownFolder} onOpenSettings={handleOpenSettings} diff --git a/packages/app/src/components/NativeTitleBar.test.tsx b/packages/app/src/components/NativeTitleBar.test.tsx index cf885616..87ed1a14 100644 --- a/packages/app/src/components/NativeTitleBar.test.tsx +++ b/packages/app/src/components/NativeTitleBar.test.tsx @@ -554,6 +554,7 @@ describe("NativeTitleBar", () => { }); it("opens self-drawn Windows app menu dropdowns from the top chrome", () => { + const openBlankEditorWindow = vi.fn(); const openMarkdown = vi.fn(); const saveMarkdown = vi.fn(); const saveMarkdownAs = vi.fn(); @@ -583,6 +584,7 @@ describe("NativeTitleBar", () => { )} onToggleAiAgent={() => {}} + onOpenBlankEditorWindow={openBlankEditorWindow} onOpenMarkdown={openMarkdown} onSaveMarkdown={saveMarkdown} onShowAbout={showAbout} @@ -625,6 +627,10 @@ describe("NativeTitleBar", () => { fireEvent.click(screen.getByRole("button", { name: "File" })); expect(screen.getByRole("menu", { name: "File" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: "New Ctrl+N" })); + expect(openBlankEditorWindow).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "File" })); fireEvent.click(screen.getByRole("menuitem", { name: "Open... Ctrl+O" })); expect(openMarkdown).toHaveBeenCalledTimes(1); diff --git a/packages/app/src/components/NativeTitleBar.tsx b/packages/app/src/components/NativeTitleBar.tsx index 5df8fe6c..96435396 100644 --- a/packages/app/src/components/NativeTitleBar.tsx +++ b/packages/app/src/components/NativeTitleBar.tsx @@ -91,6 +91,7 @@ type NativeTitleBarProps = { onSelectViewMode?: (mode: ViewMode) => unknown; onCreateMarkdownFile?: () => unknown; onExitApp?: () => unknown; + onOpenBlankEditorWindow?: () => unknown; onOpenMarkdown: () => unknown; onOpenMarkdownFolder?: () => unknown; onOpenSettings?: () => unknown; @@ -148,6 +149,7 @@ export function NativeTitleBar({ onSelectViewMode, onCreateMarkdownFile, onExitApp, + onOpenBlankEditorWindow, onOpenMarkdown, onOpenMarkdownFolder, onOpenSettings, @@ -718,6 +720,7 @@ export function NativeTitleBar({ renderTitleContent={renderTitleContent} onCreateMarkdownFile={onCreateMarkdownFile} onExitApp={onExitApp} + onOpenBlankEditorWindow={onOpenBlankEditorWindow} onOpenMarkdown={onOpenMarkdown} onOpenMarkdownFolder={onOpenMarkdownFolder} onOpenSettings={onOpenSettings} diff --git a/packages/app/src/components/WindowsNativeTitleBar.tsx b/packages/app/src/components/WindowsNativeTitleBar.tsx index 5e50b701..c191abd5 100644 --- a/packages/app/src/components/WindowsNativeTitleBar.tsx +++ b/packages/app/src/components/WindowsNativeTitleBar.tsx @@ -54,6 +54,7 @@ type WindowsNativeTitleBarProps = { renderTitleContent: (className: string, style?: CSSProperties) => ReactNode; onCreateMarkdownFile?: () => unknown; onExitApp?: () => unknown; + onOpenBlankEditorWindow?: () => unknown; onOpenMarkdown: () => unknown; onOpenMarkdownFolder?: () => unknown; onOpenSettings?: () => unknown; @@ -124,6 +125,7 @@ export function WindowsNativeTitleBar({ renderTitleContent, onCreateMarkdownFile, onExitApp, + onOpenBlankEditorWindow, onOpenMarkdown, onOpenMarkdownFolder, onOpenSettings, @@ -193,7 +195,12 @@ export function WindowsNativeTitleBar({ if (menuId === "file") { return [ - contextMenuItem("newDocument", label("menu.newDocument"), "Ctrl+N", onCreateMarkdownFile), + contextMenuItem( + "newDocument", + label("menu.newDocument"), + "Ctrl+N", + onOpenBlankEditorWindow ?? onCreateMarkdownFile + ), contextMenuItem("openDocument", label("menu.openDocument"), "Ctrl+O", onOpenMarkdown), onOpenMarkdownFolder ? contextMenuItem("openFolder", label("app.openFolderDialog"), "Ctrl+Shift+O", onOpenMarkdownFolder) diff --git a/packages/app/src/hooks/useMarkdownDocument.test.tsx b/packages/app/src/hooks/useMarkdownDocument.test.tsx index 0c852e27..362793dc 100644 --- a/packages/app/src/hooks/useMarkdownDocument.test.tsx +++ b/packages/app/src/hooks/useMarkdownDocument.test.tsx @@ -4086,7 +4086,7 @@ describe("useMarkdownDocument", () => { it("opens dropped Markdown files in current workspace tabs when enabled", async () => { const currentPath = "/mock-files/vault/current.md"; - const droppedPath = "/mock-files/external/dropped.md"; + const droppedPath = "/mock-files/vault/dropped.md"; const onTreeRootFromFilePath = vi.fn(); mockedReadNativeMarkdownFile.mockImplementation(async (path) => ({ content: path === droppedPath ? "# Dropped" : "# Current", @@ -4124,8 +4124,8 @@ describe("useMarkdownDocument", () => { expect(result.current.tabs.map((tab) => tab.path)).toEqual([currentPath, droppedPath]); }); - it("preserves the current workspace when a file replaces an empty tab", async () => { - const droppedPath = "/mock-files/external/dropped.md"; + it("reuses an empty tab for a dropped file in the current workspace", async () => { + const droppedPath = "/mock-files/vault/dropped.md"; const onTreeRootFromFilePath = vi.fn(); mockedReadNativeMarkdownFile.mockResolvedValue({ content: "# Dropped", @@ -4161,7 +4161,7 @@ describe("useMarkdownDocument", () => { it("reuses an empty active tab while retaining other open documents", async () => { const currentPath = "/mock-files/vault/current.md"; - const droppedPath = "/mock-files/external/dropped.md"; + const droppedPath = "/mock-files/vault/dropped.md"; const onTreeRootFromFilePath = vi.fn(); mockedReadNativeMarkdownFile.mockImplementation(async (path) => ({ content: path === droppedPath ? "# Dropped" : "# Current", @@ -4199,6 +4199,38 @@ describe("useMarkdownDocument", () => { expect(result.current.tabs.map((tab) => tab.path)).toEqual([currentPath, droppedPath]); }); + it("opens a dropped file outside the current Windows workspace in a new window", async () => { + const droppedPath = "C:\\mock-incoming\\outside.md"; + mockedReadNativeMarkdownFile.mockResolvedValue({ + content: "# Outside", + name: "outside.md", + path: droppedPath + }); + const { result } = renderHook(() => + useMarkdownDocument({ + documentTabsEnabled: true, + getCurrentMarkdown: (fallbackContent) => fallbackContent, + onTreeRootFromFilePath: vi.fn(), + onTreeRootFromFolderPath: vi.fn(), + openDroppedFilesInTabs: true, + preferencesReady: false, + restoreWorkspaceOnStartup: false, + workspaceSourcePath: "C:\\mock-vault" + }) + ); + + await act(async () => { + await result.current.handleDroppedMarkdownPath({ + kind: "file", + name: "outside.md", + path: droppedPath + }); + }); + + expect(mockedOpenNativeMarkdownFileInNewWindow).toHaveBeenCalledWith(droppedPath); + expect(mockedReadNativeMarkdownFile).not.toHaveBeenCalledWith(droppedPath); + }); + it("establishes a workspace from a file dropped into a fresh window", async () => { const droppedPath = "/mock-files/vault/dropped.md"; const onTreeRootFromFilePath = vi.fn(); @@ -4307,6 +4339,33 @@ describe("useMarkdownDocument", () => { ); }); + it("opens a dropped folder outside the current Windows workspace in a new window", async () => { + const folderPath = "C:\\mock-incoming"; + const onTreeRootFromFolderPath = vi.fn(); + const { result } = renderHook(() => + useMarkdownDocument({ + documentTabsEnabled: true, + getCurrentMarkdown: (fallbackContent) => fallbackContent, + onTreeRootFromFilePath: vi.fn(), + onTreeRootFromFolderPath, + preferencesReady: false, + restoreWorkspaceOnStartup: false, + workspaceSourcePath: "C:\\mock-vault" + }) + ); + + await act(async () => { + await result.current.handleDroppedMarkdownPath({ + kind: "folder", + name: "mock-incoming", + path: folderPath + }); + }); + + expect(mockedOpenNativeMarkdownFolderInNewWindow).toHaveBeenCalledWith(folderPath); + expect(onTreeRootFromFolderPath).not.toHaveBeenCalled(); + }); + it("keeps opening dropped Markdown files in new windows by default", async () => { const currentPath = "/mock-files/vault/current.md"; const droppedPath = "/mock-files/external/dropped.md"; diff --git a/packages/app/src/hooks/useMarkdownDocument.ts b/packages/app/src/hooks/useMarkdownDocument.ts index 5129a32f..869f0582 100644 --- a/packages/app/src/hooks/useMarkdownDocument.ts +++ b/packages/app/src/hooks/useMarkdownDocument.ts @@ -1768,6 +1768,21 @@ export function useMarkdownDocument({ const handleDroppedMarkdownPath = useCallback( async (target: NativeMarkdownDroppedTarget) => { + const workspaceRootPath = workspaceRootForSource(workspaceSourcePath, documentRef.current.path); + if ( + target.kind !== "image" && + workspaceRootPath && + !isPathWithinRoot(target.path, workspaceRootPath) + ) { + // Keep each window bound to one workspace; tab reuse only applies to paths inside it. + if (target.kind === "folder") { + await openNativeMarkdownFolderInNewWindow(target.path); + } else { + await openNativeMarkdownFileInNewWindow(target.path); + } + return; + } + if (target.kind === "folder") { if (!isCurrentWindowEmptyUntitled()) { await openNativeMarkdownFolderInNewWindow(target.path); diff --git a/packages/app/src/hooks/useNativeBindings.test.tsx b/packages/app/src/hooks/useNativeBindings.test.tsx index 9bb6a1c8..749566db 100644 --- a/packages/app/src/hooks/useNativeBindings.test.tsx +++ b/packages/app/src/hooks/useNativeBindings.test.tsx @@ -382,6 +382,25 @@ describe("useApplicationShortcuts", () => { expect(openSettings).toHaveBeenCalledTimes(1); }); + it("opens a blank editor window from Ctrl+N on Windows", () => { + const openBlankEditorWindow = vi.fn(); + renderHook(() => + useApplicationShortcuts({ + ...baseOptions, + openBlankEditorWindow, + platform: "windows" + }) + ); + + const handled = fireEvent.keyDown(window, { + ctrlKey: true, + key: "n" + }); + + expect(handled).toBe(false); + expect(openBlankEditorWindow).toHaveBeenCalledTimes(1); + }); + it("does not treat Control+Meta as the platform modifier for static shortcuts", () => { const openSettings = vi.fn(); renderHook(() => diff --git a/packages/app/src/hooks/useNativeBindings.ts b/packages/app/src/hooks/useNativeBindings.ts index 793a37ee..29d0a2e9 100644 --- a/packages/app/src/hooks/useNativeBindings.ts +++ b/packages/app/src/hooks/useNativeBindings.ts @@ -72,6 +72,7 @@ type ApplicationShortcutOptions = { openDocument: () => unknown | Promise; openDocumentReplace?: () => unknown | Promise; openDocumentSearch?: () => unknown | Promise; + openBlankEditorWindow?: () => unknown | Promise; openSettings?: () => unknown | Promise; openWorkspaceSearch?: () => unknown | Promise; openFolder: () => unknown | Promise; @@ -435,6 +436,7 @@ export function useApplicationShortcuts({ exportHtml, exportPdf, markdownShortcuts, + openBlankEditorWindow, openDocument, openDocumentReplace, openDocumentSearch, @@ -519,6 +521,17 @@ export function useApplicationShortcuts({ exportPdf(); } return; + } else if ( + platform === "windows" && + key === "n" && + event.ctrlKey && + !event.metaKey && + !event.shiftKey && + openBlankEditorWindow + ) { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) openBlankEditorWindow(); } else if (key === "s" && event.shiftKey) { event.preventDefault(); saveDocumentAs(); @@ -552,6 +565,7 @@ export function useApplicationShortcuts({ exportPdf, closeDocument, normalizedMarkdownShortcuts, + openBlankEditorWindow, openDocument, openDocumentReplace, openDocumentSearch, diff --git a/packages/app/src/lib/tauri/window.ts b/packages/app/src/lib/tauri/window.ts index 42d7c47c..e3d90f4e 100644 --- a/packages/app/src/lib/tauri/window.ts +++ b/packages/app/src/lib/tauri/window.ts @@ -37,6 +37,10 @@ export function hideSettingsWindow() { return getAppRuntime().window.hideSettingsWindow(); } +export function openBlankEditorWindow() { + return getAppRuntime().window.openBlankEditorWindow(); +} + export function listenNativeSettingsWindowTarget(onTarget: (target: NativeSettingsWindowTarget) => unknown) { return getAppRuntime().window.listenSettingsWindowTarget(onTarget); } diff --git a/packages/app/src/runtime/index.ts b/packages/app/src/runtime/index.ts index 1e1d1214..98f6be53 100644 --- a/packages/app/src/runtime/index.ts +++ b/packages/app/src/runtime/index.ts @@ -378,6 +378,7 @@ export type AppWindowRuntime = { onCloseRequested: (event: NativeWindowCloseRequestEvent) => unknown | Promise ) => Promise; minimizeWindow: () => Promise; + openBlankEditorWindow: () => Promise; openExternalUrl: (url: string) => Promise; openSettingsWindow: (target?: NativeSettingsWindowTarget) => Promise; prewarmSettingsWindow: () => Promise; @@ -593,6 +594,7 @@ export function createDefaultAppRuntime(): AppRuntime { listenSettingsWindowTarget: async () => () => undefined, listenWindowCloseRequested: async () => () => undefined, minimizeWindow: async () => undefined, + openBlankEditorWindow: async () => undefined, openExternalUrl: async (url) => { if (typeof window !== "undefined") { window.open(url, "_blank", "noopener,noreferrer");