Conversation
|
@tiensonqin @xyhp915 @RCmerci please review |
There was a problem hiding this comment.
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-tabsplugin (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.
| ;; 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))})))) |
There was a problem hiding this comment.
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.
| (or (= (.-tagName el) "TEXTAREA") | ||
| (= (.-tagName el) "INPUT") | ||
| (= (.-contentEditable el) "true")))) |
There was a problem hiding this comment.
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).
| (or (= (.-tagName el) "TEXTAREA") | |
| (= (.-tagName el) "INPUT") | |
| (= (.-contentEditable el) "true")))) | |
| (= (.-contentEditable el) "true"))) |
| (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 |
There was a problem hiding this comment.
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.
| (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."])] |
There was a problem hiding this comment.
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).
| (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)])] |
| (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 |
There was a problem hiding this comment.
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.
| "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})))))) |
There was a problem hiding this comment.
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.
| "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)))) |
| (tabs-handler/close-all-tabs!) | ||
| ;; load config | ||
| (repo-config-handler/restore-repo-config! graph) | ||
| (route-handler/redirect-to-home!) |
There was a problem hiding this comment.
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.
| (route-handler/redirect-to-home!) |
| (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!))) |
There was a problem hiding this comment.
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.
| (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!))))) |
| (state/pub-event! [:go/search]))} | ||
| (ui/icon "search" {:size ui/icon-size})])))]] | ||
|
|
||
| (tabs/tab-bar) |
There was a problem hiding this comment.
(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.
| (tabs/tab-bar) | |
| (when (tabs/tabs-enabled?) | |
| (tabs/tab-bar)) |
| (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))))) |
There was a problem hiding this comment.
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.
| (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)))))) |
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:
Demo
tabs-demo-c.mp4
Future scope include splitting vertically and horizontally