X Tutup
Skip to content

Feature: tabs#12397

Open
4shutosh wants to merge 11 commits intologseq:masterfrom
4shutosh:feature/tabs
Open

Feature: tabs#12397
4shutosh wants to merge 11 commits intologseq:masterfrom
4shutosh:feature/tabs

Conversation

@4shutosh
Copy link
Contributor

@4shutosh 4shutosh commented Feb 17, 2026

Logseq is too good of an app to not support tabs on it's own and rely on a plugin which tries to understand what's happening in the DOM

This PR aims to add native support for tabs


Specs:

  • cmd/ctrl + click will open the page in new tab
  • One page can have only one active tab
  • Keyboard shortcuts:
    • Cmd+1-9 - Switch to specific tab (9 = last)
    • Cmd+W - Close active tab
    • Cmd+Shift+[/] - Previous/next tab with wrap-around
  • Electron: Closes window when last tab closed in desktop app
  • Automatically uninstall conflicting plugin logseq-tabs
  • Auto Hide
    • When editor has focus, the tabs go invisible after 3 seconds, to avoid distractions and maintain cleaner look
    • Toggle in Settings → Editor → "Auto hide tabs when typing"

Demo

tabs-demo-c.mp4
image image

Future scope include splitting vertically and horizontally

@4shutosh 4shutosh marked this pull request as ready for review February 18, 2026 16:45
@4shutosh
Copy link
Contributor Author

@tiensonqin @xyhp915 @RCmerci please review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a native “tabs” feature to the Logseq frontend, integrating tab state with routing, adding keyboard shortcuts for tab management, and providing an optional “auto-hide tabs while typing” UI behavior. It also includes logic to remove a conflicting third-party plugin (logseq-tabs) once native tabs are available.

Changes:

  • Added tab state + operations (create/switch/close/reorder) and integrated tab updates into routing/history navigation.
  • Added keyboard shortcuts and UI components/styles for the new tab bar, plus a new editor setting for “auto hide tabs when typing”.
  • Added automatic uninstall behavior for the logseq-tabs plugin (Electron IPC path).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/resources/dicts/en.edn Adds command labels for tab-related shortcuts.
src/main/frontend/state/tabs.cljs Introduces tab state storage and basic tab list operations.
src/main/frontend/state.cljs Adds UI state + toggles for “auto-hide tabs when typing”.
src/main/frontend/spec/storage.cljc Adds storage spec + allowlist entry for the new UI preference key.
src/main/frontend/modules/shortcut/config.cljs Rebinds Cmd/Ctrl+W to close tab; adds tab switching shortcuts (1–9, prev/next).
src/main/frontend/handler/ui.cljs Persists and toggles the auto-hide-tabs setting.
src/main/frontend/handler/tabs.cljs Implements tab open/close/switch logic, including Electron last-tab behavior via shortcut.
src/main/frontend/handler/route.cljs Updates tab state during navigation and history-driven routing.
src/main/frontend/handler/plugin.cljs Adds auto-uninstall logic/notification for conflicting logseq-tabs plugin.
src/main/frontend/handler/events.cljs Resets tabs on graph switch (currently via close-all-tabs!).
src/main/frontend/handler/config.cljs No functional change (trailing newline).
src/main/frontend/handler.cljs Initializes tabs at app startup.
src/main/frontend/components/tabs.css Adds styling for the tab bar and its interactions.
src/main/frontend/components/tabs.cljs Adds the tab bar component, drag-reorder, and auto-hide while typing behavior.
src/main/frontend/components/settings.cljs Adds the “Auto hide tabs when typing” setting row.
src/main/frontend/components/page.cljs Adds “Remove icon” behavior in page title actions (appears unrelated to tabs).
src/main/frontend/components/left_sidebar.cljs Adds Cmd/Ctrl+click behavior to open pages/Journals in tabs.
src/main/frontend/components/header.css Adjusts header layout to accommodate a tabs bar region.
src/main/frontend/components/header.cljs Mounts the tab bar in the header and removes the header breadcrumb.
src/main/frontend/components/container.css Adjusts main content padding/layout (likely to fit new header/tabs layout).
src/main/frontend/components/container.cljs Wraps main content container to support new header/tabs layout structure.
src/main/frontend/components/block.cljs Adds Cmd/Ctrl+click behavior on page refs to open in a new tab.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +103 to +115
;; Update current active tab or switch to existing tab with this page
(when (and page (not skip-auto-tab?))
(let [page-uuid (:block/uuid page)
page-name-lc (:block/name page)
existing-tab (tabs-state/find-tab-by-page (or page-uuid page-name-lc))]
(if existing-tab
;; Tab exists - switch to it
(tabs-state/set-active-tab-id! (:id existing-tab))
;; No tab exists - update current active tab with new page
(tabs-state/update-active-tab! {:page-id (:db/id page)
:page-name page-name-lc
:page-uuid page-uuid
:title (or (:block/title page) page-name-lc (str page-uuid))}))))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redirect-to-page! now mutates tab state (switches to an existing tab or updates the active tab) when navigating, which is core navigation behavior and easy to regress. There are existing route handler tests, but none cover the new tab-related branching (e.g., switching to an existing tab vs updating the current tab, and the skip-auto-tab? escape hatch). Consider adding tests in frontend.handler.route-test (or similar) that assert the expected :tabs/* state changes for these navigation paths.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +19
(or (= (.-tagName el) "TEXTAREA")
(= (.-tagName el) "INPUT")
(= (.-contentEditable el) "true"))))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-hide logic treats any focused INPUT/TEXTAREA/contentEditable element as “the editor”, so focusing search fields, settings inputs, etc. will start the hide timer and may hide tabs unexpectedly. Since the spec says “when editor has focus”, consider narrowing this predicate to Logseq’s editor element(s) (e.g., by checking against the actual editor input from state, or requiring a specific container/selector).

Suggested change
(or (= (.-tagName el) "TEXTAREA")
(= (.-tagName el) "INPUT")
(= (.-contentEditable el) "true"))))
(= (.-contentEditable el) "true")))

Copilot uses AI. Check for mistakes.
Comment on lines +402 to 406
(tabs/tab-bar)

[:div.r.flex.drag-region.justify-between.items-center.gap-2.overflow-x-hidden.w-full
[:div.flex.flex-1
(block-breadcrumb (state/get-current-page))]
[:div.r.flex.drag-region.items-center.gap-2
[:div.flex.items-center
(when (and current-repo
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the header breadcrumb (block-breadcrumb) entirely, so the header no longer shows the block/page hierarchy context that previously appeared there. The PR description focuses on tabs and doesn’t mention removing breadcrumbs; if this is not intentional, consider restoring the breadcrumb (or moving it elsewhere) to avoid a UX regression.

Copilot uses AI. Check for mistakes.
Comment on lines +422 to +426
(defn auto-hide-tabs-typing-row [auto-hide-tabs-typing?]
(toggle "auto_hide_tabs_typing"
["Auto hide tabs when typing"
(ui/tooltip [:span.flex.px-2 (svg/info)]
[:span.block.w-64 "Auto hides the tabs when typing, visible again when you're trying to switch tabs: either using mouse pointer or keyboard shortcuts."])]
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new settings row uses hard-coded English strings for the label and tooltip text. Elsewhere in this file, user-facing settings labels/hints are consistently translated via t keys. Consider adding i18n keys for this setting label + hint and using t so non-English locales get translations (with the existing fallback to :en).

Suggested change
(defn auto-hide-tabs-typing-row [auto-hide-tabs-typing?]
(toggle "auto_hide_tabs_typing"
["Auto hide tabs when typing"
(ui/tooltip [:span.flex.px-2 (svg/info)]
[:span.block.w-64 "Auto hides the tabs when typing, visible again when you're trying to switch tabs: either using mouse pointer or keyboard shortcuts."])]
(defn auto-hide-tabs-typing-row [t auto-hide-tabs-typing?]
(toggle "auto_hide_tabs_typing"
[(t :settings-page/auto-hide-tabs-typing)
(ui/tooltip [:span.flex.px-2 (svg/info)]
[:span.block.w-64 (t :settings-page/auto-hide-tabs-typing-hint)])]

Copilot uses AI. Check for mistakes.
Comment on lines +239 to 250
(if (:logseq.property/icon (db/entity (:db/id page)))
(shui/button
{:variant :ghost
:size :sm
:class "px-2 py-0 h-6 text-xs text-muted-foreground"
:on-click (fn [_e]
(property-handler/set-block-property!
(:block/uuid page)
:logseq.property/icon
nil))}
"Remove icon")
(shui/button
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is scoped to tabs, but this hunk adds a new “Remove icon” button and changes the icon action behavior on pages. If this is intentional, it should be called out in the PR description; otherwise consider reverting/splitting into a separate PR to keep the tabs change focused and reduce review/rollback risk.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +124
"Open the all-journals page in a new tab or update current tab"
[]
;; Check if all-journals tab already exists
(let [existing-tab (tabs-state/find-tab-by-page "all-journals")
tabs (tabs-state/get-tabs)]
(if existing-tab
;; Switch to existing tab
(do
(tabs-state/set-active-tab-id! (:id existing-tab))
(route-handler/redirect! {:to :all-journals}))
;; Create new tab only if multiple tabs exist, otherwise update current
(if (> (count tabs) 1)
(let [tab-id (tabs-state/add-tab! {:page-id nil
:page-name "all-journals"
:page-uuid nil
:title "Journals"})]
(tabs-state/set-active-tab-id! tab-id)
(route-handler/redirect! {:to :all-journals})
tab-id)
;; Single tab - just update it
(do
(tabs-state/update-active-tab! {:page-id nil
:page-name "all-journals"
:page-uuid nil
:title "Journals"})
(route-handler/redirect! {:to :all-journals}))))))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open-all-journals-in-tab! only creates a new Journals tab when there are already more than 1 tab. With exactly one non-Journals tab open, Cmd/Ctrl+clicking Journals (which calls this handler) will update the current tab instead of opening a new tab, violating the spec that Cmd/Ctrl+click opens in a new tab. Consider always creating a new tab here (unless an existing Journals tab already exists), regardless of current tab count.

Suggested change
"Open the all-journals page in a new tab or update current tab"
[]
;; Check if all-journals tab already exists
(let [existing-tab (tabs-state/find-tab-by-page "all-journals")
tabs (tabs-state/get-tabs)]
(if existing-tab
;; Switch to existing tab
(do
(tabs-state/set-active-tab-id! (:id existing-tab))
(route-handler/redirect! {:to :all-journals}))
;; Create new tab only if multiple tabs exist, otherwise update current
(if (> (count tabs) 1)
(let [tab-id (tabs-state/add-tab! {:page-id nil
:page-name "all-journals"
:page-uuid nil
:title "Journals"})]
(tabs-state/set-active-tab-id! tab-id)
(route-handler/redirect! {:to :all-journals})
tab-id)
;; Single tab - just update it
(do
(tabs-state/update-active-tab! {:page-id nil
:page-name "all-journals"
:page-uuid nil
:title "Journals"})
(route-handler/redirect! {:to :all-journals}))))))
"Open the all-journals page in a new tab or switch to existing Journals tab"
[]
;; Check if all-journals tab already exists
(let [existing-tab (tabs-state/find-tab-by-page "all-journals")]
(if existing-tab
;; Switch to existing tab
(do
(tabs-state/set-active-tab-id! (:id existing-tab))
(route-handler/redirect! {:to :all-journals}))
;; No existing Journals tab - always create a new one
(let [tab-id (tabs-state/add-tab! {:page-id nil
:page-name "all-journals"
:page-uuid nil
:title "Journals"})]
(tabs-state/set-active-tab-id! tab-id)
(route-handler/redirect! {:to :all-journals})
tab-id))))

Copilot uses AI. Check for mistakes.
(tabs-handler/close-all-tabs!)
;; load config
(repo-config-handler/restore-repo-config! graph)
(route-handler/redirect-to-home!)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close-all-tabs! triggers a redirect to :all-journals, but graph-switch immediately calls route-handler/redirect-to-home! right after. This creates back-to-back redirects during graph switches (potentially affecting history, causing UI flicker, and doing extra work). Consider splitting close-all-tabs! into a “reset tab state” variant that does not navigate, and use that during graph switching.

Suggested change
(route-handler/redirect-to-home!)

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +51
(when (and (editor-element? (.-target e)) (not @typing?))
(when @hide-timer (js/clearTimeout @hide-timer))
(reset! hide-timer
(js/setTimeout
(fn []
(reset! typing? true)
(reset! hide-timer nil))
3000))))

(defn- on-doc-keydown [e]
;; Ctrl/Cmd combos are tab-switching shortcuts — reveal if hidden
(when (and @typing? (or (.-ctrlKey e) (.-metaKey e)))
(reveal-tabs!)))

(defn- on-doc-pointerdown [e]
;; Any interaction inside the left sidebar should reveal tabs
(when @typing?
(when-let [sidebar (.getElementById js/document "left-sidebar")]
(when (.contains sidebar (.-target e))
(reveal-tabs!)))))

(defn- on-tabs-mouse-enter [_e]
(when @typing?
(reveal-tabs!)))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The document-level focusin/keydown/pointerdown handlers update typing? and schedule/clear timers even when the “Auto hide tabs when typing” setting is off. This can leave typing? stuck true while disabled, so enabling the setting later may hide tabs immediately without the intended 3s delay. Consider gating these handlers on the setting (or resetting typing? when auto-hide? is false) so state/timers aren’t mutated while the feature is disabled.

Suggested change
(when (and (editor-element? (.-target e)) (not @typing?))
(when @hide-timer (js/clearTimeout @hide-timer))
(reset! hide-timer
(js/setTimeout
(fn []
(reset! typing? true)
(reset! hide-timer nil))
3000))))
(defn- on-doc-keydown [e]
;; Ctrl/Cmd combos are tab-switching shortcuts — reveal if hidden
(when (and @typing? (or (.-ctrlKey e) (.-metaKey e)))
(reveal-tabs!)))
(defn- on-doc-pointerdown [e]
;; Any interaction inside the left sidebar should reveal tabs
(when @typing?
(when-let [sidebar (.getElementById js/document "left-sidebar")]
(when (.contains sidebar (.-target e))
(reveal-tabs!)))))
(defn- on-tabs-mouse-enter [_e]
(when @typing?
(reveal-tabs!)))
(let [auto-hide? (tabs-state/auto-hide-tabs-when-typing?)]
(if auto-hide?
(when (and (editor-element? (.-target e)) (not @typing?))
(when @hide-timer (js/clearTimeout @hide-timer))
(reset! hide-timer
(js/setTimeout
(fn []
(reset! typing? true)
(reset! hide-timer nil))
3000)))
;; When auto-hide is disabled, ensure typing state/timer are cleared
(reveal-tabs!))))
(defn- on-doc-keydown [e]
;; Ctrl/Cmd combos are tab-switching shortcuts — reveal if hidden
(let [auto-hide? (tabs-state/auto-hide-tabs-when-typing?)]
(if auto-hide?
(when (and @typing? (or (.-ctrlKey e) (.-metaKey e)))
(reveal-tabs!))
;; If feature is disabled but typing? somehow remained true, normalize it.
(when @typing?
(reveal-tabs!)))))
(defn- on-doc-pointerdown [e]
;; Any interaction inside the left sidebar should reveal tabs
(let [auto-hide? (tabs-state/auto-hide-tabs-when-typing?)]
(if auto-hide?
(when @typing?
(when-let [sidebar (.getElementById js/document "left-sidebar")]
(when (.contains sidebar (.-target e))
(reveal-tabs!))))
;; If feature is disabled but typing? somehow remained true, normalize it.
(when @typing?
(reveal-tabs!)))))
(defn- on-tabs-mouse-enter [_e]
(let [auto-hide? (tabs-state/auto-hide-tabs-when-typing?)]
(if auto-hide?
(when @typing?
(reveal-tabs!))
;; If feature is disabled but typing? somehow remained true, normalize it.
(when @typing?
(reveal-tabs!)))))

Copilot uses AI. Check for mistakes.
(state/pub-event! [:go/search]))}
(ui/icon "search" {:size ui/icon-size})])))]]

(tabs/tab-bar)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(tabs/tab-bar) is rendered unconditionally in the header. On native mobile platforms tabs are meant to be disabled (tabs-enabled?), so this will still mount the component + global event listeners and may reserve header space even when there are no tabs. Consider rendering the tab bar only when tabs are enabled (and/or when there is at least one tab) to avoid layout/regression on iOS/Android native.

Suggested change
(tabs/tab-bar)
(when (tabs/tabs-enabled?)
(tabs/tab-bar))

Copilot uses AI. Check for mistakes.
Comment on lines +948 to +953
(when (util/electron?)
(ipc/ipc :uninstallMarketPlugin "logseq-tabs"))
(notification/show!
"The logseq-tabs plugin has been automatically uninstalled. Logseq now has built-in tabs support!"
:success
false)))))
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-uninstall notification message claims the plugin “has been automatically uninstalled” even on non-Electron platforms, where the code does not uninstall it (it only calls the IPC uninstall when Electron). Consider adjusting the message based on platform (e.g., “Please uninstall manually” on web) to avoid misleading users.

Suggested change
(when (util/electron?)
(ipc/ipc :uninstallMarketPlugin "logseq-tabs"))
(notification/show!
"The logseq-tabs plugin has been automatically uninstalled. Logseq now has built-in tabs support!"
:success
false)))))
(if (util/electron?)
(do
(ipc/ipc :uninstallMarketPlugin "logseq-tabs")
(notification/show!
"The logseq-tabs plugin has been automatically uninstalled. Logseq now has built-in tabs support!"
:success
false))
(notification/show!
"Logseq now has built-in tabs support. Please uninstall the logseq-tabs plugin, which is now deprecated."
:success
false))))))

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

X Tutup