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 {