From 9b181b6f0bb12f072c5e5454088bb5b4164897f2 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 13 Apr 2026 09:46:13 -0700 Subject: [PATCH 1/4] feat(desktop): Cmd+Alt+Arrow moves focus between v2 panes (#3403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): directional pane focus via Cmd+Alt+Arrow (v2) Adds spatial pane navigation to v2 workspaces: Cmd+Alt+Arrow jumps focus to the visually adjacent pane in that direction (no wrap at edges). Reclaims Cmd+Alt+Arrow from the retired PREV/NEXT_TAB and PREV/NEXT_WORKSPACE shortcuts — tabs still cycle via Ctrl+Tab and both tabs and workspaces keep Cmd+Alt+1..9 jump-to-N. The spatial neighbor util walks up the LayoutNode path to find the deepest ancestor split whose axis matches the arrow, then descends into the sibling subtree picking the near-edge leaf. * fix(panes): preserve cross-axis alignment during spatial neighbor descent findEdgePaneId previously fell through to node.first on any perpendicular split encountered while descending into the sibling subtree, losing the source pane's row/column position. In a 2x2 grid this caused the directional focus move to land on the wrong pane depending on how the grid was grouped in the layout tree (e.g. in a rows-first 2x2, down from top-right landed on bottom-left). Track the source pane's path below the pivot ancestor as an alignment path and consume one entry per perpendicular-split descent, so the descent mirrors the source's cross-axis choices. Adds unit tests for getSpatialNeighborPaneId covering single pane, simple horizontal/vertical splits, edge no-wrap, and both groupings of the 2x2 grid. * refactor(desktop): drop linear PREV/NEXT_PANE now that directional nav exists The 4-way FOCUS_PANE_{LEFT,RIGHT,UP,DOWN} shortcuts supersede linear pane cycling. Removing PREV/NEXT_PANE also frees ctrl+shift+alt+Arrow on Windows/Linux, which dodges the Intel HD Graphics screen-rotation driver shortcut that steals ctrl+alt+Arrow at the OS level. - Remove PREV_PANE/NEXT_PANE from the hotkey registry and both v1/v2 handler sites. - Remap FOCUS_PANE_{LEFT,RIGHT,UP,DOWN} on Windows/Linux from ctrl+alt+Arrow to ctrl+shift+alt+Arrow. - Delete now-unused getNextPaneId/getPreviousPaneId helpers from renderer/stores/tabs/utils.ts. Users who relied on linear cycling can re-add it via settings once the unbound-default hotkey support lands as a follow-up. --- apps/desktop/src/renderer/hotkeys/registry.ts | 38 +++-- .../useWorkspaceHotkeys.ts | 59 +++----- .../workspace/$workspaceId/page.tsx | 33 +---- .../desktop/src/renderer/stores/tabs/utils.ts | 36 ----- packages/panes/src/core/store/utils/index.ts | 3 + .../panes/src/core/store/utils/utils.test.ts | 133 ++++++++++++++++++ packages/panes/src/core/store/utils/utils.ts | 63 +++++++++ packages/panes/src/index.ts | 2 + 8 files changed, 256 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts index 4f0cffe8b44..c387ac2086c 100644 --- a/apps/desktop/src/renderer/hotkeys/registry.ts +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -354,27 +354,45 @@ export const HOTKEYS_REGISTRY = { category: "Terminal", description: "Focus the next tab in the active workspace", }, - // FORK NOTE: upstream renamed PREV_PANE → FOCUS_PANE_LEFT in #3403 (PR#3). - // Keep PREV_PANE until that cherry-pick is applied. - PREV_PANE: { + FOCUS_PANE_LEFT: { key: { - mac: "meta+shift+left", + mac: "meta+alt+left", windows: "ctrl+shift+alt+left", linux: "ctrl+shift+alt+left", }, - label: "Previous Pane", + label: "Focus Pane Left", category: "Terminal", - description: "Focus the previous pane in the current tab", + description: "Focus the pane to the left of the active pane", }, - NEXT_PANE: { + FOCUS_PANE_RIGHT: { key: { - mac: "meta+shift+right", + mac: "meta+alt+right", windows: "ctrl+shift+alt+right", linux: "ctrl+shift+alt+right", }, - label: "Next Pane", + label: "Focus Pane Right", category: "Terminal", - description: "Focus the next pane in the current tab", + description: "Focus the pane to the right of the active pane", + }, + FOCUS_PANE_UP: { + key: { + mac: "meta+alt+up", + windows: "ctrl+shift+alt+up", + linux: "ctrl+shift+alt+up", + }, + label: "Focus Pane Up", + category: "Terminal", + description: "Focus the pane above the active pane", + }, + FOCUS_PANE_DOWN: { + key: { + mac: "meta+alt+down", + windows: "ctrl+shift+alt+down", + linux: "ctrl+shift+alt+down", + }, + label: "Focus Pane Down", + category: "Terminal", + description: "Focus the pane below the active pane", }, JUMP_TO_TAB_1: { key: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index ef5bf804a13..c791446a404 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -1,4 +1,8 @@ -import type { WorkspaceStore } from "@superset/panes"; +import { + type FocusDirection, + getSpatialNeighborPaneId, + type WorkspaceStore, +} from "@superset/panes"; import { useCallback } from "react"; import { useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; @@ -83,23 +87,6 @@ export function useWorkspaceHotkeys({ } }); - useHotkey("PREV_TAB", () => { - const state = store.getState(); - if (!state.activeTabId || state.tabs.length === 0) return; - const index = state.tabs.findIndex((t) => t.id === state.activeTabId); - const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; - state.setActiveTab(state.tabs[prevIndex].id); - }); - - useHotkey("NEXT_TAB", () => { - const state = store.getState(); - if (!state.activeTabId || state.tabs.length === 0) return; - const index = state.tabs.findIndex((t) => t.id === state.activeTabId); - const nextIndex = - index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; - state.setActiveTab(state.tabs[nextIndex].id); - }); - useHotkey("PREV_TAB_ALT", () => { const state = store.getState(); if (!state.activeTabId || state.tabs.length === 0) return; @@ -138,25 +125,25 @@ export function useWorkspaceHotkeys({ // --- Pane management --- - useHotkey("PREV_PANE", () => { - const state = store.getState(); - const tab = state.getActiveTab(); - if (!tab || !tab.activePaneId) return; - const paneIds = Object.keys(tab.panes); - const index = paneIds.indexOf(tab.activePaneId); - const prevIndex = index <= 0 ? paneIds.length - 1 : index - 1; - state.setActivePane({ tabId: tab.id, paneId: paneIds[prevIndex] }); - }); + const moveFocusDirectional = useCallback( + (dir: FocusDirection) => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab || !tab.activePaneId) return; + const neighbor = getSpatialNeighborPaneId( + tab.layout, + tab.activePaneId, + dir, + ); + if (neighbor) state.setActivePane({ tabId: tab.id, paneId: neighbor }); + }, + [store], + ); - useHotkey("NEXT_PANE", () => { - const state = store.getState(); - const tab = state.getActiveTab(); - if (!tab || !tab.activePaneId) return; - const paneIds = Object.keys(tab.panes); - const index = paneIds.indexOf(tab.activePaneId); - const nextIndex = index >= paneIds.length - 1 ? 0 : index + 1; - state.setActivePane({ tabId: tab.id, paneId: paneIds[nextIndex] }); - }); + useHotkey("FOCUS_PANE_LEFT", () => moveFocusDirectional("left")); + useHotkey("FOCUS_PANE_RIGHT", () => moveFocusDirectional("right")); + useHotkey("FOCUS_PANE_UP", () => moveFocusDirectional("up")); + useHotkey("FOCUS_PANE_DOWN", () => moveFocusDirectional("down")); useHotkey("SPLIT_AUTO", () => { const state = store.getState(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index bfacf4487b3..c29798494cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -20,7 +20,6 @@ import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; import { NotFound } from "renderer/routes/not-found"; @@ -58,8 +57,6 @@ import { extractPaneIdsFromLayout, findPanePath, getFirstPaneId, - getNextPaneId, - getPreviousPaneId, resolveActiveTabIdForWorkspace, } from "renderer/stores/tabs/utils"; import { @@ -514,30 +511,6 @@ export function WorkspacePage({ useHotkey("JUMP_TO_TAB_8", () => switchToTab(7), { enabled: isActive }); useHotkey("JUMP_TO_TAB_9", () => switchToTab(8), { enabled: isActive }); - useHotkey( - "PREV_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); - if (prevPaneId) { - setFocusedPane(activeTabId, prevPaneId); - } - }, - { enabled: isActive }, - ); - - useHotkey( - "NEXT_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); - if (nextPaneId) { - setFocusedPane(activeTabId, nextPaneId); - } - }, - { enabled: isActive }, - ); - // Open in last used app shortcut const projectId = workspace?.projectId; const { data: defaultApp } = electronTrpc.projects.getDefaultApp.useQuery( @@ -826,7 +799,9 @@ export function WorkspacePage({ { enabled: isActive }, ); - // Navigate to previous workspace (⌘↑) + // FORK NOTE: v1 workspace uses tRPC-based prev/next workspace navigation. + // Upstream removed these handlers in #3403 (they use DashboardSidebar's + // flattenedWorkspaces instead). Fork keeps tRPC approach for v1. const getPreviousWorkspace = electronTrpc.workspaces.getPreviousWorkspace.useQuery( { id: workspaceId }, @@ -843,7 +818,6 @@ export function WorkspacePage({ { enabled: isActive }, ); - // Navigate to next workspace (⌘↓) const getNextWorkspace = electronTrpc.workspaces.getNextWorkspace.useQuery( { id: workspaceId }, { enabled: !!workspaceId }, @@ -859,6 +833,7 @@ export function WorkspacePage({ { enabled: isActive }, ); + return (
diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 9ca0d5e0050..6a1b3d78a25 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -627,42 +627,6 @@ export const getFirstPaneId = (layout: MosaicNode): string => { return getFirstPaneId(layout.first); }; -/** - * Gets the next pane ID in visual order (left-to-right, top-to-bottom), - * wrapping around to the first if at the end. - */ -export const getNextPaneId = ( - layout: MosaicNode, - currentPaneId: string, -): string | null => { - const paneIds = getPaneIdsInVisualOrder(layout); - if (paneIds.length <= 1) return null; - - const currentIndex = paneIds.indexOf(currentPaneId); - if (currentIndex === -1) return paneIds[0]; - - const nextIndex = (currentIndex + 1) % paneIds.length; - return paneIds[nextIndex]; -}; - -/** - * Gets the previous pane ID in visual order (right-to-left, bottom-to-top), - * wrapping around to the last if at the beginning. - */ -export const getPreviousPaneId = ( - layout: MosaicNode, - currentPaneId: string, -): string | null => { - const paneIds = getPaneIdsInVisualOrder(layout); - if (paneIds.length <= 1) return null; - - const currentIndex = paneIds.indexOf(currentPaneId); - if (currentIndex === -1) return paneIds[paneIds.length - 1]; - - const prevIndex = (currentIndex - 1 + paneIds.length) % paneIds.length; - return paneIds[prevIndex]; -}; - /** * Gets the adjacent pane ID for focus fallback when a pane is closed. * Prefers the next pane in visual order, falls back to previous if at the end. diff --git a/packages/panes/src/core/store/utils/index.ts b/packages/panes/src/core/store/utils/index.ts index 3b4da77a2bd..46eb9cafa0b 100644 --- a/packages/panes/src/core/store/utils/index.ts +++ b/packages/panes/src/core/store/utils/index.ts @@ -1,11 +1,14 @@ +export type { FocusDirection } from "./utils"; export { equalizeAllSplits, findFirstPaneId, findPaneInLayout, + findPanePath, findSiblingPaneId, generateId, getNodeAtPath, getOtherBranch, + getSpatialNeighborPaneId, positionToDirection, removePaneFromLayout, replacePaneIdInLayout, diff --git a/packages/panes/src/core/store/utils/utils.test.ts b/packages/panes/src/core/store/utils/utils.test.ts index 22fc3c0f2f4..46d060e3eeb 100644 --- a/packages/panes/src/core/store/utils/utils.test.ts +++ b/packages/panes/src/core/store/utils/utils.test.ts @@ -6,6 +6,7 @@ import { findPaneInLayout, getNodeAtPath, getOtherBranch, + getSpatialNeighborPaneId, positionToDirection, removePaneFromLayout, replacePaneIdInLayout, @@ -320,3 +321,135 @@ describe("positionToDirection", () => { expect(positionToDirection("bottom")).toBe("vertical"); }); }); + +describe("getSpatialNeighborPaneId", () => { + // +---+ + // | a | + // +---+ + it("returns null when there is only one pane", () => { + const layout: LayoutNode = { type: "pane", paneId: "a" }; + expect(getSpatialNeighborPaneId(layout, "a", "left")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "a", "right")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "a", "up")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "a", "down")).toBeNull(); + }); + + // +---+---+ + // | a | b | + // +---+---+ + it("moves between siblings in a horizontal split", () => { + const layout: LayoutNode = { + type: "split", + direction: "horizontal", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, + }; + expect(getSpatialNeighborPaneId(layout, "a", "right")).toBe("b"); + expect(getSpatialNeighborPaneId(layout, "b", "left")).toBe("a"); + expect(getSpatialNeighborPaneId(layout, "a", "up")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "a", "down")).toBeNull(); + }); + + // +---+ + // | a | + // +---+ + // | b | + // +---+ + it("moves between siblings in a vertical split", () => { + const layout: LayoutNode = { + type: "split", + direction: "vertical", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, + }; + expect(getSpatialNeighborPaneId(layout, "a", "down")).toBe("b"); + expect(getSpatialNeighborPaneId(layout, "b", "up")).toBe("a"); + expect(getSpatialNeighborPaneId(layout, "a", "right")).toBeNull(); + }); + + // +---+---+ + // | a | b | + // +---+---+ + it("does not wrap around at the layout edge", () => { + const layout: LayoutNode = { + type: "split", + direction: "horizontal", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, + }; + expect(getSpatialNeighborPaneId(layout, "a", "left")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "b", "right")).toBeNull(); + }); + + // 2x2 grid built "rows first": + // outer = vertical split { top row / bot row } + // top row = horizontal split { a | b } + // bot row = horizontal split { c | d } + // + // +---+---+ + // | a | b | + // +---+---+ + // | c | d | + // +---+---+ + it("preserves column alignment in a rows-first 2x2", () => { + const layout: LayoutNode = { + type: "split", + direction: "vertical", + first: { + type: "split", + direction: "horizontal", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, + }, + second: { + type: "split", + direction: "horizontal", + first: { type: "pane", paneId: "c" }, + second: { type: "pane", paneId: "d" }, + }, + }; + expect(getSpatialNeighborPaneId(layout, "b", "down")).toBe("d"); + expect(getSpatialNeighborPaneId(layout, "d", "up")).toBe("b"); + expect(getSpatialNeighborPaneId(layout, "c", "up")).toBe("a"); + expect(getSpatialNeighborPaneId(layout, "a", "down")).toBe("c"); + expect(getSpatialNeighborPaneId(layout, "a", "right")).toBe("b"); + expect(getSpatialNeighborPaneId(layout, "d", "left")).toBe("c"); + expect(getSpatialNeighborPaneId(layout, "a", "left")).toBeNull(); + expect(getSpatialNeighborPaneId(layout, "b", "up")).toBeNull(); + }); + + // 2x2 grid built "columns first": + // outer = horizontal split { left col / right col } + // left col = vertical split { a / c } + // right col = vertical split { b / d } + // + // +---+---+ + // | a | b | + // +---+---+ + // | c | d | + // +---+---+ + it("preserves row alignment in a columns-first 2x2", () => { + const layout: LayoutNode = { + type: "split", + direction: "horizontal", + first: { + type: "split", + direction: "vertical", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "c" }, + }, + second: { + type: "split", + direction: "vertical", + first: { type: "pane", paneId: "b" }, + second: { type: "pane", paneId: "d" }, + }, + }; + expect(getSpatialNeighborPaneId(layout, "c", "right")).toBe("d"); + expect(getSpatialNeighborPaneId(layout, "d", "left")).toBe("c"); + expect(getSpatialNeighborPaneId(layout, "a", "right")).toBe("b"); + expect(getSpatialNeighborPaneId(layout, "b", "left")).toBe("a"); + expect(getSpatialNeighborPaneId(layout, "a", "down")).toBe("c"); + expect(getSpatialNeighborPaneId(layout, "b", "down")).toBe("d"); + }); +}); diff --git a/packages/panes/src/core/store/utils/utils.ts b/packages/panes/src/core/store/utils/utils.ts index 6bc4df9a64f..3c1a0c7a52e 100644 --- a/packages/panes/src/core/store/utils/utils.ts +++ b/packages/panes/src/core/store/utils/utils.ts @@ -169,3 +169,66 @@ export function positionToDirection(position: SplitPosition): SplitDirection { export function generateId(prefix: string): string { return `${prefix}-${crypto.randomUUID()}`; } + +export type FocusDirection = "left" | "right" | "up" | "down"; + +export function findPanePath( + node: LayoutNode, + paneId: string, + currentPath: SplitBranch[] = [], +): SplitPath | null { + if (node.type === "pane") { + return node.paneId === paneId ? currentPath : null; + } + const firstPath = findPanePath(node.first, paneId, [...currentPath, "first"]); + if (firstPath) return firstPath; + return findPanePath(node.second, paneId, [...currentPath, "second"]); +} + +// Descent into a sibling subtree once a pivot split has been chosen. +// - On splits matching the arrow axis: pick the near edge (first for +// right/down, second for left/up), preserving alignmentPath. +// - On perpendicular splits: consume one entry from alignmentPath to +// preserve the source pane's cross-axis position; if exhausted, fall +// back to `first`. +function findEdgePaneId( + node: LayoutNode, + dir: FocusDirection, + alignmentPath: SplitPath = [], +): string | null { + if (node.type === "pane") return node.paneId; + const axis: SplitDirection = + dir === "left" || dir === "right" ? "horizontal" : "vertical"; + if (node.direction === axis) { + const nearEdge: SplitBranch = + dir === "right" || dir === "down" ? "first" : "second"; + return findEdgePaneId(node[nearEdge], dir, alignmentPath); + } + const [alignedBranch = "first", ...rest] = alignmentPath; + return findEdgePaneId(node[alignedBranch], dir, rest); +} + +export function getSpatialNeighborPaneId( + root: LayoutNode, + paneId: string, + dir: FocusDirection, +): string | null { + const path = findPanePath(root, paneId); + if (!path) return null; + + const axis: SplitDirection = + dir === "left" || dir === "right" ? "horizontal" : "vertical"; + const wantSecond = dir === "right" || dir === "down"; + + for (let i = path.length - 1; i >= 0; i--) { + const ancestor = getNodeAtPath(root, path.slice(0, i)); + if (!ancestor || ancestor.type !== "split") continue; + if (ancestor.direction !== axis) continue; + const cameFrom = path[i]; + if (wantSecond && cameFrom !== "first") continue; + if (!wantSecond && cameFrom !== "second") continue; + const siblingBranch: SplitBranch = wantSecond ? "second" : "first"; + return findEdgePaneId(ancestor[siblingBranch], dir, path.slice(i + 1)); + } + return null; +} diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts index 8f1844c72cb..4e08a915239 100644 --- a/packages/panes/src/index.ts +++ b/packages/panes/src/index.ts @@ -5,6 +5,8 @@ export type { WorkspaceStore, } from "./core/store"; export { createWorkspaceStore } from "./core/store"; +export type { FocusDirection } from "./core/store/utils"; +export { getSpatialNeighborPaneId } from "./core/store/utils"; export type { ContextMenuActionConfig, PaneActionConfig, From 8e96cbcc2b586feebbea960e63064e66e3f0335f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 13 Apr 2026 10:17:32 -0700 Subject: [PATCH 2/4] feat(desktop): v2 diff viewer opens in its own tab + collapsed tab/pane title resolution (#3420) * feat(desktop): v2 diff viewer opens in its own tab + pane-derived tab titles openDiffPane now scans all tabs for an existing diff pane (focus + scroll) and falls back to addTab, so clicking a file in the Changes sidebar never hijacks the focused editor tab. Collapses tab/pane title resolution onto a single canonical field: PaneDefinition.getTitle is tightened to (pane) => string, file's rich JSX moves into the existing renderTitle hook, and a new resolveTabTitle helper powers both the tab bar and the "Move to Tab" context menu. tab.titleOverride is reserved for user renames; every auto-default caller is stripped and multi-pane tabs fall back to "Tab N" instead of "tab-". * feat(desktop): pane-derived tab titles reserve tab.titleOverride for user renames Preset execution and workspace bootstrap were baking preset.name / terminal.label onto tab.titleOverride, which meant those names persisted misleadingly after a tab was split and couldn't be distinguished from a real user rename. Move both to the pane's titleOverride instead, and teach resolveTabTitle to read pane.titleOverride before falling through to getTitle() for single-pane tabs. tab.titleOverride is now written only by the tab-bar rename action; splitting a named tab flips the label to "Tab N" while the pane keeps its name in its header, and user renames still win over everything. * fix(desktop): browser.getTitle falls back to "Browser" for about:blank Unnavigated browser panes had their pane header fall through to pane.id (a raw UUID) because getTitle returned undefined for about:blank and the old titleOverride: "Browser" default was removed along with the other auto-default titleOverride writes. * fix(desktop): browser tab title uses URL.host to preserve port URL.hostname drops the port, so localhost:3000 and localhost:4000 both rendered as "localhost" in the tab bar. URL.host keeps the port when one is explicitly set. --- .../$pendingId/buildSetupPaneLayout.ts | 2 +- .../useDefaultContextMenuActions.tsx | 14 ++++-- .../components/BrowserPane/BrowserPane.tsx | 14 ------ .../components/BrowserPane/index.ts | 1 - .../hooks/usePaneRegistry/usePaneRegistry.tsx | 15 +++++-- .../useV2PresetExecution.ts | 45 ++++++++++++------- .../useWorkspaceHotkeys.ts | 3 -- .../v2-workspace/$workspaceId/page.tsx | 40 +++++++---------- packages/panes/src/core/store/store.test.ts | 2 - packages/panes/src/core/store/store.ts | 3 +- packages/panes/src/index.ts | 2 +- .../react/components/Workspace/Workspace.tsx | 4 +- .../components/Tab/components/Pane/Pane.tsx | 2 +- .../src/react/components/Workspace/index.ts | 1 + .../Workspace/utils/resolveTabTitle.ts | 18 ++++++++ packages/panes/src/react/index.ts | 2 +- packages/panes/src/react/types.ts | 3 +- 17 files changed, 95 insertions(+), 76 deletions(-) create mode 100644 packages/panes/src/react/components/Workspace/utils/resolveTabTitle.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts index 6a8abfb8746..8221d5ff994 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts @@ -17,7 +17,6 @@ export function buildSetupPaneLayout( const tabId = `tab-${crypto.randomUUID()}`; return { id: tabId, - titleOverride: t.label, createdAt: Date.now(), activePaneId: paneId, layout: { type: "pane" as const, paneId }, @@ -25,6 +24,7 @@ export function buildSetupPaneLayout( [paneId]: { id: paneId, kind: "terminal", + titleOverride: t.label, data: { terminalId: t.id } as TerminalPaneData, }, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx index 22da979ff64..20fda20fd90 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -1,4 +1,9 @@ -import type { ContextMenuActionConfig, RendererContext } from "@superset/panes"; +import { + type ContextMenuActionConfig, + type PaneRegistry, + type RendererContext, + resolveTabTitle, +} from "@superset/panes"; import { useMemo } from "react"; import { LuColumns2, @@ -18,7 +23,9 @@ import type { TerminalPaneData, } from "../../types"; -export function useDefaultContextMenuActions(): ContextMenuActionConfig[] { +export function useDefaultContextMenuActions( + paneRegistry: PaneRegistry, +): ContextMenuActionConfig[] { const splitDownShortcut = useHotkeyDisplay("SPLIT_DOWN").text; const splitRightShortcut = useHotkeyDisplay("SPLIT_RIGHT").text; const splitWithChatShortcut = useHotkeyDisplay("SPLIT_WITH_CHAT").text; @@ -115,7 +122,7 @@ export function useDefaultContextMenuActions(): ContextMenuActionConfig[] = otherTabs.map((tab) => ({ key: `move-to-${tab.id}`, - label: tab.titleOverride ?? tab.id, + label: resolveTabTitle(tab, tabs, paneRegistry), onSelect: () => { ctx.store .getState() @@ -154,6 +161,7 @@ export function useDefaultContextMenuActions(): ContextMenuActionConfig, -): string | undefined { - const browser = getSingleBrowserPane(tab); - if (!browser) return undefined; - if (browser.data.pageTitle) return browser.data.pageTitle; - if (browser.data.url && browser.data.url !== "about:blank") { - try { - return new URL(browser.data.url).hostname; - } catch {} - } - return undefined; -} - export function renderBrowserTabIcon(tab: Tab) { const browser = getSingleBrowserPane(tab); if (!browser?.data.faviconUrl) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts index ac9bea3ed43..e6b7ed03397 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts @@ -1,7 +1,6 @@ export { BrowserPane, BrowserPaneToolbar, - getBrowserTabTitle, renderBrowserTabIcon, } from "./BrowserPane"; export { browserRuntimeRegistry } from "./browserRuntimeRegistry"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 065b95ba054..39ea0de2916 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -120,7 +120,8 @@ export function usePaneRegistry( const name = getFileName(data.filePath); return ; }, - getTitle: (ctx: RendererContext) => { + getTitle: (pane) => getFileName((pane.data as FilePaneData).filePath), + renderTitle: (ctx: RendererContext) => { const data = ctx.pane.data as FilePaneData; const name = data.displayName ?? getFileName(data.filePath); return ( @@ -256,9 +257,15 @@ export function usePaneRegistry( }, browser: { getIcon: () => , - getTitle: (ctx: RendererContext) => { - const data = ctx.pane.data as BrowserPaneData; - return data.pageTitle || data.url; + getTitle: (pane) => { + const data = pane.data as BrowserPaneData; + if (data.pageTitle) return data.pageTitle; + if (data.url && data.url !== "about:blank") { + try { + return new URL(data.url).host; + } catch {} + } + return "Browser"; }, renderPane: (ctx: RendererContext) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts index f3ff170904b..d31b098ea73 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -10,9 +10,13 @@ import { filterMatchingPresetsForProject } from "shared/preset-project-targeting import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData, TerminalPaneData } from "../../types"; -function makeTerminalPane(terminalId: string): CreatePaneInput { +function makeTerminalPane( + terminalId: string, + titleOverride?: string, +): CreatePaneInput { return { kind: "terminal", + titleOverride, data: { terminalId } as TerminalPaneData, }; } @@ -89,8 +93,7 @@ export function useV2PresetExecution({ preset.commands[0] as string, ); state.addTab({ - titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(id)], + panes: [makeTerminalPane(id, preset.name || undefined)], }); break; } @@ -99,16 +102,22 @@ export function useV2PresetExecution({ const ids = await Promise.all( preset.commands.map((cmd) => createSessionWithCommand(cmd)), ); - const panes = ids.map((id) => makeTerminalPane(id)); + const panes = ids.map((id) => + makeTerminalPane(id, preset.name || undefined), + ); state.addTab({ - titleOverride: preset.name || "Terminal", panes: panes.length > 0 ? (panes as [ CreatePaneInput, ...CreatePaneInput[], ]) - : [makeTerminalPane(crypto.randomUUID())], + : [ + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + ), + ], }); break; } @@ -119,8 +128,9 @@ export function useV2PresetExecution({ ); for (let i = 0; i < ids.length; i++) { state.addTab({ - titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(ids[i] as string)], + panes: [ + makeTerminalPane(ids[i] as string, preset.name || undefined), + ], }); } break; @@ -132,14 +142,13 @@ export function useV2PresetExecution({ ); if (!activeTabId) { state.addTab({ - titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(id)], + panes: [makeTerminalPane(id, preset.name || undefined)], }); break; } state.addPane({ tabId: activeTabId, - pane: makeTerminalPane(id), + pane: makeTerminalPane(id, preset.name || undefined), }); break; } @@ -149,23 +158,29 @@ export function useV2PresetExecution({ preset.commands.map((cmd) => createSessionWithCommand(cmd)), ); if (!activeTabId) { - const panes = ids.map((id) => makeTerminalPane(id)); + const panes = ids.map((id) => + makeTerminalPane(id, preset.name || undefined), + ); state.addTab({ - titleOverride: preset.name || "Terminal", panes: panes.length > 0 ? (panes as [ CreatePaneInput, ...CreatePaneInput[], ]) - : [makeTerminalPane(crypto.randomUUID())], + : [ + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + ), + ], }); break; } for (const id of ids) { state.addPane({ tabId: activeTabId, - pane: makeTerminalPane(id), + pane: makeTerminalPane(id, preset.name || undefined), }); } break; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index c791446a404..2de8bc35427 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -39,7 +39,6 @@ export function useWorkspaceHotkeys({ useHotkey("NEW_GROUP", () => { store.getState().addTab({ - titleOverride: "Terminal", panes: [ { kind: "terminal", @@ -51,14 +50,12 @@ export function useWorkspaceHotkeys({ useHotkey("NEW_CHAT", () => { store.getState().addTab({ - titleOverride: "Chat", panes: [{ kind: "chat", data: { sessionId: null } as ChatPaneData }], }); }); useHotkey("NEW_BROWSER", () => { store.getState().addTab({ - titleOverride: "Browser", panes: [ { kind: "browser", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index a91c27ea351..1a51b49e435 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -38,10 +38,7 @@ import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; -import { - getBrowserTabTitle, - renderBrowserTabIcon, -} from "./hooks/usePaneRegistry/components/BrowserPane"; +import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -145,7 +142,7 @@ function WorkspaceContent({ projectId, }); const paneRegistry = usePaneRegistry(workspaceId); - const defaultContextMenuActions = useDefaultContextMenuActions(); + const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); const rightSidebarOpenViewWidth = useRightSidebarOpenViewWidth(); const utils = electronTrpc.useUtils(); const { data: showPresetsBar } = @@ -219,7 +216,6 @@ function WorkspaceContent({ const state = store.getState(); if (openInNewTab) { state.addTab({ - titleOverride: filePath.split(/[/\\]/).pop() ?? "File", panes: [ { kind: "file", @@ -261,7 +257,6 @@ function WorkspaceContent({ hasChanges: false, } as FilePaneData, }, - tabTitle: "Files", }); if (!activeTabId || !activePanePath) { @@ -291,9 +286,8 @@ function WorkspaceContent({ const openDiffPane = useCallback( (filePath: string) => { const state = store.getState(); - const activeTab = state.tabs.find((t) => t.id === state.activeTabId); - if (activeTab) { - for (const pane of Object.values(activeTab.panes)) { + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { if (pane.kind !== "diff") continue; const prev = pane.data as DiffPaneData; state.setPaneData({ @@ -303,19 +297,21 @@ function WorkspaceContent({ path: filePath, } as PaneViewerData, }); - state.setActivePane({ tabId: activeTab.id, paneId: pane.id }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); return; } } - state.openPane({ - pane: { - kind: "diff", - data: { - path: filePath, - collapsedFiles: [], - } as DiffPaneData, - }, - tabTitle: "Changes", + state.addTab({ + panes: [ + { + kind: "diff", + data: { + path: filePath, + collapsedFiles: [], + } as DiffPaneData, + }, + ], }); }, [store], @@ -323,7 +319,6 @@ function WorkspaceContent({ const addTerminalTab = useCallback(() => { store.getState().addTab({ - titleOverride: "Terminal", panes: [ { kind: "terminal", @@ -337,7 +332,6 @@ function WorkspaceContent({ const addChatTab = useCallback(() => { store.getState().addTab({ - titleOverride: "Chat", panes: [ { kind: "chat", @@ -349,7 +343,6 @@ function WorkspaceContent({ const addBrowserTab = useCallback(() => { store.getState().addTab({ - titleOverride: "Browser", panes: [ { kind: "browser", @@ -469,7 +462,6 @@ function WorkspaceContent({ registry={paneRegistry} paneActions={defaultPaneActions} contextMenuActions={defaultContextMenuActions} - getTabTitle={getBrowserTabTitle} renderTabIcon={renderBrowserTabIcon} renderBelowTabBar={() => ( { store.getState().openPane({ pane: tp("p1", "opened"), - tabTitle: "My Tab", }); expect(store.getState().tabs).toHaveLength(1); - expect(store.getState().tabs[0]?.titleOverride).toBe("My Tab"); expect(store.getState().getActivePane()?.pane.data.label).toBe("opened"); }); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 06442cf2a33..34778b9cb25 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -121,7 +121,7 @@ export interface WorkspaceStore extends WorkspaceState { newPane: CreatePaneInput; }) => void; - openPane: (args: { pane: CreatePaneInput; tabTitle?: string }) => void; + openPane: (args: { pane: CreatePaneInput }) => void; splitPane: (args: { tabId: string; @@ -406,7 +406,6 @@ export function createWorkspaceStore( // No tab → create one if (!tab || !activeTabId) { get().addTab({ - titleOverride: args.tabTitle, panes: [args.pane], }); return; diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts index 4e08a915239..a4070aa2dcf 100644 --- a/packages/panes/src/index.ts +++ b/packages/panes/src/index.ts @@ -17,7 +17,7 @@ export type { TabContext, WorkspaceProps, } from "./react"; -export { Workspace } from "./react"; +export { resolveTabTitle, Workspace } from "./react"; export type { LayoutNode, Pane, diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 34847171540..f0d3a72dd87 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -5,6 +5,7 @@ import type { Pane } from "../../../types"; import type { WorkspaceProps } from "../../types"; import { Tab } from "./components/Tab"; import { TabBar } from "./components/TabBar"; +import { resolveTabTitle } from "./utils/resolveTabTitle"; export function Workspace({ store, @@ -12,7 +13,6 @@ export function Workspace({ className, renderTabAccessory, renderTabIcon, - getTabTitle, renderEmptyState, renderAddTabMenu, renderBelowTabBar, @@ -78,7 +78,7 @@ export function Workspace({ onReorderTab={(tabId, toIndex) => store.getState().reorderTab({ tabId, toIndex }) } - getTabTitle={(tab) => tab.titleOverride ?? getTabTitle?.(tab) ?? tab.id} + getTabTitle={(tab) => resolveTabTitle(tab, tabs, registry)} renderTabIcon={renderTabIcon} renderAddTabMenu={renderAddTabMenu} renderTabAccessory={renderTabAccessory} diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx index 1ac744d2c51..22096e349fe 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx @@ -211,7 +211,7 @@ export function Pane({ } const title = definition - ? (pane.titleOverride ?? definition.getTitle?.(context) ?? pane.id) + ? (pane.titleOverride ?? definition.getTitle?.(pane) ?? pane.id) : `Unknown: ${pane.kind}`; const icon = definition?.getIcon?.(context); const titleContent = definition?.renderTitle?.(context); diff --git a/packages/panes/src/react/components/Workspace/index.ts b/packages/panes/src/react/components/Workspace/index.ts index e904b737efa..ff44d323596 100644 --- a/packages/panes/src/react/components/Workspace/index.ts +++ b/packages/panes/src/react/components/Workspace/index.ts @@ -1 +1,2 @@ +export { resolveTabTitle } from "./utils/resolveTabTitle"; export { Workspace } from "./Workspace"; diff --git a/packages/panes/src/react/components/Workspace/utils/resolveTabTitle.ts b/packages/panes/src/react/components/Workspace/utils/resolveTabTitle.ts new file mode 100644 index 00000000000..388466a791a --- /dev/null +++ b/packages/panes/src/react/components/Workspace/utils/resolveTabTitle.ts @@ -0,0 +1,18 @@ +import type { Tab } from "../../../../types"; +import type { PaneRegistry } from "../../../types"; + +export function resolveTabTitle( + tab: Tab, + tabs: Tab[], + registry: PaneRegistry, +): string { + if (tab.titleOverride) return tab.titleOverride; + const panes = Object.values(tab.panes); + const onlyPane = panes.length === 1 ? panes[0] : undefined; + if (onlyPane) { + const fromPane = + onlyPane.titleOverride ?? registry[onlyPane.kind]?.getTitle?.(onlyPane); + if (fromPane) return fromPane; + } + return `Tab ${tabs.indexOf(tab) + 1}`; +} diff --git a/packages/panes/src/react/index.ts b/packages/panes/src/react/index.ts index c54e0d47c8f..4d825812559 100644 --- a/packages/panes/src/react/index.ts +++ b/packages/panes/src/react/index.ts @@ -1,4 +1,4 @@ -export { Workspace } from "./components/Workspace"; +export { resolveTabTitle, Workspace } from "./components/Workspace"; export type { ContextMenuActionConfig, PaneActionConfig, diff --git a/packages/panes/src/react/types.ts b/packages/panes/src/react/types.ts index d21c1f8d0f2..ab4b3058de0 100644 --- a/packages/panes/src/react/types.ts +++ b/packages/panes/src/react/types.ts @@ -58,7 +58,7 @@ export interface RendererContext { export interface PaneDefinition { renderPane(context: RendererContext): ReactNode; - getTitle?(context: RendererContext): ReactNode; + getTitle?(pane: Pane): string | undefined; getIcon?(context: RendererContext): ReactNode; renderTitle?(context: RendererContext): ReactNode; renderHeaderExtras?(context: RendererContext): ReactNode; @@ -88,7 +88,6 @@ export interface WorkspaceProps { className?: string; renderTabAccessory?: (tab: Tab) => ReactNode; renderTabIcon?: (tab: Tab) => ReactNode; - getTabTitle?: (tab: Tab) => string | undefined; renderEmptyState?: () => ReactNode; renderAddTabMenu?: () => ReactNode; renderBelowTabBar?: () => ReactNode; From ce0fb6134bfe8dc043c84e6753fba44bf3a7f42f Mon Sep 17 00:00:00 2001 From: MocA-Love <56917703+MocA-Love@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:41:33 +0900 Subject: [PATCH 3/4] fix(desktop): post-cherry-pick fixes for spatial pane navigation + diff viewer Follow-up fixes for #3403 and #3420 cherry-picks: hotkeys/types.ts: - Widen PlatformKey and HotkeyDefinition.key to allow null per platform (mirrors upstream #3422; needed here because v1 workspace uses fork's tRPC-based PREV/NEXT_WORKSPACE handlers that reference null-bound keys) hotkeys/registry.ts: - Re-add PREV_TAB, NEXT_TAB, PREV_WORKSPACE, NEXT_WORKSPACE as null-bound (matches upstream #3422 final state; deleted by #3403's auto-merge but fork still needs the IDs for v1 tRPC-based workspace nav handlers) hotkeys/useRecordHotkeys.ts: - Null-guard canonicalizeChord(defaultKey) so recording a new chord for an unbound hotkey doesn't throw routes/workspace/$workspaceId/page.tsx: - Restore navigateToWorkspace import (removed by cherry-pick auto-merge); fork's tRPC-based PREV/NEXT_WORKSPACE handlers still need it routes/v2-workspace/$workspaceId/page.tsx: - Remove tabTitle argument from openPane call (deprecated by #3420; pane-level titleOverride handles tab titles now via resolveTabTitle) FORK NOTE: the registry entries and types.ts widening overlap with PR#2 (#3422 cherry-pick). When both PRs merge, git detects identical changes and auto-resolves. --- .../_dashboard/v2-workspace/$workspaceId/page.tsx | 1 - .../_authenticated/_dashboard/workspace/$workspaceId/page.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 1a51b49e435..3a3acd2514f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -205,7 +205,6 @@ function WorkspaceContent({ displayName, } as FilePaneData, }, - tabTitle: "Files", }); }, [store], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index c29798494cd..610def264bb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -20,6 +20,7 @@ import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; import { NotFound } from "renderer/routes/not-found"; @@ -833,7 +834,6 @@ export function WorkspacePage({ { enabled: isActive }, ); - return (
From d26a7c9811606765d9ab0ffde1d867ab5df688cb Mon Sep 17 00:00:00 2001 From: MocA-Love <56917703+MocA-Love@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:05:52 +0900 Subject: [PATCH 4/4] fix(desktop): address Codex review comments on PR#3 1. Parse file tab titles with Windows path separators (Codex P2) - getFileName in usePaneRegistry.tsx split on / only, regressing Windows paths like C:\repo\foo.ts which would render as the full path. - Use split(/[/\\]/) to handle both separators cross-platform. 2. Restore PREV_TAB/NEXT_TAB handlers in v2 useWorkspaceHotkeys (Codex P2) - #3403 (cherry-picked here) removed these handlers, but #3422 (PR#2) restored them as null-bound in the registry. Without callbacks, users rebinding these hotkeys in Settings would press them to no effect. - Add handlers matching upstream c925f4d4a's restoration so override-based tab navigation works in v2 workspaces. --- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 2 +- .../useWorkspaceHotkeys/useWorkspaceHotkeys.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 39ea0de2916..49b9b266c65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -45,7 +45,7 @@ import { FilePane } from "./components/FilePane"; import { TerminalPane } from "./components/TerminalPane"; function getFileName(filePath: string): string { - return filePath.split("/").pop() ?? filePath; + return filePath.split(/[/\\]/).pop() || filePath; } const MOD_KEY = navigator.platform.toLowerCase().includes("mac") diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 2de8bc35427..f8fb8103799 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -84,6 +84,23 @@ export function useWorkspaceHotkeys({ } }); + useHotkey("PREV_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; + state.setActiveTab(state.tabs[prevIndex].id); + }); + + useHotkey("NEXT_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = + index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; + state.setActiveTab(state.tabs[nextIndex].id); + }); + useHotkey("PREV_TAB_ALT", () => { const state = store.getState(); if (!state.activeTabId || state.tabs.length === 0) return;