mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
perf(switch): do less work in the workspace-switch frame (#35185)
* perf(i18n): cache option-free translations per language and resource version A workspace switch re-renders chrome that resolves hundreds of static strings through i18next (~3 ms per switch). Option-free lookups only change with the language or the loaded resources, so cache them keyed on both. * perf(file-search): skip file search state writes that change nothing Every workspace switch cancels the outgoing workspace's idle search, which wrote an identical state object and re-notified every store subscriber. * perf(sidebar): keep unchanged worktree cards and project headers memoized across switches A plain press replaces the sidebar selection, and the selection-keyed gesture callbacks plus the shared selectedWorktrees array were passed to every card, so every card and its menus re-rendered on each workspace switch. Gesture callbacks now read the latest selection from a ref, only selected rows receive the selection, and project header actions are memoized. * test(terminal): bound toast translations from above now that static strings are cached * perf(terminal): stop re-rendering hidden panes and retained tab strips on workspace switches Hidden panes re-measured their title-overlay rects on every switch, forcing a layout and clearing the rects, which re-rendered the pane once on hide and again on show. Hidden overlays are display:none, so they keep their last rects until shown. Retained tab strips subscribed to the active repo and worktree for a Windows-only shell menu, re-rendering every strip; those reads are gated like the project maps beside them. * perf(app-shell): let root overlays skip the App render a workspace switch causes App re-renders on every switch (its chrome layout tracks the active worktree), and it handed fresh floating-workspace and onboarding objects to the root overlays, re-rendering every overlay and dialog. Both objects are memoized, AppRootSurfaces and the floating toggle button are memo components, and the toggle callback is stable. * perf(worktrees): switch the active repo and worktree in one store write Activating a worktree in another project wrote activeRepoId and then the active worktree separately, so every store subscriber re-ran its selector twice (~1 ms per switch). The repo change now rides along as a state transition of the same setActiveWorktree write, as the SSH server-copy activation already does. * perf(app-shell): stop App re-rendering on workspace switches App subscribed to the raw active worktree and tab ids through the chrome layout and three sync hooks, so every switch re-rendered the whole app shell. The layout now exposes booleans, shortcut dispatch and the titlebar expand button read the active ids when they fire, and the git-status polling, web session tab sync and review refresh reporting hooks run in a null gate component under AppBackgroundServices. * perf(worktrees): fold visit recency and nav history into the activation write A switch wrote the active worktree, then the leave/enter recency stamps and the nav-history entry as separate store writes, each re-running every subscriber's selector. The stamps (resolved against the post-activation selection, as markVisited did) and the history entry now ride in the same setActiveWorktree state transition as the repo follow. * perf(terminal): present instead of clearing the shared glyph atlas on worktree reveal The settled reveal reset predates xterm's per-renderer page-layout versioning (renderers now rebuild their model when a shared atlas page changes), so a healthy atlas no longer needs re-rasterizing on every worktree switch. DPR repair and system wake keep the full reset. * fix(worktrees): keep fresh visit stamps when the activation's history entry is de-duplicated * fix(app-shell): repaint memoized shell controls on a language change * test(floating-terminal): stub the language subscription in the toggle button's hook harness --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read
|
||||
# the default host, through defaultCreationHost. Everything else routes by the owner.
|
||||
# Prune after removing reads: pnpm check:focus-read-ratchet --prune
|
||||
3 src/renderer/src/app-shell/use-app-shell-services.ts
|
||||
1 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx
|
||||
2 src/renderer/src/app-shell/use-app-shell-services.ts
|
||||
2 src/renderer/src/App.tsx
|
||||
1 src/renderer/src/component-fixtures/component-fixture-explorer.tsx # dev-only fixture page seeds default settings
|
||||
1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useAppStore } from '../store'
|
||||
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
|
||||
import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling'
|
||||
import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync/use-web-session-tabs-sync'
|
||||
import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting'
|
||||
|
||||
/** Hosts services that track the active workspace, so a switch re-renders this null node instead of App. */
|
||||
export function ActiveWorkspaceSyncGate(): null {
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
useWebSessionTabsSync()
|
||||
useVisibleReviewRefreshReporting()
|
||||
// Poll above the removable sidebar to keep rebase/merge badges fresh; wait for session readiness to protect first paint.
|
||||
useGitStatusPolling({ enabled: workspaceSessionReady })
|
||||
// Watch files above Explorer so editor changes still arrive on Source Control/Checks.
|
||||
useEditorExternalWatch()
|
||||
return null
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
|
||||
import { SavedHostScopeWidenGate } from '../components/sidebar/SavedHostScopeWidenGate'
|
||||
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
|
||||
import { useAppStore } from '../store'
|
||||
import { ActiveWorkspaceSyncGate } from './ActiveWorkspaceSyncGate'
|
||||
import { StructuredAgentSessionAttentionBridge } from '../components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge'
|
||||
|
||||
@@ -22,6 +23,7 @@ export function AppBackgroundServices(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<WorkspacePortScanner enabled={workspaceSessionReady} />
|
||||
<ActiveWorkspaceSyncGate />
|
||||
{/* Keep permission notices available across app surfaces. */}
|
||||
<MacosTccPromptNoticeHost />
|
||||
{/* Keep agent-status retention subscriptions out of the App render tree. */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModalBoundary, OverlayBoundary } from './app-root-surface-boundaries'
|
||||
import { OnboardingLanguageLoading } from '../components/onboarding/OnboardingLanguageLoading'
|
||||
import { useLanguageReadiness } from '../i18n/language-readiness'
|
||||
import { NotificationCardStack } from '../components/NotificationCardStack'
|
||||
import { Suspense } from 'react'
|
||||
import { memo, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -108,12 +109,14 @@ function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Keep root overlays in stable sibling order; fetch gated chunks only when their surfaces can appear. */
|
||||
export function AppRootSurfaces(props: {
|
||||
/** Keep root overlays in stable sibling order; fetch gated chunks only when their surfaces can appear. Memoized so active-workspace App renders skip every root overlay. */
|
||||
export const AppRootSurfaces = memo(function AppRootSurfaces(props: {
|
||||
floatingWorkspace: FloatingWorkspacePanelState
|
||||
onboardingGate: OnboardingGate
|
||||
}): React.JSX.Element {
|
||||
const { floatingWorkspace, onboardingGate } = props
|
||||
// Memoized: subscribe so a language change still repaints its labels.
|
||||
useTranslation()
|
||||
const languageReady = useLanguageReadiness((state) => state.ready)
|
||||
const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts()
|
||||
usePreloadWorktreeJumpPalette()
|
||||
@@ -398,4 +401,4 @@ export function AppRootSurfaces(props: {
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useRef } from 'react'
|
||||
import { memo, Suspense, useCallback, useRef } from 'react'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
@@ -12,6 +12,9 @@ import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
|
||||
import type { AppChromeLayout } from './use-app-chrome-layout'
|
||||
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
|
||||
|
||||
// Skips the shell render each workspace switch causes; its props only change on toggle.
|
||||
const MemoFloatingTerminalToggleButton = memo(FloatingTerminalToggleButton)
|
||||
|
||||
const Landing = lazy(() => import('../components/Landing'))
|
||||
const WorktreeCreationPanel = lazy(
|
||||
() => import('../components/worktree-creation/WorktreeCreationPanel')
|
||||
@@ -65,7 +68,7 @@ function WorktreeSidebar({
|
||||
}
|
||||
|
||||
function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
|
||||
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
|
||||
const { activeView, hasActiveWorktree, activePendingCreationId, creationLayoutActive } = layout
|
||||
return (
|
||||
<>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
@@ -82,7 +85,9 @@ function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element
|
||||
reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating}
|
||||
/>
|
||||
) : null}
|
||||
{activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? <Landing /> : null}
|
||||
{activeView === 'terminal' && !hasActiveWorktree && !creationLayoutActive ? (
|
||||
<Landing />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +103,11 @@ export function AppWorkspaceShell(props: {
|
||||
const scrollOffsetRef = useRef(0)
|
||||
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
|
||||
const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef }
|
||||
const { setOpenWithFocus } = floatingWorkspace
|
||||
const toggleFloatingWorkspace = useCallback(
|
||||
() => setOpenWithFocus((open) => !open),
|
||||
[setOpenWithFocus]
|
||||
)
|
||||
|
||||
return (
|
||||
// Avoid active-worktree reset keys that remount whole surfaces during wake.
|
||||
@@ -214,9 +224,9 @@ export function AppWorkspaceShell(props: {
|
||||
</Suspense>
|
||||
</div>
|
||||
{floatingWorkspace.showToggleButton ? (
|
||||
<FloatingTerminalToggleButton
|
||||
<MemoFloatingTerminalToggleButton
|
||||
open={floatingWorkspace.open}
|
||||
onToggle={() => floatingWorkspace.setOpenWithFocus((open) => !open)}
|
||||
onToggle={toggleFloatingWorkspace}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { ActivityTitlebarControls } from '../components/activity/ActivityTitlebarControls'
|
||||
import { useShortcutLabel } from '../hooks/useShortcutLabel'
|
||||
import { useAppStore } from '../store'
|
||||
import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector'
|
||||
import { hasCustomTitleBar } from './app-window-chrome'
|
||||
import type { AppChromeLayout } from './use-app-chrome-layout'
|
||||
|
||||
@@ -33,12 +34,13 @@ export function RightSidebarToggle(): React.JSX.Element {
|
||||
|
||||
export function TitlebarMainStrip({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
|
||||
const handleToggleExpand = (): void => {
|
||||
if (!layout.effectiveActiveTabId) {
|
||||
const { effectiveActiveTabId } = selectActiveTerminalChromeState(useAppStore.getState())
|
||||
if (!effectiveActiveTabId) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
|
||||
detail: { tabId: layout.effectiveActiveTabId }
|
||||
detail: { tabId: effectiveActiveTabId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,13 +32,19 @@ export function useAppChromeLayout() {
|
||||
s.activePendingCreationId !== null &&
|
||||
s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined
|
||||
)
|
||||
const {
|
||||
activeWorktreeId,
|
||||
tabCount,
|
||||
effectiveActiveTabId,
|
||||
activeTabCanExpand,
|
||||
effectiveActiveTabExpanded
|
||||
} = useAppStore(useShallow(selectActiveTerminalChromeState))
|
||||
// Booleans only: App owns this hook, and raw active ids would re-render the whole app on every workspace switch.
|
||||
const { hasActiveWorktree, hasTabBar, activeTabCanExpand, effectiveActiveTabExpanded } =
|
||||
useAppStore(
|
||||
useShallow((s) => {
|
||||
const chrome = selectActiveTerminalChromeState(s)
|
||||
return {
|
||||
hasActiveWorktree: chrome.activeWorktreeId !== null,
|
||||
hasTabBar: chrome.tabCount >= 2,
|
||||
activeTabCanExpand: chrome.activeTabCanExpand,
|
||||
effectiveActiveTabExpanded: chrome.effectiveActiveTabExpanded
|
||||
}
|
||||
})
|
||||
)
|
||||
const backgroundTerminalMountRequested = useSyncExternalStore(
|
||||
subscribeBackgroundTerminalWorktreeMountRequests,
|
||||
hasRequestedBackgroundTerminalWorktreeMount,
|
||||
@@ -56,7 +62,7 @@ export function useAppChromeLayout() {
|
||||
(s) => (s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0) > 0
|
||||
)
|
||||
const canMountTerminalWorkbenchNow =
|
||||
activeWorktreeId !== null || backgroundTerminalMountRequested || floatingWorkspaceHasTabs
|
||||
hasActiveWorktree || backgroundTerminalMountRequested || floatingWorkspaceHasTabs
|
||||
// Use state so discarded renders cannot leak the mount latch; the OR below covers this render.
|
||||
const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)
|
||||
if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) {
|
||||
@@ -71,8 +77,7 @@ export function useAppChromeLayout() {
|
||||
hasActivePendingCreation: activePendingCreationExists
|
||||
})
|
||||
const workspaceChromeActive =
|
||||
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
|
||||
const hasTabBar = tabCount >= 2
|
||||
activeView === 'terminal' && hasActiveWorktree && !creationLayoutActive
|
||||
// Activity/Space own full pages and hide the workspace sidebar.
|
||||
const showSidebar =
|
||||
activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
|
||||
@@ -120,12 +125,11 @@ export function useAppChromeLayout() {
|
||||
|
||||
return {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
activePendingCreationId,
|
||||
activeTabCanExpand,
|
||||
effectiveActiveTabId,
|
||||
collapsedSidebarHeaderWidth,
|
||||
creationLayoutActive,
|
||||
hasActiveWorktree,
|
||||
isFullScreen,
|
||||
leftSidebarStyle,
|
||||
leftTitlebarChromeLayout,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useClosedEditorTabCleanup } from '../components/editor/useClosedEditorT
|
||||
import { useIpcEvents } from '../hooks/useIpcEvents'
|
||||
import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents'
|
||||
import { useReadOnScreenAttention } from '../hooks/useReadOnScreenAttention'
|
||||
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
|
||||
import { useAppMenuPaste } from '../hooks/useAppMenuPaste'
|
||||
import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions'
|
||||
import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste'
|
||||
@@ -12,9 +11,7 @@ import {
|
||||
usePrimarySelectionPaste
|
||||
} from '../hooks/usePrimarySelectionPaste'
|
||||
import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerEventsRecovery'
|
||||
import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling'
|
||||
import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice'
|
||||
import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync/use-web-session-tabs-sync'
|
||||
import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync'
|
||||
import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync'
|
||||
import { useLegacyChatRenameMigration } from '../runtime/legacy-chat-rename-migration'
|
||||
@@ -25,7 +22,6 @@ import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/br
|
||||
import { useBrowserClientHostedDownloadNotices } from '../components/browser-pane/browser-client-hosted-download-notices'
|
||||
import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice'
|
||||
import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice'
|
||||
import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting'
|
||||
import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh'
|
||||
import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice'
|
||||
|
||||
@@ -39,7 +35,6 @@ export function useAppShellServices(): void {
|
||||
|
||||
useClosedEditorTabCleanup()
|
||||
useRadixBodyPointerEventsRecovery()
|
||||
useWebSessionTabsSync()
|
||||
useLocalStructuredSessionTabsSync()
|
||||
useHostStructuredAgentsSync()
|
||||
useLegacyChatRenameMigration()
|
||||
@@ -47,14 +42,9 @@ export function useAppShellServices(): void {
|
||||
useIpcEvents()
|
||||
useProfileStateSaveDelayNotice()
|
||||
useRemoteRuntimeRecoveryTriggers()
|
||||
useVisibleReviewRefreshReporting()
|
||||
useVisibleHostedReviewRefresh({ enabled: workspaceSessionReady })
|
||||
useTerminalViewerColorPublication()
|
||||
useAutomationDispatchEvents()
|
||||
// Poll above the removable sidebar to keep rebase/merge badges fresh; wait for session readiness to protect first paint.
|
||||
useGitStatusPolling({ enabled: workspaceSessionReady })
|
||||
// Watch files above Explorer so editor changes still arrive on Source Control/Checks.
|
||||
useEditorExternalWatch()
|
||||
useReadOnScreenAttention()
|
||||
useAppMenuPaste()
|
||||
useAppMenuSelectionActions()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, type SetStateAction } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import {
|
||||
TOGGLE_FLOATING_TERMINAL_EVENT,
|
||||
requestFloatingTerminalOpenMaximized
|
||||
@@ -116,16 +123,31 @@ export function useFloatingWorkspacePanel() {
|
||||
}
|
||||
}, [settingsHydrated, enabled, setOpenWithFocus])
|
||||
|
||||
return {
|
||||
cancelReturnFocusFrame,
|
||||
enabled,
|
||||
open,
|
||||
openMaximized,
|
||||
setOpenWithFocus,
|
||||
// Retain panes’ local state while closed once the floating workspace owns tabs.
|
||||
shouldMountPanel: enabled && (open || visibleTabCount > 0),
|
||||
showToggleButton: enabled && (triggerLocation === 'floating-button' || !statusBarVisible),
|
||||
tourInteractionSnapshotRef,
|
||||
visibleTabCount
|
||||
}
|
||||
const shouldMountPanel = enabled && (open || visibleTabCount > 0)
|
||||
const showToggleButton = enabled && (triggerLocation === 'floating-button' || !statusBarVisible)
|
||||
// Stable identity lets memoized root surfaces skip the App render each workspace switch causes.
|
||||
return useMemo(
|
||||
() => ({
|
||||
cancelReturnFocusFrame,
|
||||
enabled,
|
||||
open,
|
||||
openMaximized,
|
||||
setOpenWithFocus,
|
||||
// Retain panes’ local state while closed once the floating workspace owns tabs.
|
||||
shouldMountPanel,
|
||||
showToggleButton,
|
||||
tourInteractionSnapshotRef,
|
||||
visibleTabCount
|
||||
}),
|
||||
[
|
||||
cancelReturnFocusFrame,
|
||||
enabled,
|
||||
open,
|
||||
openMaximized,
|
||||
setOpenWithFocus,
|
||||
shouldMountPanel,
|
||||
showToggleButton,
|
||||
visibleTabCount
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,8 @@ export function useGlobalKeybindings(args: {
|
||||
const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy)
|
||||
const pluginCommands = usePluginCommands()
|
||||
|
||||
const shortcutState: AppShortcutState = {
|
||||
const shortcutState: Omit<AppShortcutState, 'activeWorktreeId'> = {
|
||||
activeView: layout.activeView,
|
||||
activeWorktreeId: layout.activeWorktreeId,
|
||||
actions,
|
||||
creationLayoutActive: layout.creationLayoutActive,
|
||||
floatingTerminalEnabled: floatingWorkspace.enabled,
|
||||
@@ -71,12 +70,17 @@ export function useGlobalKeybindings(args: {
|
||||
useEffect(() => {
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
|
||||
// The active worktree is read at dispatch: App deliberately does not re-render on workspace switches.
|
||||
const readShortcutState = (): AppShortcutState => ({
|
||||
...shortcutStateRef.current,
|
||||
activeWorktreeId: useAppStore.getState().activeWorktreeId
|
||||
})
|
||||
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
|
||||
(createAppCommandHandlers(shortcutStateRef.current).get(actionId) ?? (() => false))()
|
||||
(createAppCommandHandlers(readShortcutState()).get(actionId) ?? (() => false))()
|
||||
)
|
||||
|
||||
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
|
||||
const state = shortcutStateRef.current
|
||||
const state = readShortcutState()
|
||||
const {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { onOnboardingReopened } from '../components/onboarding/show-onboarding-event'
|
||||
import { shouldShowOnboarding } from '../components/onboarding/should-show-onboarding'
|
||||
@@ -166,10 +166,9 @@ export function useOnboardingAndFeatureTips() {
|
||||
settings
|
||||
])
|
||||
|
||||
return {
|
||||
applyStartupOnboardingState,
|
||||
onboarding,
|
||||
setOnboarding,
|
||||
shouldRender: onboarding !== null && shouldShowOnboarding(onboarding)
|
||||
}
|
||||
const shouldRender = onboarding !== null && shouldShowOnboarding(onboarding)
|
||||
return useMemo(
|
||||
() => ({ applyStartupOnboardingState, onboarding, setOnboarding, shouldRender }),
|
||||
[applyStartupOnboardingState, onboarding, shouldRender]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as ReactModule from 'react'
|
||||
import type * as ReactI18nextModule from 'react-i18next'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY } from './floating-terminal-trigger-position'
|
||||
|
||||
@@ -53,6 +54,11 @@ vi.mock('react', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ReactI18nextModule>()),
|
||||
useTranslation: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
PanelsTopLeft: function PanelsTopLeft() {
|
||||
return null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { PanelsTopLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -63,6 +64,8 @@ export function FloatingTerminalToggleButton({
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
// Memoized: subscribe so a language change still repaints its labels.
|
||||
useTranslation()
|
||||
const shortcutLabel = useShortcutLabel('floatingTerminal.toggle')
|
||||
// Read shared unread state so engaging with or closing a tab clears its minimized-panel attention dot.
|
||||
const hasFloatingUnread = useAppStore(selectFloatingWorkspaceHasUnread)
|
||||
|
||||
@@ -119,6 +119,7 @@ export function renderWorktreeItemRow(
|
||||
: undefined
|
||||
const worktreeDragGroupKey = ctx.groupKeyByRowKey.get(itemRow.rowKey)
|
||||
const worktreeIdentity = getWorktreeHostIdentity(itemRow.worktree)
|
||||
const isMultiSelected = ctx.selectedWorktreeIds.has(worktreeIdentity)
|
||||
const isLineageDropTarget =
|
||||
ctx.worktreeDragState.draggingWorktreeId &&
|
||||
(ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id ||
|
||||
@@ -132,7 +133,7 @@ export function renderWorktreeItemRow(
|
||||
key={itemRow.rowKey}
|
||||
id={getWorktreeOptionId(itemRow.rowKey)}
|
||||
role="option"
|
||||
aria-selected={ctx.selectedWorktreeIds.has(worktreeIdentity)}
|
||||
aria-selected={isMultiSelected}
|
||||
aria-current={isActiveWorktree ? 'page' : undefined}
|
||||
data-worktree-id={itemRow.worktree.id}
|
||||
data-worktree-host-identity={worktreeIdentity}
|
||||
@@ -181,12 +182,13 @@ export function renderWorktreeItemRow(
|
||||
activeSurfaceVariant={
|
||||
isActiveWorktree && !forceActiveSurface ? ctx.getActiveSurfaceVariant(itemRow) : 'primary'
|
||||
}
|
||||
isMultiSelected={ctx.selectedWorktreeIds.has(worktreeIdentity)}
|
||||
isMultiSelected={isMultiSelected}
|
||||
revealHighlight={ctx.highlightedRevealRowKey === itemRow.rowKey}
|
||||
revealHighlightTone={
|
||||
ctx.agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
|
||||
}
|
||||
selectedWorktrees={ctx.selectedWorktrees}
|
||||
// Only selected rows act on the selection; passing it to the rest would re-render every card per click.
|
||||
selectedWorktrees={isMultiSelected ? ctx.selectedWorktrees : undefined}
|
||||
nativeDragEnabled={false}
|
||||
isLineageDropTarget={Boolean(isLineageDropTarget)}
|
||||
contentIndent={cardContentIndent}
|
||||
|
||||
+10
-4
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React from 'react'
|
||||
import { useRepoSshGate } from '@/hooks/use-repo-ssh-gate'
|
||||
import {
|
||||
@@ -78,7 +79,8 @@ export type RepoHeaderProjectActions = {
|
||||
onCreateForRepo: (projectId: string) => void
|
||||
}
|
||||
|
||||
export function RepoHeaderProjectActionsMenu({
|
||||
// Memoized: the sidebar re-renders every header row when the active workspace changes.
|
||||
export const RepoHeaderProjectActionsMenu = React.memo(function RepoHeaderProjectActionsMenu({
|
||||
repo,
|
||||
label,
|
||||
projectGroups,
|
||||
@@ -89,6 +91,8 @@ export function RepoHeaderProjectActionsMenu({
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
actions: RepoHeaderProjectActions
|
||||
}): React.JSX.Element {
|
||||
// Memoized: subscribe so a language change still repaints its labels.
|
||||
useTranslation()
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
@@ -184,9 +188,9 @@ export function RepoHeaderProjectActionsMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function RepoHeaderCreateWorkspaceButton({
|
||||
export const RepoHeaderCreateWorkspaceButton = React.memo(function RepoHeaderCreateWorkspaceButton({
|
||||
repo,
|
||||
label,
|
||||
onCreateForRepo
|
||||
@@ -195,6 +199,8 @@ export function RepoHeaderCreateWorkspaceButton({
|
||||
label: string
|
||||
onCreateForRepo: (projectId: string) => void
|
||||
}): React.JSX.Element {
|
||||
// Memoized: subscribe so a language change still repaints its labels.
|
||||
useTranslation()
|
||||
const sshGate = useRepoSshGate(repo)
|
||||
const createState = getRepoHeaderCreateState({ repo, label, sshGate })
|
||||
const fallbackLabel = translate(
|
||||
@@ -254,4 +260,4 @@ export function RepoHeaderCreateWorkspaceButton({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
+37
-1
@@ -28,6 +28,8 @@ import { useWorktreeSidebarScrollSuppression } from './use-scroll-suppression'
|
||||
import { EMPTY_PROJECT_GROUPS, type VirtualizedWorktreeViewportProps } from './viewport-props'
|
||||
import { useWorktreeDropCommitContext } from '../drag/use-drop-commit-context'
|
||||
import { buildWorktreeVirtualRowContext } from './virtual-row-context'
|
||||
import type { RepoHeaderProjectActions } from '../rows/repo-header-project-actions'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host'
|
||||
import { renderWorktreeVirtualRow } from '../rows/virtual-row-dispatch'
|
||||
|
||||
const EMPTY_HOST_OPTIONS: NonNullable<VirtualizedWorktreeViewportProps['hostOptions']> = []
|
||||
@@ -68,6 +70,40 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
|
||||
projectGrouping: props.projectGrouping
|
||||
})
|
||||
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
|
||||
const {
|
||||
handleOpenRepoSettings,
|
||||
handleOpenWorktreeVisibility,
|
||||
handleCreateGroupFromRepo,
|
||||
handleMoveProjectToGroup,
|
||||
handleRemoveProjectFromGroup,
|
||||
handleRemoveProject,
|
||||
handleCreateForRepo
|
||||
} = props
|
||||
// Why stable: a new object re-renders every project header's menu tree on each workspace switch.
|
||||
const projectActions = useMemo<RepoHeaderProjectActions>(
|
||||
() => ({
|
||||
getWorktreeVisibilityDefaults: (repo) =>
|
||||
getRepoOwnerWorktreeVisibilityDefaults(repo, settings, worktreeVisibilityDefaultsByHost),
|
||||
onOpenRepoSettings: handleOpenRepoSettings,
|
||||
onOpenWorktreeVisibility: handleOpenWorktreeVisibility,
|
||||
onCreateGroupFromRepo: handleCreateGroupFromRepo,
|
||||
onMoveProjectToGroup: handleMoveProjectToGroup,
|
||||
onRemoveProjectFromGroup: handleRemoveProjectFromGroup,
|
||||
onRemoveProject: handleRemoveProject,
|
||||
onCreateForRepo: handleCreateForRepo
|
||||
}),
|
||||
[
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost,
|
||||
handleOpenRepoSettings,
|
||||
handleOpenWorktreeVisibility,
|
||||
handleCreateGroupFromRepo,
|
||||
handleMoveProjectToGroup,
|
||||
handleRemoveProjectFromGroup,
|
||||
handleRemoveProject,
|
||||
handleCreateForRepo
|
||||
]
|
||||
)
|
||||
|
||||
const reveal = useSidebarRevealHighlight()
|
||||
const scrollSuppression = useWorktreeSidebarScrollSuppression(scrollRef)
|
||||
@@ -308,7 +344,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
|
||||
virtualization,
|
||||
measureVirtualRowElement,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost,
|
||||
projectActions,
|
||||
newCardStyle,
|
||||
folderBackedProjectGroupIds,
|
||||
projectGroups,
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { WorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
|
||||
import type { WorktreeListVirtualizer } from './use-virtualizer'
|
||||
import type { VirtualizedWorktreeViewportProps } from './viewport-props'
|
||||
import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host'
|
||||
import type { RepoHeaderProjectActions } from '../rows/repo-header-project-actions'
|
||||
|
||||
import type { ProjectHeaderHostContext } from '../rows/project-header-host-context'
|
||||
|
||||
@@ -27,7 +27,7 @@ type BuildArgs = {
|
||||
virtualization: WorktreeListVirtualizer
|
||||
measureVirtualRowElement: (element: HTMLDivElement | null) => void
|
||||
settings: AppState['settings']
|
||||
worktreeVisibilityDefaultsByHost: AppState['worktreeVisibilityDefaultsByHost']
|
||||
projectActions: RepoHeaderProjectActions
|
||||
newCardStyle: boolean
|
||||
folderBackedProjectGroupIds: ReadonlySet<string>
|
||||
session: WorktreeDragSession
|
||||
@@ -85,21 +85,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual
|
||||
projectHeaderHostContexts: args.projectHeaderHostContexts,
|
||||
getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus,
|
||||
toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor,
|
||||
projectActions: {
|
||||
getWorktreeVisibilityDefaults: (repo) =>
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
repo,
|
||||
args.settings,
|
||||
args.worktreeVisibilityDefaultsByHost
|
||||
),
|
||||
onOpenRepoSettings: props.handleOpenRepoSettings,
|
||||
onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility,
|
||||
onCreateGroupFromRepo: props.handleCreateGroupFromRepo,
|
||||
onMoveProjectToGroup: props.handleMoveProjectToGroup,
|
||||
onRemoveProjectFromGroup: props.handleRemoveProjectFromGroup,
|
||||
onRemoveProject: props.handleRemoveProject,
|
||||
onCreateForRepo: props.handleCreateForRepo
|
||||
},
|
||||
projectActions: args.projectActions,
|
||||
onRenameProjectGroup: props.handleRenameProjectGroup,
|
||||
onDeleteProjectGroup: props.handleDeleteProjectGroup,
|
||||
onCreateFolderWorkspace: props.handleCreateFolderWorkspace,
|
||||
|
||||
@@ -121,8 +121,6 @@ export function useTabBarRuntimeModel({
|
||||
const defaultWindowsPowerShellImplementation = useAppStore((s) =>
|
||||
resolveWindowsPowerShellImplementationSetting(s.settings)
|
||||
)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
// Why: use the worktree's owning host so offered Windows shells match the host that actually runs the terminal.
|
||||
const activeRuntimeEnvironmentId = useAppStore((s) =>
|
||||
@@ -176,6 +174,11 @@ export function useTabBarRuntimeModel({
|
||||
const worktreesByRepo = useAppStore((s) =>
|
||||
needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO
|
||||
)
|
||||
// Gated like the maps above: every retained strip would otherwise re-render on each workspace switch.
|
||||
const activeRepoId = useAppStore((s) => (needsLocalProjectRuntime ? s.activeRepoId : null))
|
||||
const activeWorktreeId = useAppStore((s) =>
|
||||
needsLocalProjectRuntime ? s.activeWorktreeId : null
|
||||
)
|
||||
const localProjectRuntime = useMemo(() => {
|
||||
if (!needsLocalProjectRuntime) {
|
||||
return undefined
|
||||
|
||||
@@ -123,7 +123,8 @@ describe('humanizeTerminalError', () => {
|
||||
if (kind === 'startup') {
|
||||
expect(tokenChecks).toBe(2 * rowCount)
|
||||
}
|
||||
expect(startupTranslations).toBe(2 * matchingRows)
|
||||
// Upper bound only: option-free translations are cached after their first lookup.
|
||||
expect(startupTranslations).toBeLessThanOrEqual(2 * matchingRows)
|
||||
expect(
|
||||
view.container.textContent?.match(/Codex exited while starting\./g) ?? []
|
||||
).toHaveLength(matchingRows)
|
||||
|
||||
@@ -123,12 +123,12 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second)
|
||||
})
|
||||
|
||||
it('schedules the repaint after rendering resumes on a heavy reveal', () => {
|
||||
it('schedules the settled present after rendering resumes on a heavy reveal', () => {
|
||||
const order: string[] = []
|
||||
const manager = createManager(order)
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(order).toEqual(['resume-rendering', 'fit-reveal', 'reveal-repaint'])
|
||||
expect(order).toEqual(['resume-rendering', 'fit-reveal', 'reveal-present'])
|
||||
})
|
||||
|
||||
it('routes a heavy reveal through fitAllRevealedPanes, not the sync fit', () => {
|
||||
@@ -182,9 +182,9 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
|
||||
// The expensive registry-wide rebuild is still deferred to the settled frame.
|
||||
// A healthy shared atlas is never cleared on an ordinary reveal.
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fit on a light tab reveal', () => {
|
||||
|
||||
@@ -113,8 +113,8 @@ export function resumeTerminalVisibility({
|
||||
for (const pane of manager.getPanes()) {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
// Rebuild once after attachment/measurement instead of paying a duplicate global atlas reset.
|
||||
manager.scheduleRevealRepaint()
|
||||
// Settled present covers attach/measure races; the shared atlas is still valid (renderers rebuild their model on page-layout changes), so re-rasterizing every glyph is waste.
|
||||
manager.scheduleRevealPresent()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+6
-6
@@ -197,13 +197,12 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
'fit-reveal',
|
||||
'intent:terminal-a',
|
||||
'intent:terminal-b',
|
||||
'reveal-repaint'
|
||||
'reveal-present'
|
||||
])
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(order.slice(-3)).toEqual(['reveal-repaint', 'reset-atlas', 'refresh'])
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
// A healthy shared atlas is not cleared on an ordinary worktree reveal.
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
|
||||
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
|
||||
maxChars: 256 * 1024
|
||||
@@ -572,7 +571,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enforces scroll intent after hidden layout changes the viewport', () => {
|
||||
|
||||
@@ -116,6 +116,10 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl
|
||||
setPaneTitleOverlayRects(clearPaneTitleOverlayRects)
|
||||
return
|
||||
}
|
||||
// Hidden overlays are display:none; keeping their last rects avoids a forced layout and a pane re-render on every workspace switch.
|
||||
if (!isVisible && !shouldMeasureHiddenStartup) {
|
||||
return
|
||||
}
|
||||
let frame: number | null = null
|
||||
const scheduleSync = (): void => {
|
||||
if (frame !== null) {
|
||||
@@ -147,6 +151,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl
|
||||
paneTitles,
|
||||
renamingPaneId,
|
||||
sessionRestoredBannerPaneIds,
|
||||
shouldMeasureHiddenStartup,
|
||||
syncPaneTitleOverlayRects,
|
||||
containerRef,
|
||||
managerRef,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import {
|
||||
areSelectionsEqual,
|
||||
@@ -85,13 +85,25 @@ export function useListMultiSelection<T>({
|
||||
}
|
||||
}, [selectedKeys.size, getScope])
|
||||
|
||||
// Why read through a ref: every plain click replaces the selection, and selection-keyed callbacks would re-render every memoized row.
|
||||
const latestSelectionRef = useRef({
|
||||
visibleKeys,
|
||||
selectedKeys,
|
||||
selectionAnchorKey,
|
||||
selectedItems
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
latestSelectionRef.current = { visibleKeys, selectedKeys, selectionAnchorKey, selectedItems }
|
||||
})
|
||||
|
||||
const updateSelectionForGesture = useCallback(
|
||||
(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>, item: T): boolean => {
|
||||
const latest = latestSelectionRef.current
|
||||
const intent = getSelectionIntent(event, navigator.userAgent.includes('Mac'))
|
||||
const result = updateSelection({
|
||||
visibleIds: visibleKeys,
|
||||
previousSelectedIds: selectedKeys,
|
||||
previousAnchorId: selectionAnchorKey,
|
||||
visibleIds: latest.visibleKeys,
|
||||
previousSelectedIds: latest.selectedKeys,
|
||||
previousAnchorId: latest.selectionAnchorKey,
|
||||
targetId: getKey(item),
|
||||
intent
|
||||
})
|
||||
@@ -100,20 +112,21 @@ export function useListMultiSelection<T>({
|
||||
// Plain click navigates; modifier gestures are selection-only so a batch can build without switching away.
|
||||
return intent !== 'replace'
|
||||
},
|
||||
[getKey, visibleKeys, selectedKeys, selectionAnchorKey]
|
||||
[getKey]
|
||||
)
|
||||
|
||||
const selectForContextMenu = useCallback(
|
||||
(item: T): readonly T[] => {
|
||||
const latest = latestSelectionRef.current
|
||||
const key = getKey(item)
|
||||
if (selectedKeys.has(key) && selectedKeys.size > 1) {
|
||||
return selectedItems
|
||||
if (latest.selectedKeys.has(key) && latest.selectedKeys.size > 1) {
|
||||
return latest.selectedItems
|
||||
}
|
||||
setSelectedKeys(new Set([key]))
|
||||
setSelectionAnchorKey(key)
|
||||
return [item]
|
||||
},
|
||||
[getKey, selectedKeys, selectedItems]
|
||||
[getKey]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,10 @@ import trCatalogUrl from './locales/tr.json?url'
|
||||
import zhCatalogUrl from './locales/zh.json?url'
|
||||
import zhHantCatalogUrl from './locales/zh-Hant.json?url'
|
||||
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
|
||||
import { advanceTranslationResourceVersion } from './translation-resource-version'
|
||||
import {
|
||||
advanceTranslationResourceVersion,
|
||||
getTranslationResourceVersion
|
||||
} from './translation-resource-version'
|
||||
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
|
||||
import type { SupportedUiLocale } from '../../../shared/ui-locale'
|
||||
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
|
||||
@@ -110,11 +113,38 @@ const getPluralRule = pluralResolver.getRule.bind(pluralResolver)
|
||||
pluralResolver.getRule = (code: string, options?: { ordinal?: boolean }) =>
|
||||
getPluralRule(pluralLocaleByResourceLanguage.get(code) ?? code, options)
|
||||
|
||||
export function translate(key: string, fallback: string, options?: TOptions): string {
|
||||
// Re-rendered chrome resolves hundreds of option-free strings per workspace switch; they only change with language or resources.
|
||||
const PLAIN_TRANSLATION_CACHE_LIMIT = 5000
|
||||
const plainTranslationCache = new Map<string, string>()
|
||||
let plainTranslationCacheScope = ''
|
||||
|
||||
function translateUncached(key: string, fallback: string, options?: TOptions): string {
|
||||
const value = i18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
|
||||
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
|
||||
}
|
||||
|
||||
export function translate(key: string, fallback: string, options?: TOptions): string {
|
||||
if (options !== undefined) {
|
||||
return translateUncached(key, fallback, options)
|
||||
}
|
||||
const scope = `${i18n.isInitialized}\0${i18n.language}\0${getTranslationResourceVersion()}`
|
||||
if (
|
||||
scope !== plainTranslationCacheScope ||
|
||||
plainTranslationCache.size >= PLAIN_TRANSLATION_CACHE_LIMIT
|
||||
) {
|
||||
plainTranslationCache.clear()
|
||||
plainTranslationCacheScope = scope
|
||||
}
|
||||
const cacheKey = `${key}\0${fallback}`
|
||||
const cached = plainTranslationCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const value = translateUncached(key, fallback)
|
||||
plainTranslationCache.set(cacheKey, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export async function loadRendererUiLanguage(language: UiLanguage): Promise<string> {
|
||||
const resolved = resolveUiLocale(language)
|
||||
const resourceLanguage = resolveRendererResourceLanguage(resolved)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
|
||||
@@ -54,28 +55,39 @@ afterEach(() => {
|
||||
describe('activateAndRevealWorktree', () => {
|
||||
it('does not restamp focus recency when reselecting the already-active terminal worktree', () => {
|
||||
const worktree = makeWorktree()
|
||||
const { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar } =
|
||||
seedAlreadyActiveWorktree(worktree)
|
||||
const { revealWorktreeInSidebar } = seedAlreadyActiveWorktree(worktree)
|
||||
const before = useAppStore.getState()
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(markWorktreeVisited).not.toHaveBeenCalled()
|
||||
expect(recordWorktreeVisit).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().lastVisitedAtByWorktreeId).toBe(before.lastVisitedAtByWorktreeId)
|
||||
expect(useAppStore.getState().worktreeNavHistory).toBe(before.worktreeNavHistory)
|
||||
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
|
||||
})
|
||||
|
||||
it('records a visit when activating the same worktree changes the current view', () => {
|
||||
const worktree = makeWorktree()
|
||||
const { markWorktreeVisited, recordWorktreeVisit } = seedAlreadyActiveWorktree(worktree, {
|
||||
activeView: 'tasks'
|
||||
})
|
||||
seedAlreadyActiveWorktree(worktree, { activeView: 'tasks' })
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(markWorktreeVisited).toHaveBeenCalledWith(worktree.id)
|
||||
expect(recordWorktreeVisit).toHaveBeenCalledWith(worktree.id)
|
||||
const state = useAppStore.getState()
|
||||
expect(getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, worktree)).toBeDefined()
|
||||
expect(state.worktreeNavHistory.at(-1)).toBe(worktree.id)
|
||||
})
|
||||
|
||||
it('keeps the fresh visit stamp when the history entry is de-duplicated', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedAlreadyActiveWorktree(worktree, { activeView: 'tasks' })
|
||||
useAppStore.setState({ worktreeNavHistory: [worktree.id], worktreeNavHistoryIndex: 0 })
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, worktree)).toBeDefined()
|
||||
expect(state.worktreeNavHistory).toEqual([worktree.id])
|
||||
})
|
||||
|
||||
it('adds the activated project to an active project filter', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { getWorktreeVisitKey } from './worktree-visit-recency'
|
||||
|
||||
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
@@ -39,7 +40,6 @@ describe('switching workspaces with the sidebar-inputs switch on', () => {
|
||||
vi.useFakeTimers({ now: 50_000, toFake: ['Date'] })
|
||||
const shells = worktree('shells')
|
||||
const next = worktree('next')
|
||||
const markWorktreeVisited = vi.fn()
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{ id: 'repo-1', path: '/workspace/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }
|
||||
@@ -52,16 +52,15 @@ describe('switching workspaces with the sidebar-inputs switch on', () => {
|
||||
tabsByWorktree: { [shells.id]: [], [next.id]: [] },
|
||||
ptyIdsByTabId: {},
|
||||
everActivatedWorktreeIds: new Set([shells.id, next.id]),
|
||||
markWorktreeVisited,
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
refreshGitHubForWorktreeIfStale: vi.fn()
|
||||
})
|
||||
|
||||
activateAndRevealWorktree(next.id)
|
||||
|
||||
expect(markWorktreeVisited.mock.calls).toEqual([
|
||||
[shells.id, 49_999, 'local'],
|
||||
[next.id, 50_000]
|
||||
])
|
||||
expect(useAppStore.getState().lastVisitedAtByWorktreeId).toEqual({
|
||||
[getWorktreeVisitKey(shells.id, 'local')]: 49_999,
|
||||
[next.id]: 50_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { ActiveWorktreeStateTransition } from '@/store/slices/worktree-helpers'
|
||||
import { stampWorktreeVisit } from '@/store/slices/worktrees/session/worktree-visit-recency'
|
||||
import { appendHistoryEntry } from '@/store/slices/worktree-nav-history'
|
||||
|
||||
type PreviousActive = { worktreeId: string | null; hostId: ExecutionHostId | null }
|
||||
type VisitState = Pick<
|
||||
@@ -12,6 +15,33 @@ export function readPreviousActive(state: VisitState): PreviousActive {
|
||||
return { worktreeId: state.activeWorktreeId, hostId: state.activeWorkspaceExecutionHostId }
|
||||
}
|
||||
|
||||
/** Arguments of one markWorktreeVisited call. */
|
||||
type VisitStamp = Parameters<AppState['markWorktreeVisited']>
|
||||
|
||||
/** The recency stamps opening `workspaceId` writes, in write order. */
|
||||
export function visitStampsFor(
|
||||
workspaceId: string,
|
||||
previous: PreviousActive,
|
||||
nextHostId?: ExecutionHostId | null
|
||||
): VisitStamp[] {
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
return [[workspaceId]]
|
||||
}
|
||||
// Switch point: terminals no longer stamp Recent, so leaving a workspace marks it used up to now.
|
||||
const visitedAt = Date.now()
|
||||
// The same repoId::path on another host is another workspace.
|
||||
const left =
|
||||
previous.worktreeId !== null &&
|
||||
(previous.worktreeId !== workspaceId ||
|
||||
(nextHostId !== undefined && previous.hostId !== nextHostId))
|
||||
const stamps: VisitStamp[] = []
|
||||
if (left && previous.worktreeId) {
|
||||
stamps.push([previous.worktreeId, visitedAt - 1, previous.hostId ?? undefined])
|
||||
}
|
||||
stamps.push([workspaceId, visitedAt])
|
||||
return stamps
|
||||
}
|
||||
|
||||
/** Stamps focus recency for the workspace being opened; `nextHostId` when the caller names one. */
|
||||
export function markVisited(
|
||||
state: VisitState,
|
||||
@@ -19,19 +49,72 @@ export function markVisited(
|
||||
previous: PreviousActive,
|
||||
nextHostId?: ExecutionHostId | null
|
||||
): void {
|
||||
// Switch point: terminals no longer stamp Recent, so leaving a workspace marks it used up to now.
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
state.markWorktreeVisited(workspaceId)
|
||||
return
|
||||
for (const stamp of visitStampsFor(workspaceId, previous, nextHostId)) {
|
||||
state.markWorktreeVisited(...stamp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Recency stamps as markVisited writes them after activation: resolved against the post-activation selection. */
|
||||
function stampActivationVisits(
|
||||
current: AppState,
|
||||
worktreeId: string,
|
||||
previousActive: PreviousActive,
|
||||
nextHostId: ExecutionHostId | null
|
||||
): AppState['lastVisitedAtByWorktreeId'] {
|
||||
const visitState = {
|
||||
lastVisitedAtByWorktreeId: current.lastVisitedAtByWorktreeId,
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: nextHostId,
|
||||
getKnownWorktreeById: current.getKnownWorktreeById
|
||||
}
|
||||
for (const stamp of visitStampsFor(worktreeId, previousActive, nextHostId)) {
|
||||
visitState.lastVisitedAtByWorktreeId = stampWorktreeVisit(visitState, ...stamp)
|
||||
}
|
||||
return visitState.lastVisitedAtByWorktreeId
|
||||
}
|
||||
|
||||
// A de-duplicated append returns the whole input state; spreading it would undo the fresh visit stamps.
|
||||
function historyPatch({
|
||||
worktreeNavHistory,
|
||||
worktreeNavHistoryIndex
|
||||
}: Pick<AppState, 'worktreeNavHistory' | 'worktreeNavHistoryIndex'>): Pick<
|
||||
AppState,
|
||||
'worktreeNavHistory' | 'worktreeNavHistoryIndex'
|
||||
> {
|
||||
return { worktreeNavHistory, worktreeNavHistoryIndex }
|
||||
}
|
||||
|
||||
/** Folds the repo follow, recency stamps and nav-history entry into the activation write: each store write re-runs every subscriber's selector. */
|
||||
export function buildActivationStateTransition(
|
||||
state: VisitState & Pick<AppState, 'activeRepoId' | 'isNavigatingHistory'>,
|
||||
worktreeId: string,
|
||||
args: { repoId: string; stampsVisit: boolean; nextHostId: ExecutionHostId | null }
|
||||
): { stateTransition: ActiveWorktreeStateTransition } | undefined {
|
||||
const { stampsVisit, nextHostId } = args
|
||||
const repoId = args.repoId === state.activeRepoId ? null : args.repoId
|
||||
if (repoId === null && !stampsVisit) {
|
||||
return undefined
|
||||
}
|
||||
const previousActive = readPreviousActive(state)
|
||||
// History replay moves the index instead of visiting anew.
|
||||
const recordsHistory = stampsVisit && !state.isNavigatingHistory
|
||||
return {
|
||||
stateTransition: (current) => ({
|
||||
activate: true,
|
||||
patch: {
|
||||
...(repoId !== null ? { activeRepoId: repoId } : {}),
|
||||
...(stampsVisit
|
||||
? {
|
||||
lastVisitedAtByWorktreeId: stampActivationVisits(
|
||||
current,
|
||||
worktreeId,
|
||||
previousActive,
|
||||
nextHostId
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(recordsHistory ? historyPatch(appendHistoryEntry(current, worktreeId)) : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
const visitedAt = Date.now()
|
||||
// The same repoId::path on another host is another workspace.
|
||||
const left =
|
||||
previous.worktreeId !== null &&
|
||||
(previous.worktreeId !== workspaceId ||
|
||||
(nextHostId !== undefined && previous.hostId !== nextHostId))
|
||||
if (left && previous.worktreeId) {
|
||||
state.markWorktreeVisited(previous.worktreeId, visitedAt - 1, previous.hostId ?? undefined)
|
||||
}
|
||||
state.markWorktreeVisited(workspaceId, visitedAt)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@ import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar-
|
||||
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
|
||||
import { revealFloatingWorkspacePanel } from './floating-workspace-panel-reveal'
|
||||
import { findPendingWorktreeCreationId } from './pending-worktree-creation'
|
||||
import { markVisited, readPreviousActive } from './worktree-activation-visit'
|
||||
import {
|
||||
buildActivationStateTransition,
|
||||
markVisited,
|
||||
readPreviousActive
|
||||
} from './worktree-activation-visit'
|
||||
import { selectFolderWorkspaces } from '@/store/workspace-record-selectors'
|
||||
|
||||
/** Share palette/dialog activation; derive repoId and no-op if the selected worktree vanished. */
|
||||
@@ -202,16 +206,20 @@ export function activateAndRevealWorktree(
|
||||
state.activeWorkspaceExecutionHostId === (opts?.executionHostId ?? null) &&
|
||||
state.activeView === 'terminal'
|
||||
|
||||
if (wt.repoId !== state.activeRepoId) {
|
||||
state.setActiveRepo(wt.repoId)
|
||||
}
|
||||
|
||||
if (state.activeView !== 'terminal') {
|
||||
state.setActiveView('terminal')
|
||||
}
|
||||
|
||||
const previousActive = readPreviousActive(state)
|
||||
state.setActiveWorktree(worktreeId, opts?.executionHostId)
|
||||
state.setActiveWorktree(
|
||||
worktreeId,
|
||||
opts?.executionHostId,
|
||||
buildActivationStateTransition(state, worktreeId, {
|
||||
repoId: wt.repoId,
|
||||
// Why: Cmd+J focus recency (docs/cmd-j-empty-query-ordering.md); a plain reselect must not restamp it.
|
||||
stampsVisit: !isPlainAlreadyActiveTerminal,
|
||||
nextHostId: opts?.executionHostId ?? null
|
||||
})
|
||||
)
|
||||
const postActivationState = useAppStore.getState()
|
||||
const ownerRuntimeEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin(
|
||||
postActivationState,
|
||||
@@ -225,16 +233,6 @@ export function activateAndRevealWorktree(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: focus recency for Cmd+J ordering, distinct from recordWorktreeVisit/lastActivityAt; stamp before any later async step could throw. See docs/cmd-j-empty-query-ordering.md.
|
||||
if (!isPlainAlreadyActiveTerminal) {
|
||||
markVisited(state, worktreeId, previousActive, opts?.executionHostId ?? null)
|
||||
}
|
||||
|
||||
// Why: skip re-recording for goBack/goForward history navigation — it moves the index instead of visiting anew (isNavigatingHistory).
|
||||
if (!isPlainAlreadyActiveTerminal && !state.isNavigatingHistory) {
|
||||
state.recordWorktreeVisit(worktreeId)
|
||||
}
|
||||
|
||||
// Decide gate before resuming: resume consumes sleeping evidence, and late structured inventory can otherwise authorize a shell beside a pending chat (STA-1111).
|
||||
const shouldGateAgentActivation =
|
||||
!hasActivationWork &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import type { EditorGet, EditorSet } from '../types/editor-set-get'
|
||||
import type { EditorSlice } from '../types/editor-slice'
|
||||
import { defaultFileSearchState } from '../search/file-search-state'
|
||||
@@ -19,11 +20,16 @@ export function createFileSearchActions(
|
||||
fileSearchStateByWorktree: {},
|
||||
updateFileSearchState: (worktreeId, updates) =>
|
||||
set((s) => {
|
||||
const current = s.fileSearchStateByWorktree[worktreeId] || defaultFileSearchState()
|
||||
const existing = s.fileSearchStateByWorktree[worktreeId]
|
||||
const next = { ...(existing || defaultFileSearchState()), ...updates }
|
||||
// Switching workspaces cancels the outgoing idle search; a no-op write would re-notify every subscriber.
|
||||
if (existing && shallow(existing, next)) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
fileSearchStateByWorktree: {
|
||||
...s.fileSearchStateByWorktree,
|
||||
[worktreeId]: { ...current, ...updates }
|
||||
[worktreeId]: next
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -147,7 +147,7 @@ function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean {
|
||||
return findWorktreeById(selectWorktreesByRepo(state), entry) !== undefined
|
||||
}
|
||||
|
||||
function appendHistoryEntry(
|
||||
export function appendHistoryEntry(
|
||||
s: { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number },
|
||||
entry: WorktreeNavHistoryEntry
|
||||
): { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number } {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { runAsAutomaticDeselection } from '@/store/active-worktree-deselection-intent'
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { AppState } from '../../../types'
|
||||
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
|
||||
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
@@ -19,32 +20,55 @@ export function createMarkWorktreeVisited(
|
||||
_get: WorktreeSliceGet
|
||||
): WorktreeSlice['markWorktreeVisited'] {
|
||||
return (worktreeId, visitedAt, executionHostId) => {
|
||||
// Cmd+J needs user-focus recency independent of PTY activity; racing CLI/IPC timestamps must not regress (cmd-j-empty-query-ordering.md).
|
||||
set((s) => {
|
||||
const now = visitedAt ?? Date.now()
|
||||
const ownerHostId =
|
||||
executionHostId ??
|
||||
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
|
||||
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
|
||||
const visitKey = getWorktreeVisitKey(worktreeId, ownerHostId)
|
||||
const prev =
|
||||
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
|
||||
id: worktreeId,
|
||||
hostId: ownerHostId
|
||||
}) ?? 0
|
||||
if (!(now > prev)) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
lastVisitedAtByWorktreeId: {
|
||||
...s.lastVisitedAtByWorktreeId,
|
||||
[visitKey]: now
|
||||
}
|
||||
}
|
||||
const lastVisitedAtByWorktreeId = stampWorktreeVisit(
|
||||
s,
|
||||
worktreeId,
|
||||
visitedAt,
|
||||
executionHostId
|
||||
)
|
||||
return lastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId
|
||||
? s
|
||||
: { lastVisitedAtByWorktreeId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeVisitStampState = Pick<
|
||||
AppState,
|
||||
| 'lastVisitedAtByWorktreeId'
|
||||
| 'activeWorktreeId'
|
||||
| 'activeWorkspaceExecutionHostId'
|
||||
| 'getKnownWorktreeById'
|
||||
>
|
||||
|
||||
/** Returns the visit map with `worktreeId` stamped, or the same map when the stamp would not advance. */
|
||||
export function stampWorktreeVisit(
|
||||
s: WorktreeVisitStampState,
|
||||
worktreeId: string,
|
||||
visitedAt?: number,
|
||||
executionHostId?: ExecutionHostId
|
||||
): AppState['lastVisitedAtByWorktreeId'] {
|
||||
// Cmd+J needs user-focus recency independent of PTY activity; racing CLI/IPC timestamps must not regress (cmd-j-empty-query-ordering.md).
|
||||
const now = visitedAt ?? Date.now()
|
||||
const ownerHostId =
|
||||
executionHostId ??
|
||||
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
|
||||
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
|
||||
const prev =
|
||||
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
|
||||
id: worktreeId,
|
||||
hostId: ownerHostId
|
||||
}) ?? 0
|
||||
if (!(now > prev)) {
|
||||
return s.lastVisitedAtByWorktreeId
|
||||
}
|
||||
return {
|
||||
...s.lastVisitedAtByWorktreeId,
|
||||
[getWorktreeVisitKey(worktreeId, ownerHostId)]: now
|
||||
}
|
||||
}
|
||||
|
||||
export function createPruneLastVisitedTimestamps(
|
||||
set: WorktreeSliceSet,
|
||||
_get: WorktreeSliceGet
|
||||
|
||||
Reference in New Issue
Block a user