mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
WIP: floating terminal mixed tabs
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# Review Context
|
||||
|
||||
## Branch Info
|
||||
|
||||
- Base: origin/main (`95526a42c4ab06518c7cd44577ff2ed270a0edf3`)
|
||||
- Current: `Jinwoo-H/floating-terminal-improvements`
|
||||
|
||||
## Changed Files Summary
|
||||
|
||||
- M `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx`
|
||||
- M `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`
|
||||
- M `src/renderer/src/store/slices/browser.test.ts`
|
||||
- M `src/renderer/src/store/slices/browser.ts`
|
||||
- M `src/renderer/src/store/slices/editor.test.ts`
|
||||
- M `src/renderer/src/store/slices/editor.ts`
|
||||
|
||||
## Changed Line Ranges (PR Scope)
|
||||
|
||||
<!-- In scope: issues on these lines OR caused by these changes. Out of scope: unrelated pre-existing issues -->
|
||||
|
||||
| File | Changed Lines |
|
||||
| --- | --- |
|
||||
| `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx` | 1-3, 6-7, 18-22, 31-35, 37-41, 45-47, 63-65, 67, 72-75, 139-150, 157-204, 263-275, 277-288, 296-300, 303-305, 307, 309-312, 316, 404-405, 497-499 |
|
||||
| `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx` | 1-7, 9, 12-22, 24, 33-34, 36-41, 53-57, 76-80, 83, 85-89, 93-96, 109, 120-234, 258, 260, 263-265, 267-272, 275, 278-282, 317-335, 337-365, 367-384, 386-387, 389-410, 415-422, 426, 428-439, 443, 447, 449-463, 467-472, 475, 580-581, 584-585, 588-591, 597-622, 635-654, 656-683, 686, 724, 728-757 |
|
||||
| `src/renderer/src/store/slices/browser.test.ts` | 12, 184-199 |
|
||||
| `src/renderer/src/store/slices/browser.ts` | 16, 422-423, 425 |
|
||||
| `src/renderer/src/store/slices/editor.test.ts` | 12, 255-282 |
|
||||
| `src/renderer/src/store/slices/editor.ts` | 28, 498-519, 849, 1059 |
|
||||
|
||||
## Review Standards Reference
|
||||
|
||||
- Follow `/review-code` standards
|
||||
- Follow `/review-algorithm-architecture` standards
|
||||
- Focus on correctness, security, performance, maintainability
|
||||
- Priority levels: Critical > High > Medium > Low
|
||||
|
||||
## File Categories
|
||||
|
||||
### Frontend/UI
|
||||
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx`
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`
|
||||
- `src/renderer/src/store/slices/browser.test.ts`
|
||||
- `src/renderer/src/store/slices/browser.ts`
|
||||
- `src/renderer/src/store/slices/editor.test.ts`
|
||||
- `src/renderer/src/store/slices/editor.ts`
|
||||
|
||||
## Skipped Issues (Do Not Re-validate)
|
||||
|
||||
<!-- Issues validated but deemed not worth fixing. Do not re-validate these in future iterations. -->
|
||||
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
|
||||
<!-- NOTE: Skips should be RARE - only purely cosmetic issues with no functional impact -->
|
||||
|
||||
None.
|
||||
|
||||
## Iteration State
|
||||
|
||||
Current iteration: 1
|
||||
Last completed phase: Setup
|
||||
Files fixed this iteration: []
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: these tests mock the floating panel's
|
||||
* React/store environment directly so close and bootstrap behavior can be
|
||||
* asserted without mounting the full Electron renderer. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
type EffectCallback = () => void | (() => void)
|
||||
|
||||
@@ -11,6 +15,11 @@ type ReactElementLike = {
|
||||
|
||||
type FloatingPanelStoreState = {
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
browserTabsByWorktree: Record<string, BrowserTab[]>
|
||||
browserPagesByWorkspace: Record<string, unknown[]>
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
openFiles: OpenFile[]
|
||||
activeTabIdByWorktree: Record<string, string | null>
|
||||
expandedPaneByTabId: Record<string, boolean>
|
||||
createTab: (
|
||||
@@ -19,12 +28,23 @@ type FloatingPanelStoreState = {
|
||||
shellOverride?: string,
|
||||
options?: { activate?: boolean }
|
||||
) => TerminalTab
|
||||
createBrowserTab: (
|
||||
worktreeId: string,
|
||||
url: string,
|
||||
options?: { title?: string; targetGroupId?: string }
|
||||
) => BrowserTab
|
||||
closeTab: (tabId: string) => void
|
||||
setActiveTabForWorktree: (worktreeId: string, tabId: string) => void
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
closeBrowserTab: (tabId: string) => void
|
||||
closeFile: (fileId: string) => void
|
||||
markFileDirty: (fileId: string, dirty: boolean) => void
|
||||
activateTab: (tabId: string) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
|
||||
pinFile: (fileId: string, tabId?: string) => void
|
||||
openFile: (file: unknown, options?: unknown) => void
|
||||
browserDefaultUrl: string
|
||||
tabBarOrderByWorktree: Record<string, string[]>
|
||||
settings: { floatingTerminalCwd?: string }
|
||||
}
|
||||
@@ -40,13 +60,19 @@ const storeBox = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateTab: vi.fn(),
|
||||
closeBrowserTab: vi.fn(),
|
||||
closeFile: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
createBrowserTab: vi.fn(),
|
||||
createTab: vi.fn(),
|
||||
focusTerminalTabSurface: vi.fn(),
|
||||
getFloatingTerminalCwd: vi.fn(),
|
||||
getInstallStatus: vi.fn(),
|
||||
setActiveTabForWorktree: vi.fn(),
|
||||
setTabBarOrder: vi.fn(),
|
||||
markFileDirty: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
pinFile: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setTabColor: vi.fn(),
|
||||
setTabCustomTitle: vi.fn(),
|
||||
setTabPaneExpanded: vi.fn()
|
||||
@@ -110,12 +136,72 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/BrowserPane', () => ({
|
||||
default: function BrowserPane() {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/editor/EditorPanel', () => ({
|
||||
default: function EditorPanel() {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: function Button() {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: function Dialog(props: { children?: unknown }) {
|
||||
return props.children
|
||||
},
|
||||
DialogContent: function DialogContent(props: { children?: unknown }) {
|
||||
return props.children
|
||||
},
|
||||
DialogDescription: function DialogDescription(props: { children?: unknown }) {
|
||||
return props.children
|
||||
},
|
||||
DialogFooter: function DialogFooter(props: { children?: unknown }) {
|
||||
return props.children
|
||||
},
|
||||
DialogHeader: function DialogHeader(props: { children?: unknown }) {
|
||||
return props.children
|
||||
},
|
||||
DialogTitle: function DialogTitle(props: { children?: unknown }) {
|
||||
return props.children
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal/useTerminalSaveDialog', () => ({
|
||||
useTerminalSaveDialog: () => ({
|
||||
handleSaveDialogCancel: vi.fn(),
|
||||
handleSaveDialogDiscard: vi.fn(),
|
||||
handleSaveDialogSave: vi.fn(),
|
||||
requestCloseFile: mocks.closeFile,
|
||||
saveDialogFile: null,
|
||||
saveDialogFileId: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/create-untitled-markdown', () => ({
|
||||
createUntitledMarkdownFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ipc-error', () => ({
|
||||
extractIpcErrorMessage: (_err: unknown, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: mocks.focusTerminalTabSurface
|
||||
}))
|
||||
@@ -174,7 +260,32 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
||||
|
||||
function setFloatingTabs(tabs: TerminalTab[]): void {
|
||||
const state = storeBox.state as FloatingPanelStoreState
|
||||
const groupId = 'floating-group'
|
||||
const unifiedTabs = tabs.map<Tab>((tab, index) => ({
|
||||
id: tab.id,
|
||||
entityId: tab.id,
|
||||
groupId,
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: tab.title,
|
||||
customLabel: tab.customTitle,
|
||||
color: tab.color,
|
||||
sortOrder: index,
|
||||
createdAt: tab.createdAt
|
||||
}))
|
||||
state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }
|
||||
state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: unifiedTabs }
|
||||
state.groupsByWorktree = {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: [
|
||||
{
|
||||
id: groupId,
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeTabId: unifiedTabs[0]?.id ?? null,
|
||||
tabOrder: unifiedTabs.map((tab) => tab.id),
|
||||
recentTabIds: unifiedTabs.map((tab) => tab.id)
|
||||
}
|
||||
]
|
||||
}
|
||||
state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }
|
||||
state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) }
|
||||
}
|
||||
@@ -182,15 +293,27 @@ function setFloatingTabs(tabs: TerminalTab[]): void {
|
||||
function resetStore(tabs: TerminalTab[] = []): void {
|
||||
storeBox.state = {
|
||||
tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs },
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
groupsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
openFiles: [],
|
||||
activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null },
|
||||
expandedPaneByTabId: {},
|
||||
activateTab: mocks.activateTab,
|
||||
closeBrowserTab: mocks.closeBrowserTab,
|
||||
closeFile: mocks.closeFile,
|
||||
createTab: mocks.createTab,
|
||||
createBrowserTab: mocks.createBrowserTab,
|
||||
closeTab: mocks.closeTab,
|
||||
setActiveTabForWorktree: mocks.setActiveTabForWorktree,
|
||||
setTabBarOrder: mocks.setTabBarOrder,
|
||||
markFileDirty: mocks.markFileDirty,
|
||||
openFile: mocks.openFile,
|
||||
pinFile: mocks.pinFile,
|
||||
setActiveTab: mocks.setActiveTab,
|
||||
setTabCustomTitle: mocks.setTabCustomTitle,
|
||||
setTabColor: mocks.setTabColor,
|
||||
setTabPaneExpanded: mocks.setTabPaneExpanded,
|
||||
browserDefaultUrl: 'about:blank',
|
||||
tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) },
|
||||
settings: { floatingTerminalCwd: '~' }
|
||||
} satisfies FloatingPanelStoreState
|
||||
@@ -278,10 +401,8 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
await renderPanel(true)
|
||||
runEffects()
|
||||
expect(mocks.createTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith(
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
'created-tab'
|
||||
)
|
||||
expect(mocks.createTab).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab')
|
||||
|
||||
await renderPanel(true)
|
||||
runEffects()
|
||||
@@ -373,6 +494,9 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
;(storeBox.state as FloatingPanelStoreState).tabBarOrderByWorktree = {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: ['tab-c', 'tab-a', 'tab-b']
|
||||
}
|
||||
;(storeBox.state as FloatingPanelStoreState).groupsByWorktree[
|
||||
FLOATING_TERMINAL_WORKTREE_ID
|
||||
][0].tabOrder = ['tab-c', 'tab-a', 'tab-b']
|
||||
|
||||
const element = await renderPanel(true)
|
||||
const tabBar = findByTypeName(element, 'TabBar')
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
/* eslint-disable max-lines -- Why: the floating panel owns window chrome,
|
||||
* resizing, orchestration setup, and mixed terminal/browser/editor tab
|
||||
* handling in one surface so the floating worktree does not drift from the
|
||||
* main tab model while still keeping the DOM-mounted panes local. */
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import BrowserPane from '@/components/browser-pane/BrowserPane'
|
||||
import TabBar from '@/components/tab-bar/TabBar'
|
||||
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
|
||||
import TerminalPane from '@/components/terminal-pane/TerminalPane'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useTerminalSaveDialog } from '@/components/terminal/useTerminalSaveDialog'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import {
|
||||
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
|
||||
ORCHESTRATION_SETUP_STATE_EVENT,
|
||||
@@ -11,12 +30,18 @@ import {
|
||||
notifyOrchestrationSetupStateChanged
|
||||
} from '@/lib/orchestration-setup-state'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type {
|
||||
BrowserTab as BrowserTabState,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TerminalTab
|
||||
} from '../../../../shared/types'
|
||||
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
|
||||
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
|
||||
import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
|
||||
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
|
||||
export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton'
|
||||
import {
|
||||
clampFloatingTerminalBounds,
|
||||
@@ -25,6 +50,11 @@ import {
|
||||
type FloatingTerminalPanelBounds
|
||||
} from './floating-terminal-panel-bounds'
|
||||
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
|
||||
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
|
||||
const EMPTY_GROUPS: TabGroup[] = []
|
||||
const EMPTY_UNIFIED_TABS: Tab[] = []
|
||||
|
||||
const EditorPanel = lazy(() => import('@/components/editor/EditorPanel'))
|
||||
|
||||
type FloatingTerminalPanelProps = {
|
||||
open: boolean
|
||||
@@ -43,16 +73,27 @@ export function FloatingTerminalPanel({
|
||||
onOpenChange
|
||||
}: FloatingTerminalPanelProps): React.JSX.Element | null {
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
|
||||
const setTabBarOrder = useAppStore((s) => s.setTabBarOrder)
|
||||
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
const markFileDirty = useAppStore((s) => s.markFileDirty)
|
||||
const activateTab = useAppStore((s) => s.activateTab)
|
||||
const setActiveTab = useAppStore((s) => s.setActiveTab)
|
||||
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((s) => s.setTabColor)
|
||||
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
|
||||
const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID])
|
||||
const pinFile = useAppStore((s) => s.pinFile)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~')
|
||||
|
||||
const [cwd, setCwd] = useState<string | null>(null)
|
||||
@@ -65,6 +106,7 @@ export function FloatingTerminalPanel({
|
||||
const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
|
||||
const normalizedInitialBoundsRef = useRef(false)
|
||||
const previousOpenRef = useRef(false)
|
||||
const pendingLastEditorCloseRef = useRef(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
@@ -75,7 +117,116 @@ export function FloatingTerminalPanel({
|
||||
} | null>(null)
|
||||
|
||||
const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
|
||||
const activeTabId = activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? tabs[0]?.id ?? null
|
||||
const browserTabs = browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
|
||||
const groups = groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
|
||||
const unifiedTabs = unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
|
||||
const floatingFiles = useMemo(
|
||||
() => openFiles.filter((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID),
|
||||
[openFiles]
|
||||
)
|
||||
const activeGroup = useMemo(
|
||||
() =>
|
||||
groups.find((group) => group.activeTabId != null) ??
|
||||
(unifiedTabs[0]
|
||||
? (groups.find((group) => group.id === unifiedTabs[0].groupId) ?? null)
|
||||
: null),
|
||||
[groups, unifiedTabs]
|
||||
)
|
||||
const groupTabs = useMemo(
|
||||
() => (activeGroup ? unifiedTabs.filter((tab) => tab.groupId === activeGroup.id) : unifiedTabs),
|
||||
[activeGroup, unifiedTabs]
|
||||
)
|
||||
const activeTab = useMemo(
|
||||
() =>
|
||||
(activeGroup?.activeTabId
|
||||
? groupTabs.find((tab) => tab.id === activeGroup.activeTabId)
|
||||
: null) ??
|
||||
groupTabs[0] ??
|
||||
null,
|
||||
[activeGroup, groupTabs]
|
||||
)
|
||||
const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null
|
||||
const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null
|
||||
const activeEditorUnifiedId =
|
||||
activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser'
|
||||
? activeTab.id
|
||||
: null
|
||||
const activeEditorFileId =
|
||||
activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser'
|
||||
? activeTab.entityId
|
||||
: null
|
||||
const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs])
|
||||
const terminalItems = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((tab) => tab.contentType === 'terminal')
|
||||
.map((tab) => {
|
||||
const terminalTab = terminalTabById.get(tab.entityId)
|
||||
return terminalTab
|
||||
? {
|
||||
...terminalTab,
|
||||
unifiedTabId: tab.id,
|
||||
title: tab.label,
|
||||
customTitle: tab.customLabel ?? terminalTab.customTitle,
|
||||
color: tab.color ?? terminalTab.color
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((tab): tab is TerminalTab & { unifiedTabId: string } => tab !== null),
|
||||
[groupTabs, terminalTabById]
|
||||
)
|
||||
const browserItems = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((tab) => tab.contentType === 'browser')
|
||||
.map((tab) => {
|
||||
const browserTab = browserTabs.find((candidate) => candidate.id === tab.entityId)
|
||||
return browserTab ? { ...browserTab, tabId: tab.id } : null
|
||||
})
|
||||
.filter((tab): tab is BrowserTabState & { tabId: string } => tab !== null),
|
||||
[browserTabs, groupTabs]
|
||||
)
|
||||
const editorItems = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser')
|
||||
.map((tab) => {
|
||||
const file = floatingFiles.find((candidate) => candidate.id === tab.entityId)
|
||||
return file ? { ...file, tabId: tab.id } : null
|
||||
})
|
||||
.filter((file): file is OpenFile & { tabId: string } => file !== null),
|
||||
[floatingFiles, groupTabs]
|
||||
)
|
||||
const tabBarOrder = useMemo(
|
||||
() =>
|
||||
(activeGroup?.tabOrder ?? []).map((tabId) => {
|
||||
const tab = groupTabs.find((candidate) => candidate.id === tabId)
|
||||
return tab?.contentType === 'terminal' || tab?.contentType === 'browser'
|
||||
? tab.entityId
|
||||
: tabId
|
||||
}),
|
||||
[activeGroup, groupTabs]
|
||||
)
|
||||
const activeBrowserTab = activeBrowserId
|
||||
? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null)
|
||||
: null
|
||||
const activeEditorFile = activeEditorFileId
|
||||
? (floatingFiles.find((file) => file.id === activeEditorFileId) ?? null)
|
||||
: null
|
||||
const activeTabType =
|
||||
activeTab?.contentType === 'browser'
|
||||
? 'browser'
|
||||
: activeTab?.contentType === 'terminal'
|
||||
? 'terminal'
|
||||
: 'editor'
|
||||
const {
|
||||
saveDialogFileId,
|
||||
saveDialogFile,
|
||||
requestCloseFile,
|
||||
handleSaveDialogSave,
|
||||
handleSaveDialogDiscard,
|
||||
handleSaveDialogCancel
|
||||
} = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || normalizedInitialBoundsRef.current || typeof window === 'undefined') {
|
||||
@@ -99,27 +250,31 @@ export function FloatingTerminalPanel({
|
||||
useEffect(() => {
|
||||
const opened = open && !previousOpenRef.current
|
||||
previousOpenRef.current = open
|
||||
// Why: zero terminal tabs only means "bootstrap" when the panel is newly
|
||||
// Why: zero renderable tabs only means "bootstrap" when the panel is newly
|
||||
// opened. Later zero-tab states are intentional closes or PTY exits.
|
||||
if (!opened || tabs.length > 0) {
|
||||
if (!opened || unifiedTabs.length > 0) {
|
||||
return
|
||||
}
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
|
||||
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
|
||||
}, [createTab, open, setActiveTabForWorktree, tabs.length])
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null,
|
||||
[activeTabId, tabs]
|
||||
)
|
||||
const activeFloatingTabId = activeTab?.id ?? null
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
focusTerminalTabSurface(tab.id)
|
||||
}, [createTab, open, unifiedTabs.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !activeFloatingTabId) {
|
||||
if (!open || !activeTerminalId) {
|
||||
return
|
||||
}
|
||||
focusTerminalTabSurface(activeFloatingTabId)
|
||||
}, [activeFloatingTabId, open])
|
||||
focusTerminalTabSurface(activeTerminalId)
|
||||
}, [activeTerminalId, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || saveDialogFileId !== null || !pendingLastEditorCloseRef.current) {
|
||||
return
|
||||
}
|
||||
if (unifiedTabs.length === 0) {
|
||||
pendingLastEditorCloseRef.current = false
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [onOpenChange, open, saveDialogFileId, unifiedTabs.length])
|
||||
|
||||
const refreshOrchestrationSetupVisibility = useCallback(async (): Promise<void> => {
|
||||
if (isOrchestrationSetupDismissed()) {
|
||||
@@ -154,73 +309,162 @@ export function FloatingTerminalPanel({
|
||||
}
|
||||
}, [refreshOrchestrationSetupVisibility])
|
||||
|
||||
const createFloatingTab = useCallback(() => {
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
|
||||
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
|
||||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
const validIds = new Set(currentTabs.map((entry) => entry.id))
|
||||
const order = stored.filter((id) => validIds.has(id) && id !== tab.id)
|
||||
for (const entry of currentTabs) {
|
||||
if (entry.id !== tab.id && !order.includes(entry.id)) {
|
||||
order.push(entry.id)
|
||||
const activateFloatingItem = useCallback(
|
||||
(visibleId: string) => {
|
||||
const item = resolveGroupTabFromVisibleId(groupTabs, visibleId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
}
|
||||
order.push(tab.id)
|
||||
setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order)
|
||||
focusTerminalTabSurface(tab.id)
|
||||
}, [createTab, setActiveTabForWorktree, setTabBarOrder])
|
||||
activateTab(item.id)
|
||||
if (item.contentType === 'terminal') {
|
||||
setActiveTab(item.entityId)
|
||||
focusTerminalTabSurface(item.entityId)
|
||||
} else if (item.contentType === 'browser') {
|
||||
const workspace = useAppStore
|
||||
.getState()
|
||||
.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find(
|
||||
(tab) => tab.id === item.entityId
|
||||
)
|
||||
if (workspace?.activePageId && window.api?.browser) {
|
||||
void window.api.browser.notifyActiveTabChanged({ browserPageId: workspace.activePageId })
|
||||
}
|
||||
}
|
||||
},
|
||||
[activateTab, groupTabs, setActiveTab]
|
||||
)
|
||||
|
||||
const closeFloatingTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const createFloatingTerminalTab = useCallback(
|
||||
(shellOverride?: string) => {
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride, {
|
||||
activate: true
|
||||
})
|
||||
focusTerminalTabSurface(tab.id)
|
||||
},
|
||||
[activeGroup, createTab]
|
||||
)
|
||||
|
||||
const createFloatingBrowserTab = useCallback(() => {
|
||||
const url = browserDefaultUrl ?? 'about:blank'
|
||||
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, {
|
||||
title: 'New Browser Tab',
|
||||
focusAddressBar: true,
|
||||
targetGroupId: activeGroup?.id
|
||||
})
|
||||
}, [activeGroup, browserDefaultUrl, createBrowserTab])
|
||||
|
||||
const createFloatingMarkdownTab = useCallback(() => {
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const fileInfo = await createUntitledMarkdownFile(
|
||||
cwd,
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined,
|
||||
settings
|
||||
)
|
||||
openFile(fileInfo, {
|
||||
preview: false,
|
||||
targetGroupId: activeGroup?.id,
|
||||
suppressActiveRuntimeFallback: true
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
|
||||
}
|
||||
})()
|
||||
}, [activeGroup, cwd, openFile, settings])
|
||||
|
||||
const closeFloatingItem = useCallback(
|
||||
(visibleId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
const isClosingLastTab = currentTabs.length === 1 && currentTabs[0]?.id === tabId
|
||||
closeTab(tabId)
|
||||
const currentGroupTabs = activeGroup
|
||||
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.groupId === activeGroup.id
|
||||
)
|
||||
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
|
||||
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
const isClosingLastTab = currentGroupTabs.length === 1
|
||||
if (item.contentType === 'terminal') {
|
||||
closeTab(item.entityId)
|
||||
} else if (item.contentType === 'browser') {
|
||||
destroyWorkspaceWebviews(browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
} else {
|
||||
requestCloseFile(item.entityId)
|
||||
if (state.openFiles.find((file) => file.id === item.entityId)?.isDirty) {
|
||||
pendingLastEditorCloseRef.current = isClosingLastTab
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isClosingLastTab) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
},
|
||||
[closeTab, onOpenChange]
|
||||
[
|
||||
activeGroup,
|
||||
browserPagesByWorkspace,
|
||||
closeBrowserTab,
|
||||
closeTab,
|
||||
onOpenChange,
|
||||
requestCloseFile
|
||||
]
|
||||
)
|
||||
|
||||
const closeOthers = useCallback(
|
||||
(tabId: string) => {
|
||||
// Why: bulk close mutates Zustand immediately, so read the live tab list
|
||||
// instead of closing over the render snapshot.
|
||||
(visibleId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
for (const tab of currentTabs) {
|
||||
if (tab.id !== tabId) {
|
||||
closeTab(tab.id)
|
||||
const currentGroupTabs = activeGroup
|
||||
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.groupId === activeGroup.id
|
||||
)
|
||||
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
|
||||
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
for (const tab of currentGroupTabs) {
|
||||
if (tab.id !== item.id && !tab.isPinned) {
|
||||
closeFloatingItem(tab.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeTab]
|
||||
[activeGroup, closeFloatingItem]
|
||||
)
|
||||
|
||||
const closeToRight = useCallback(
|
||||
(tabId: string) => {
|
||||
// Why: see closeOthers; context menu actions can run after the rendered
|
||||
// tab snapshot is stale.
|
||||
(visibleId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
const terminalIds = currentTabs.map((tab) => tab.id)
|
||||
const visibleIds = reconcileTabOrder(
|
||||
state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID],
|
||||
terminalIds,
|
||||
[]
|
||||
)
|
||||
const index = visibleIds.findIndex((id) => id === tabId)
|
||||
const currentGroup = activeGroup
|
||||
? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find(
|
||||
(group) => group.id === activeGroup.id
|
||||
)
|
||||
: null
|
||||
const currentGroupTabs = currentGroup
|
||||
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.groupId === currentGroup.id
|
||||
)
|
||||
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
|
||||
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
|
||||
if (!item || !currentGroup) {
|
||||
return
|
||||
}
|
||||
const index = currentGroup.tabOrder.indexOf(item.id)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
for (const id of visibleIds.slice(index + 1)) {
|
||||
closeTab(id)
|
||||
const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab]))
|
||||
for (const tabId of currentGroup.tabOrder.slice(index + 1)) {
|
||||
const tab = tabById.get(tabId)
|
||||
if (tab && !tab.isPinned) {
|
||||
closeFloatingItem(tab.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeTab]
|
||||
[activeGroup, closeFloatingItem]
|
||||
)
|
||||
|
||||
const toggleMaximized = useCallback(() => {
|
||||
@@ -325,23 +569,49 @@ export function FloatingTerminalPanel({
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTab?.id ?? null}
|
||||
tabs={terminalItems}
|
||||
activeTabId={activeTerminalId}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)}
|
||||
onClose={closeFloatingTab}
|
||||
onActivate={activateFloatingItem}
|
||||
onClose={closeFloatingItem}
|
||||
onCloseOthers={closeOthers}
|
||||
onCloseToRight={closeToRight}
|
||||
onNewTerminalTab={createFloatingTab}
|
||||
onNewBrowserTab={() => {}}
|
||||
terminalOnly
|
||||
onNewTerminalTab={() => createFloatingTerminalTab()}
|
||||
onNewTerminalWithShell={createFloatingTerminalTab}
|
||||
onNewBrowserTab={createFloatingBrowserTab}
|
||||
onNewFileTab={createFloatingMarkdownTab}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
onTogglePaneExpand={(tabId) =>
|
||||
setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true)
|
||||
}
|
||||
activeTabType="terminal"
|
||||
editorFiles={editorItems}
|
||||
browserTabs={browserItems}
|
||||
activeFileId={activeEditorUnifiedId}
|
||||
activeBrowserTabId={activeBrowserId}
|
||||
activeTabType={activeTabType}
|
||||
onActivateFile={activateFloatingItem}
|
||||
onCloseFile={closeFloatingItem}
|
||||
onActivateBrowserTab={activateFloatingItem}
|
||||
onCloseBrowserTab={closeFloatingItem}
|
||||
onDuplicateBrowserTab={(browserTabId) => {
|
||||
const source = browserTabs.find((tab) => tab.id === browserTabId)
|
||||
if (!source) {
|
||||
return
|
||||
}
|
||||
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, {
|
||||
title: source.title,
|
||||
sessionProfileId: source.sessionProfileId,
|
||||
targetGroupId: activeGroup?.id
|
||||
})
|
||||
}}
|
||||
onCloseAllFiles={() => {
|
||||
for (const file of editorItems) {
|
||||
closeFloatingItem(file.tabId)
|
||||
}
|
||||
}}
|
||||
onPinFile={pinFile}
|
||||
tabBarOrder={tabBarOrder}
|
||||
/>
|
||||
</div>
|
||||
@@ -354,29 +624,58 @@ export function FloatingTerminalPanel({
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
|
||||
{cwd
|
||||
? tabs.map((tab) => (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={
|
||||
tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden'
|
||||
}
|
||||
aria-hidden={tab.id !== activeTab?.id}
|
||||
>
|
||||
<TerminalPane
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={tab.id === activeTab?.id}
|
||||
isVisible={tab.id === activeTab?.id}
|
||||
onPtyExit={() => closeTab(tab.id)}
|
||||
onCloseTab={() => closeFloatingTab(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
? tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTerminalId
|
||||
return (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TerminalPane
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={isActive}
|
||||
isVisible={isActive}
|
||||
onPtyExit={() => closeTab(tab.id)}
|
||||
onCloseTab={() => closeFloatingItem(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
{browserTabs.map((tab) => {
|
||||
const isActive = tab.id === activeBrowserTab?.id
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<BrowserPane browserTab={tab} isActive={isActive} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{activeEditorFile ? (
|
||||
<div className="absolute inset-0 flex min-h-0 min-w-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EditorPanel
|
||||
activeFileId={activeEditorFile.id}
|
||||
activeViewStateId={activeEditorUnifiedId}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showOrchestrationSetup ? (
|
||||
{showOrchestrationSetup && activeTabType === 'terminal' ? (
|
||||
<div
|
||||
className="absolute right-4 bottom-4 z-10 w-[280px] rounded-md border border-border/60 bg-card/95 p-3 text-card-foreground shadow-xs"
|
||||
data-floating-terminal-no-drag
|
||||
@@ -414,10 +713,40 @@ export function FloatingTerminalPanel({
|
||||
{!maximized && <FloatingTerminalResizeHandles bounds={bounds} setBounds={setBounds} />}
|
||||
<FloatingTerminalOrchestrationDialog
|
||||
open={orchestrationDialogOpen}
|
||||
activeTabId={activeTab?.id ?? null}
|
||||
activeTabId={activeTerminalId}
|
||||
onOpenChange={setOrchestrationDialogOpen}
|
||||
onSetupStateChange={() => void refreshOrchestrationSetupVisibility()}
|
||||
/>
|
||||
<Dialog
|
||||
open={saveDialogFileId !== null}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleSaveDialogCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{saveDialogFile
|
||||
? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?`
|
||||
: 'This file has unsaved changes.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogDiscard}>
|
||||
Don't Save
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
@@ -180,6 +181,20 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBrowserSlice floating tabs', () => {
|
||||
it('focuses the address bar for new floating browser tabs without changing the main surface', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ activeWorktreeId: 'wt-1', activeTabType: 'terminal' } as Partial<AppState>)
|
||||
|
||||
const tab = store.getState().createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', {
|
||||
focusAddressBar: true
|
||||
})
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[tab.id]).toBe(true)
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBrowserSlice runtime guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
WorkspaceSessionState
|
||||
} from '../../../../shared/types'
|
||||
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import {
|
||||
MAX_BROWSER_HISTORY_ENTRIES,
|
||||
@@ -419,8 +419,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId
|
||||
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
const shouldFocusAddressBar =
|
||||
shouldUpdateGlobalActiveSurface &&
|
||||
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
|
||||
(options?.focusAddressBar ??
|
||||
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
const { toastErrorMock } = vi.hoisted(() => ({
|
||||
toastErrorMock: vi.fn()
|
||||
@@ -251,6 +252,34 @@ describe('createEditorSlice openDiff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice floating editor activation', () => {
|
||||
it('opens floating markdown tabs without changing the main active editor surface', () => {
|
||||
const store = createEditorStore()
|
||||
store.setState({
|
||||
activeFileId: '/repo/main.md',
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { 'wt-1': '/repo/main.md' },
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor' }
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().openFile({
|
||||
filePath: '/tmp/orca/untitled.md',
|
||||
relativePath: 'untitled.md',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
language: 'markdown',
|
||||
isUntitled: true,
|
||||
mode: 'edit'
|
||||
})
|
||||
|
||||
expect(store.getState().activeFileId).toBe('/repo/main.md')
|
||||
expect(store.getState().activeTabType).toBe('editor')
|
||||
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
|
||||
'/tmp/orca/untitled.md'
|
||||
)
|
||||
expect(store.getState().activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('editor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice untitled cleanup routing', () => {
|
||||
const runtimeEnvironmentCallMock = vi.fn()
|
||||
const runtimeEnvironmentTransportCallMock = vi.fn()
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
WorkspaceVisibleTabType
|
||||
} from '../../../../shared/types'
|
||||
import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
|
||||
import {
|
||||
fetchRuntimeGit,
|
||||
@@ -494,6 +495,28 @@ function openWorkspaceEditorItem(
|
||||
return created?.id ?? fileId
|
||||
}
|
||||
|
||||
function buildEditorActiveResult(
|
||||
state: Pick<EditorSlice, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree'>,
|
||||
worktreeId: string,
|
||||
fileId: string
|
||||
): {
|
||||
activeFileId?: string
|
||||
activeTabType?: 'editor'
|
||||
activeFileIdByWorktree: Record<string, string | null>
|
||||
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
|
||||
} {
|
||||
return {
|
||||
// Why: floating markdown tabs use the editor surface without becoming the
|
||||
// main worktree's active editor. Updating only the per-worktree maps keeps
|
||||
// the workspace behind the floating panel from switching surfaces.
|
||||
...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
? {}
|
||||
: { activeFileId: fileId, activeTabType: 'editor' as const }),
|
||||
activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: fileId },
|
||||
activeTabTypeByWorktree: { ...state.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_OPERATION_FAILED_MESSAGE = 'Remote operation failed'
|
||||
const REMOTE_OPERATION_DETAIL_MAX_LENGTH = 200
|
||||
|
||||
@@ -823,12 +846,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
}
|
||||
}
|
||||
|
||||
const activeResult = {
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor' as const,
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' as const }
|
||||
}
|
||||
const activeResult = buildEditorActiveResult(s, worktreeId, id)
|
||||
|
||||
if (existing) {
|
||||
// If opening as non-preview, also pin the existing tab
|
||||
@@ -1038,12 +1056,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
const worktreeId = file.worktreeId
|
||||
const runtimeEnvironmentId =
|
||||
file.runtimeEnvironmentId ?? s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined
|
||||
const activeResult = {
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor' as const,
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' as const }
|
||||
}
|
||||
const activeResult = buildEditorActiveResult(s, worktreeId, id)
|
||||
|
||||
if (existing) {
|
||||
const needsUpdate =
|
||||
|
||||
Reference in New Issue
Block a user