Skip to content

Commit ed69bfd

Browse files
committed
feat: tabs in title bar
1 parent a3e638f commit ed69bfd

9 files changed

Lines changed: 265 additions & 107 deletions

File tree

apps/desktop/src/main/app-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
149149
tomlKey: 'wrap_tabs',
150150
comment: 'wrap the tab strip instead of scrolling it'
151151
},
152+
titlebarTabs: {
153+
section: 'editor',
154+
tomlKey: 'titlebar_tabs',
155+
comment: 'show tabs in the window title bar (desktop)'
156+
},
152157
editorFontSize: {
153158
section: 'editor',
154159
tomlKey: 'font_size',

packages/app-core/src/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ function App(): JSX.Element {
388388
const textFont = useStore((s) => s.textFont)
389389
const monoFont = useStore((s) => s.monoFont)
390390
const darkSidebar = useStore((s) => s.darkSidebar)
391+
const titlebarTabs = useStore((s) => s.titlebarTabs)
391392
const hasCompletedOnboarding = useStore((s) => s.hasCompletedOnboarding)
392393
const persistWorkspace = useStore((s) => s.persistWorkspace)
393394
const flushDirtyNotes = useStore((s) => s.flushDirtyNotes)
@@ -673,6 +674,16 @@ function App(): JSX.Element {
673674
document.documentElement.setAttribute('data-opaque', '')
674675
}, [])
675676

677+
// Title-bar tabs layout: mirror the pref onto the root element so override
678+
// CSS and keyboard-navigation smoke checks can detect the mode globally.
679+
useEffect(() => {
680+
if (titlebarTabs) {
681+
document.documentElement.setAttribute('data-titlebar-tabs', '')
682+
} else {
683+
document.documentElement.removeAttribute('data-titlebar-tabs')
684+
}
685+
}, [titlebarTabs])
686+
676687
// Sidebar darken toggle: when on, the sidebar reads `--z-bg-1`
677688
// (one step darker than the main canvas `--z-bg`) regardless of
678689
// theme, giving a subtle chrome/content separation.

packages/app-core/src/components/EditorPane.tsx

Lines changed: 79 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* The store keeps per-path note content (`noteContents`) shared across
77
* all panes, so the same note open in two panes stays in sync on edit.
88
*/
9+
import { createPortal } from 'react-dom'
910
import {
1011
Fragment,
1112
useCallback,
@@ -98,7 +99,7 @@ import { autocompletion } from '@codemirror/autocomplete'
9899
import { useStore } from '../store'
99100
import type { LineNumberMode } from '../store'
100101
import type { PaneEdge, PaneLeaf } from '../lib/pane-layout'
101-
import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout'
102+
import { findLeaf, allLeaves, inferPaneDropEdge } from '../lib/pane-layout'
102103
import { livePreviewPlugin } from '../lib/cm-live-preview'
103104
import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair'
104105
import { tablePlugin, tableVimEntry } from '../lib/cm-table'
@@ -854,6 +855,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
854855
const textFont = useStore((s) => s.textFont)
855856
const tabsEnabled = useStore((s) => s.tabsEnabled)
856857
const wrapTabs = useStore((s) => s.wrapTabs)
858+
const titlebarTabs = useStore((s) => s.titlebarTabs)
859+
const isSinglePane = useStore((s) => allLeaves(s.paneLayout).length === 1)
857860
const jumpToPreviousNote = useStore((s) => s.jumpToPreviousNote)
858861
const jumpToNextNote = useStore((s) => s.jumpToNextNote)
859862
const canGoBack = useStore((s) => s.noteBackstack.length > 0)
@@ -3439,11 +3442,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
34393442
el?.scrollIntoView({ inline: 'nearest', block: 'nearest' })
34403443
}, [activeTab, hasTabs, wrapTabs, tabStripMeasureKey])
34413444

3442-
// Outer header holds the back/forward nav buttons + the (flex-1) tab strip.
3443-
const tabStripHeaderClass = [
3444-
'glass-header flex shrink-0 items-stretch border-b border-paper-300/70 pl-1',
3445-
wrapTabs ? 'min-h-[var(--z-tab-height)]' : 'h-[var(--z-tab-height)]'
3446-
].join(' ')
3445+
const titlebarTabsActive = titlebarTabs && isSinglePane && hasTabs
3446+
const tabStripHeaderClass = titlebarTabsActive
3447+
? 'titlebar-tab-strip flex shrink-0 items-stretch'
3448+
: [
3449+
'glass-header flex shrink-0 items-stretch border-b border-paper-300/70 pl-1',
3450+
wrapTabs ? 'min-h-[var(--z-tab-height)]' : 'h-[var(--z-tab-height)]'
3451+
].join(' ')
34473452
const tabStripClass = [
34483453
'workspace-tab-strip flex min-w-0 flex-1 items-stretch gap-0',
34493454
wrapTabs
@@ -3664,6 +3669,72 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
36643669
isActive ? '' : 'opacity-[0.98]'
36653670
].join(' ')
36663671

3672+
const tabStripHost =
3673+
typeof document !== 'undefined' ? document.getElementById('titlebar-tabs-host') : null
3674+
const tabStrip = (
3675+
<div className={tabStripHeaderClass}>
3676+
<div className="flex shrink-0 items-center gap-0.5 self-center">
3677+
{!titlebarTabsActive && !sidebarOpen && (
3678+
<IconBtn
3679+
title="Show sidebar (⌘1)"
3680+
onClick={toggleSidebar}
3681+
tooltipAlign="left"
3682+
>
3683+
<PanelLeftIcon width={16} height={16} />
3684+
</IconBtn>
3685+
)}
3686+
<IconBtn
3687+
title={labelWithShortcut(
3688+
'Go back',
3689+
getKeymapDisplay(tabNavOverrides, vimMode ? 'vim.historyBack' : 'global.historyBack')
3690+
)}
3691+
onClick={() => void jumpToPreviousNote()}
3692+
disabled={!canGoBack}
3693+
tooltipAlign="left"
3694+
>
3695+
<ArrowLeftIcon width={16} height={16} />
3696+
</IconBtn>
3697+
<IconBtn
3698+
title={labelWithShortcut(
3699+
'Go forward',
3700+
getKeymapDisplay(tabNavOverrides, vimMode ? 'vim.historyForward' : 'global.historyForward')
3701+
)}
3702+
onClick={() => void jumpToNextNote()}
3703+
disabled={!canGoForward}
3704+
tooltipAlign="left"
3705+
>
3706+
<ArrowRightIcon width={16} height={16} />
3707+
</IconBtn>
3708+
</div>
3709+
<div
3710+
ref={tabStripRef}
3711+
className={tabStripClass}
3712+
onDragOver={handleTabStripDragOver}
3713+
onDrop={handleTabStripDrop}
3714+
>
3715+
{tabItems.map((tab, i) => {
3716+
// Draw a subtle vertical separator between the last pinned
3717+
// tab and the first unpinned one (VSCode convention). The
3718+
// separator is a flex sibling, not a wrapper, so drag hit-
3719+
// detection on the tab itself is unchanged.
3720+
const prevPinned = i > 0 ? tabItems[i - 1].pinned : false
3721+
const needsSeparator = prevPinned && !tab.pinned
3722+
return (
3723+
<Fragment key={tab.path}>
3724+
{needsSeparator && (
3725+
<div
3726+
aria-hidden
3727+
className="mx-0.5 h-5 shrink-0 self-center border-l border-paper-300/70"
3728+
/>
3729+
)}
3730+
{renderTab(tab)}
3731+
</Fragment>
3732+
)
3733+
})}
3734+
</div>
3735+
</div>
3736+
)
3737+
36673738
return (
36683739
<section
36693740
ref={paneRootRef}
@@ -3691,69 +3762,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
36913762
setFocusedPanel('editor')
36923763
}}
36933764
>
3694-
{hasTabs && (
3695-
<div className={tabStripHeaderClass}>
3696-
<div className="flex shrink-0 items-center gap-0.5 self-center">
3697-
{!sidebarOpen && (
3698-
<IconBtn
3699-
title="Show sidebar (⌘1)"
3700-
onClick={toggleSidebar}
3701-
tooltipAlign="left"
3702-
>
3703-
<PanelLeftIcon width={16} height={16} />
3704-
</IconBtn>
3705-
)}
3706-
<IconBtn
3707-
title={labelWithShortcut(
3708-
'Go back',
3709-
getKeymapDisplay(tabNavOverrides, vimMode ? 'vim.historyBack' : 'global.historyBack')
3710-
)}
3711-
onClick={() => void jumpToPreviousNote()}
3712-
disabled={!canGoBack}
3713-
tooltipAlign="left"
3714-
>
3715-
<ArrowLeftIcon width={16} height={16} />
3716-
</IconBtn>
3717-
<IconBtn
3718-
title={labelWithShortcut(
3719-
'Go forward',
3720-
getKeymapDisplay(tabNavOverrides, vimMode ? 'vim.historyForward' : 'global.historyForward')
3721-
)}
3722-
onClick={() => void jumpToNextNote()}
3723-
disabled={!canGoForward}
3724-
tooltipAlign="left"
3725-
>
3726-
<ArrowRightIcon width={16} height={16} />
3727-
</IconBtn>
3728-
</div>
3729-
<div
3730-
ref={tabStripRef}
3731-
className={tabStripClass}
3732-
onDragOver={handleTabStripDragOver}
3733-
onDrop={handleTabStripDrop}
3734-
>
3735-
{tabItems.map((tab, i) => {
3736-
// Draw a subtle vertical separator between the last pinned
3737-
// tab and the first unpinned one (VSCode convention). The
3738-
// separator is a flex sibling, not a wrapper, so drag hit-
3739-
// detection on the tab itself is unchanged.
3740-
const prevPinned = i > 0 ? tabItems[i - 1].pinned : false
3741-
const needsSeparator = prevPinned && !tab.pinned
3742-
return (
3743-
<Fragment key={tab.path}>
3744-
{needsSeparator && (
3745-
<div
3746-
aria-hidden
3747-
className="mx-0.5 h-5 shrink-0 self-center border-l border-paper-300/70"
3748-
/>
3749-
)}
3750-
{renderTab(tab)}
3751-
</Fragment>
3752-
)
3753-
})}
3754-
</div>
3755-
</div>
3756-
)}
3765+
{hasTabs && !titlebarTabsActive && tabStrip}
3766+
{hasTabs && titlebarTabsActive && tabStripHost && createPortal(tabStrip, tabStripHost)}
37573767
{content && !zenMode && (
37583768
<header className="glass-header flex h-12 shrink-0 items-center justify-between gap-3 px-4">
37593769
<div className="flex min-w-0 flex-1 items-center gap-1">

packages/app-core/src/components/SettingsModal.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,8 @@ export function SettingsModal(): JSX.Element {
539539
const setHiddenWorkflowPresets = useStore((s) => s.setHiddenWorkflowPresets);
540540
const wrapTabs = useStore((s) => s.wrapTabs);
541541
const setWrapTabs = useStore((s) => s.setWrapTabs);
542+
const titlebarTabs = useStore((s) => s.titlebarTabs);
543+
const setTitlebarTabs = useStore((s) => s.setTitlebarTabs);
542544
const quickNoteDateTitle = useStore((s) => s.quickNoteDateTitle);
543545
const setQuickNoteDateTitle = useStore((s) => s.setQuickNoteDateTitle);
544546
const quickNoteTitlePrefix = useStore((s) => s.quickNoteTitlePrefix);
@@ -1355,6 +1357,13 @@ export function SettingsModal(): JSX.Element {
13551357
"Show /-separated tags as a collapsible tree in the sidebar and Tags view instead of a flat list.",
13561358
keywords: ["hierarchical", "tree", "tags", "nested", "hierarchy"],
13571359
},
1360+
{
1361+
id: "tabs-in-title-bar",
1362+
title: "Tabs in title bar",
1363+
description:
1364+
"Move the sidebar toggle and note tabs into the title bar when only one pane is open.",
1365+
keywords: ["title bar", "tabs", "sidebar toggle"],
1366+
},
13581367
],
13591368
content: (
13601369
<div className="space-y-6">
@@ -1768,6 +1777,13 @@ export function SettingsModal(): JSX.Element {
17681777
settingId="nested-tags"
17691778
onChange={setNestedTags}
17701779
/>
1780+
<ToggleRow
1781+
label="Tabs in title bar"
1782+
description="Move the sidebar toggle and note tabs into the title bar when only one pane is open. Hides the centered window title."
1783+
value={titlebarTabs}
1784+
settingId="tabs-in-title-bar"
1785+
onChange={setTitlebarTabs}
1786+
/>
17711787
</Section>
17721788

17731789
<Section

packages/app-core/src/components/Sidebar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@ export function Sidebar(): JSX.Element {
538538
const previewNote = useStore((s) => s.previewNote);
539539
const selectedPath = useStore((s) => s.selectedPath);
540540
const tabsEnabled = useStore((s) => s.tabsEnabled);
541+
const titlebarTabs = useStore((s) => s.titlebarTabs);
541542
const openNoteInTab = useStore((s) => s.openNoteInTab);
542543
const systemFolderLabels = useStore((s) => s.systemFolderLabels);
543544
const workspaceMode = useStore((s) => s.workspaceMode);
@@ -3104,9 +3105,11 @@ export function Sidebar(): JSX.Element {
31043105
>
31053106
<PlusIcon />
31063107
</IconBtn>
3107-
<IconBtn title="Hide sidebar (⌘1)" onClick={toggleSidebar}>
3108-
<PanelLeftIcon />
3109-
</IconBtn>
3108+
{!titlebarTabs && (
3109+
<IconBtn title="Hide sidebar (⌘1)" onClick={toggleSidebar}>
3110+
<PanelLeftIcon />
3111+
</IconBtn>
3112+
)}
31103113
</div>
31113114
</div>
31123115

0 commit comments

Comments
 (0)