Skip to content

Commit 0a1dca2

Browse files
committed
feat: tabs in title bar
1 parent e535bd0 commit 0a1dca2

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)
@@ -3441,11 +3444,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
34413444
el?.scrollIntoView({ inline: 'nearest', block: 'nearest' })
34423445
}, [activeTab, hasTabs, wrapTabs, tabStripMeasureKey])
34433446

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

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