diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx
index f74bd8e5..2090e6af 100644
--- a/packages/app-core/src/App.tsx
+++ b/packages/app-core/src/App.tsx
@@ -378,6 +378,7 @@ function App(): JSX.Element {
const textFont = useStore((s) => s.textFont)
const monoFont = useStore((s) => s.monoFont)
const darkSidebar = useStore((s) => s.darkSidebar)
+ const titlebarTabs = useStore((s) => s.titlebarTabs)
const hasCompletedOnboarding = useStore((s) => s.hasCompletedOnboarding)
const persistWorkspace = useStore((s) => s.persistWorkspace)
const flushDirtyNotes = useStore((s) => s.flushDirtyNotes)
@@ -662,6 +663,16 @@ function App(): JSX.Element {
document.documentElement.setAttribute('data-opaque', '')
}, [])
+ // Title-bar tabs layout: mirror the pref onto the root element so override
+ // CSS and keyboard-navigation smoke checks can detect the mode globally.
+ useEffect(() => {
+ if (titlebarTabs) {
+ document.documentElement.setAttribute('data-titlebar-tabs', '')
+ } else {
+ document.documentElement.removeAttribute('data-titlebar-tabs')
+ }
+ }, [titlebarTabs])
+
// Sidebar darken toggle: when on, the sidebar reads `--z-bg-1`
// (one step darker than the main canvas `--z-bg`) regardless of
// theme, giving a subtle chrome/content separation.
diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx
index 7bbc1a04..5645ee73 100644
--- a/packages/app-core/src/components/EditorPane.tsx
+++ b/packages/app-core/src/components/EditorPane.tsx
@@ -6,6 +6,7 @@
* The store keeps per-path note content (`noteContents`) shared across
* all panes, so the same note open in two panes stays in sync on edit.
*/
+import { createPortal } from 'react-dom'
import {
Fragment,
useCallback,
@@ -98,7 +99,7 @@ import { autocompletion } from '@codemirror/autocomplete'
import { useStore } from '../store'
import type { LineNumberMode } from '../store'
import type { PaneEdge, PaneLeaf } from '../lib/pane-layout'
-import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout'
+import { findLeaf, allLeaves, inferPaneDropEdge } from '../lib/pane-layout'
import { livePreviewPlugin } from '../lib/cm-live-preview'
import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair'
import { tablePlugin, tableVimEntry } from '../lib/cm-table'
@@ -892,6 +893,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const textFont = useStore((s) => s.textFont)
const tabsEnabled = useStore((s) => s.tabsEnabled)
const wrapTabs = useStore((s) => s.wrapTabs)
+ const titlebarTabs = useStore((s) => s.titlebarTabs)
+ const isSinglePane = useStore((s) => allLeaves(s.paneLayout).length === 1)
const jumpToPreviousNote = useStore((s) => s.jumpToPreviousNote)
const jumpToNextNote = useStore((s) => s.jumpToNextNote)
const canGoBack = useStore((s) => s.noteBackstack.length > 0)
@@ -3438,11 +3441,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
el?.scrollIntoView({ inline: 'nearest', block: 'nearest' })
}, [activeTab, hasTabs, wrapTabs, tabStripMeasureKey])
- // Outer header holds the back/forward nav buttons + the (flex-1) tab strip.
- const tabStripHeaderClass = [
- 'glass-header flex shrink-0 items-stretch border-b border-paper-300/70 pl-1',
- wrapTabs ? 'min-h-[var(--z-tab-height)]' : 'h-[var(--z-tab-height)]'
- ].join(' ')
+ const titlebarTabsActive = titlebarTabs && isSinglePane && hasTabs
+ const tabStripHeaderClass = titlebarTabsActive
+ ? 'titlebar-tab-strip flex shrink-0 items-stretch'
+ : [
+ 'glass-header flex shrink-0 items-stretch border-b border-paper-300/70 pl-1',
+ wrapTabs ? 'min-h-[var(--z-tab-height)]' : 'h-[var(--z-tab-height)]'
+ ].join(' ')
const tabStripClass = [
'workspace-tab-strip flex min-w-0 flex-1 items-stretch gap-0',
wrapTabs
@@ -3663,6 +3668,72 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
isActive ? '' : 'opacity-[0.98]'
].join(' ')
+ const tabStripHost =
+ typeof document !== 'undefined' ? document.getElementById('titlebar-tabs-host') : null
+ const tabStrip = (
+
+
+ {!titlebarTabsActive && !sidebarOpen && (
+
+
+
+ )}
+
void jumpToPreviousNote()}
+ disabled={!canGoBack}
+ tooltipAlign="left"
+ >
+
+
+
void jumpToNextNote()}
+ disabled={!canGoForward}
+ tooltipAlign="left"
+ >
+
+
+
+
+ {tabItems.map((tab, i) => {
+ // Draw a subtle vertical separator between the last pinned
+ // tab and the first unpinned one (VSCode convention). The
+ // separator is a flex sibling, not a wrapper, so drag hit-
+ // detection on the tab itself is unchanged.
+ const prevPinned = i > 0 ? tabItems[i - 1].pinned : false
+ const needsSeparator = prevPinned && !tab.pinned
+ return (
+
+ {needsSeparator && (
+
+ )}
+ {renderTab(tab)}
+
+ )
+ })}
+
+
+ )
+
return (
- {hasTabs && (
-
-
- {!sidebarOpen && (
-
-
-
- )}
-
void jumpToPreviousNote()}
- disabled={!canGoBack}
- tooltipAlign="left"
- >
-
-
-
void jumpToNextNote()}
- disabled={!canGoForward}
- tooltipAlign="left"
- >
-
-
-
-
- {tabItems.map((tab, i) => {
- // Draw a subtle vertical separator between the last pinned
- // tab and the first unpinned one (VSCode convention). The
- // separator is a flex sibling, not a wrapper, so drag hit-
- // detection on the tab itself is unchanged.
- const prevPinned = i > 0 ? tabItems[i - 1].pinned : false
- const needsSeparator = prevPinned && !tab.pinned
- return (
-
- {needsSeparator && (
-
- )}
- {renderTab(tab)}
-
- )
- })}
-
-
- )}
+ {hasTabs && !titlebarTabsActive && tabStrip}
+ {hasTabs && titlebarTabsActive && tabStripHost && createPortal(tabStrip, tabStripHost)}
{content && !zenMode && (
diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx
index f27494c7..11246440 100644
--- a/packages/app-core/src/components/SettingsModal.tsx
+++ b/packages/app-core/src/components/SettingsModal.tsx
@@ -526,6 +526,8 @@ export function SettingsModal(): JSX.Element {
const setHiddenWorkflowPresets = useStore((s) => s.setHiddenWorkflowPresets);
const wrapTabs = useStore((s) => s.wrapTabs);
const setWrapTabs = useStore((s) => s.setWrapTabs);
+ const titlebarTabs = useStore((s) => s.titlebarTabs);
+ const setTitlebarTabs = useStore((s) => s.setTitlebarTabs);
const quickNoteDateTitle = useStore((s) => s.quickNoteDateTitle);
const setQuickNoteDateTitle = useStore((s) => s.setQuickNoteDateTitle);
const quickNoteTitlePrefix = useStore((s) => s.quickNoteTitlePrefix);
@@ -1335,6 +1337,13 @@ export function SettingsModal(): JSX.Element {
"Show /-separated tags as a collapsible tree in the sidebar and Tags view instead of a flat list.",
keywords: ["hierarchical", "tree", "tags", "nested", "hierarchy"],
},
+ {
+ id: "tabs-in-title-bar",
+ title: "Tabs in title bar",
+ description:
+ "Move the sidebar toggle and note tabs into the title bar when only one pane is open.",
+ keywords: ["title bar", "tabs", "sidebar toggle"],
+ },
],
content: (
@@ -1748,6 +1757,13 @@ export function SettingsModal(): JSX.Element {
settingId="nested-tags"
onChange={setNestedTags}
/>
+
s.previewNote);
const selectedPath = useStore((s) => s.selectedPath);
const tabsEnabled = useStore((s) => s.tabsEnabled);
+ const titlebarTabs = useStore((s) => s.titlebarTabs);
const openNoteInTab = useStore((s) => s.openNoteInTab);
const systemFolderLabels = useStore((s) => s.systemFolderLabels);
const workspaceMode = useStore((s) => s.workspaceMode);
@@ -3104,9 +3105,11 @@ export function Sidebar(): JSX.Element {
>
-
-
-
+ {!titlebarTabs && (
+
+
+
+ )}
diff --git a/packages/app-core/src/components/TitleBar.tsx b/packages/app-core/src/components/TitleBar.tsx
index 61c0925e..eecae105 100644
--- a/packages/app-core/src/components/TitleBar.tsx
+++ b/packages/app-core/src/components/TitleBar.tsx
@@ -1,4 +1,5 @@
import { useStore } from '../store'
+import { allLeaves } from '../lib/pane-layout'
import { isTasksTabPath } from '@shared/tasks'
import { isWorkflowsTabPath } from '@shared/workflows-view'
import { isAtlasTabPath } from '@shared/atlas-view'
@@ -8,6 +9,7 @@ import { isArchiveTabPath } from '@shared/archive'
import { isTrashTabPath } from '@shared/trash'
import { isQuickNotesTabPath } from '@shared/quick-notes'
import { resolveSystemFolderLabels } from '../lib/system-folder-labels'
+import { PanelLeftIcon } from './icons'
export function TitleBar(): JSX.Element {
const vault = useStore((s) => s.vault)
@@ -15,54 +17,91 @@ export function TitleBar(): JSX.Element {
const selectedPath = useStore((s) => s.selectedPath)
const systemFolderLabels = useStore((s) => s.systemFolderLabels)
const workspaceMode = useStore((s) => s.workspaceMode)
+ const titlebarTabs = useStore((s) => s.titlebarTabs)
+ const paneLayout = useStore((s) => s.paneLayout)
+ const sidebarOpen = useStore((s) => s.sidebarOpen)
+ const toggleSidebar = useStore((s) => s.toggleSidebar)
const isMac = window.zen.platformSync() === 'darwin'
const labels = resolveSystemFolderLabels(systemFolderLabels)
+ const isSinglePane = allLeaves(paneLayout).length === 1
+ const hostingTabs = titlebarTabs && isSinglePane
const title = activeNote
? activeNote.title
: isQuickNotesTabPath(selectedPath)
? labels.quick
- : isTasksTabPath(selectedPath)
- ? labels.tasks
- : isWorkflowsTabPath(selectedPath)
- ? 'Workflows'
- : isAtlasTabPath(selectedPath)
- ? 'Atlas'
- : isTagsTabPath(selectedPath)
- ? 'Tags'
- : isHelpTabPath(selectedPath)
- ? 'Help'
- : isArchiveTabPath(selectedPath)
- ? labels.archive
- : isTrashTabPath(selectedPath)
- ? labels.trash
- : vault
- ? vault.name
- : 'ZenNotes'
+ : isTasksTabPath(selectedPath)
+ ? labels.tasks
+ : isWorkflowsTabPath(selectedPath)
+ ? 'Workflows'
+ : isAtlasTabPath(selectedPath)
+ ? 'Atlas'
+ : isTagsTabPath(selectedPath)
+ ? 'Tags'
+ : isHelpTabPath(selectedPath)
+ ? 'Help'
+ : isArchiveTabPath(selectedPath)
+ ? labels.archive
+ : isTrashTabPath(selectedPath)
+ ? labels.trash
+ : vault
+ ? vault.name
+ : 'ZenNotes'
return (
-
- {title}
- {workspaceMode === 'remote' && (
-
- Remote
-
- )}
-
- {!isMac && (
-
- window.zen.windowMinimize()} label="–" />
- window.zen.windowToggleMaximize()} label="▢" />
- window.zen.windowClose()}
- label="✕"
- className="hover:bg-red-500/90 hover:text-white"
- />
-
+ {hostingTabs ? (
+ <>
+
+
+ {!isMac && (
+
+ window.zen.windowMinimize()} label="–" />
+ window.zen.windowToggleMaximize()} label="▢" />
+ window.zen.windowClose()}
+ label="✕"
+ className="hover:bg-red-500/90 hover:text-white"
+ />
+
+ )}
+ >
+ ) : (
+ <>
+
+ {title}
+ {workspaceMode === 'remote' && (
+
+ Remote
+
+ )}
+
+ {!isMac && (
+
+ window.zen.windowMinimize()} label="–" />
+ window.zen.windowToggleMaximize()} label="▢" />
+ window.zen.windowClose()}
+ label="✕"
+ className="hover:bg-red-500/90 hover:text-white"
+ />
+
+ )}
+ >
)}
)
diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts
index 473f6606..0e30d8bb 100644
--- a/packages/app-core/src/store.ts
+++ b/packages/app-core/src/store.ts
@@ -560,6 +560,7 @@ interface Prefs {
hideBuiltinTemplates: boolean // hide shipped built-in templates from the pickers
tabsEnabled: boolean
wrapTabs: boolean
+ titlebarTabs: boolean
themeId: string
themeFamily: ThemeFamily
themeMode: ThemeMode
@@ -998,6 +999,7 @@ export const DEFAULT_PREFS: Prefs = {
hideBuiltinTemplates: false,
tabsEnabled: true,
wrapTabs: false,
+ titlebarTabs: false,
themeId: DEFAULT_THEME_ID,
themeFamily: 'gruvbox',
themeMode: 'dark',
@@ -1194,6 +1196,10 @@ function normalizePrefs(p: Partial): Prefs {
typeof p.tabsEnabled === 'boolean' ? p.tabsEnabled : DEFAULT_PREFS.tabsEnabled,
wrapTabs:
typeof p.wrapTabs === 'boolean' ? p.wrapTabs : DEFAULT_PREFS.wrapTabs,
+ titlebarTabs:
+ typeof p.titlebarTabs === 'boolean'
+ ? p.titlebarTabs
+ : DEFAULT_PREFS.titlebarTabs,
themeId,
themeFamily,
themeMode,
@@ -2202,6 +2208,7 @@ function collectPrefs(s: {
hideBuiltinTemplates: boolean
tabsEnabled: boolean
wrapTabs: boolean
+ titlebarTabs: boolean
themeId: string
themeFamily: ThemeFamily
themeMode: ThemeMode
@@ -2297,6 +2304,7 @@ function collectPrefs(s: {
hideBuiltinTemplates: s.hideBuiltinTemplates,
tabsEnabled: s.tabsEnabled,
wrapTabs: s.wrapTabs,
+ titlebarTabs: s.titlebarTabs,
themeId: s.themeId,
themeFamily: s.themeFamily,
themeMode: s.themeMode,
@@ -2809,6 +2817,7 @@ interface Store {
hideBuiltinTemplates: boolean
tabsEnabled: boolean
wrapTabs: boolean
+ titlebarTabs: boolean
settingsOpen: boolean
/** Chapter index of the guided Workflows tutorial, or null when it is not
* running. Session-only on purpose: the tutorial re-seeds (and first
@@ -3311,6 +3320,7 @@ interface Store {
setHiddenWorkflowPresets: (ids: readonly string[]) => void
setTabsEnabled: (on: boolean) => void
setWrapTabs: (on: boolean) => void
+ setTitlebarTabs: (on: boolean) => void
setSettingsOpen: (open: boolean) => void
setWorkflowTutorialStep: (step: number | null) => void
setWorkflowRunRecord: (
@@ -4592,6 +4602,7 @@ export const useStore = create((set, get) => {
hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates,
tabsEnabled: loadPrefs().tabsEnabled,
wrapTabs: loadPrefs().wrapTabs,
+ titlebarTabs: loadPrefs().titlebarTabs,
settingsOpen: false,
workflowTutorialStep: null,
workflowRunRecord: null,
@@ -7351,6 +7362,10 @@ export const useStore = create((set, get) => {
set({ wrapTabs: on })
savePrefs(collectPrefs(get()))
},
+ setTitlebarTabs: (on) => {
+ set({ titlebarTabs: on })
+ savePrefs(collectPrefs(get()))
+ },
setPdfExportUseTheme: (on) => {
set({ pdfExportUseTheme: on })
savePrefs(collectPrefs(get()))
diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css
index c94d3ef7..63e79d91 100644
--- a/packages/app-core/src/styles/index.css
+++ b/packages/app-core/src/styles/index.css
@@ -1498,6 +1498,63 @@ textarea,
-webkit-app-region: no-drag;
}
+/* ===========================================================================
+ * Title-bar tabs layout
+ * --------------------------------------------------------------------------
+ * When Settings → "Tabs in title bar" is on and there is only one pane, the
+ * title bar hosts the sidebar toggle, back/forward navigation, and the note
+ * tab strip instead of the centered window title. The active EditorPane
+ * portals its tab strip into #titlebar-tabs-host. When split into multiple
+ * panes, the title bar falls back to the normal centered title and each pane
+ * keeps its inline tab strip.
+ * =======================================================================*/
+
+/* The host must stretch to fill the title bar and clip any overflow so long
+ tab lists don't collide with window controls. */
+#titlebar-tabs-host {
+ display: flex;
+ min-width: 0;
+ flex: 1 1 0%;
+ align-items: stretch;
+ overflow: hidden;
+ -webkit-app-region: no-drag;
+}
+
+#titlebar-tabs-host:empty {
+ display: none;
+}
+
+/* The ported tab strip fills the host and inherits the title bar's glass
+ background; remove its own chrome so it doesn't double-paint borders. */
+#titlebar-tabs-host > .titlebar-tab-strip {
+ width: 100%;
+ height: 100%;
+ background: transparent;
+ border: none;
+ -webkit-app-region: no-drag;
+}
+
+/* Make the tab rows the same height as the title bar. */
+#titlebar-tabs-host > .titlebar-tab-strip .workspace-tab-strip {
+ height: 100%;
+}
+
+/* Slimmer horizontal scrollbar inside the title bar. */
+#titlebar-tabs-host > .titlebar-tab-strip .workspace-tab-strip::-webkit-scrollbar {
+ height: 4px;
+}
+
+/* When the title bar is hosting tabs, keep its right padding compact so the
+ tab strip reaches the window controls / right edge. */
+.drag-region[data-titlebar-tabs] {
+ padding-right: 0;
+}
+
+/* Make the active tab in the title bar more visible against the glass. */
+#titlebar-tabs-host > .titlebar-tab-strip [data-tab-active="true"] {
+ background: rgb(var(--z-bg-1) / 0.9);
+}
+
/* --- CodeMirror editor theme ------------------------------------------- */
.cm-editor {