diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2d5ad9909..344414d8c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,6 +1,12 @@ import "@/styles/unistyles"; import { polyfillCrypto } from "@/polyfills/crypto"; -import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router"; +import { + Stack, + useGlobalSearchParams, + useNavigationContainerRef, + usePathname, + useRouter, +} from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -68,6 +74,7 @@ import { } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; import { attachConsole } from "@/utils/tauri-attach-console"; +import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; polyfillCrypto(); attachConsole(); @@ -501,6 +508,26 @@ function AppWithSidebar({ children }: { children: ReactNode }) { ); } +function NavigationActiveWorkspaceObserver() { + const navigationRef = useNavigationContainerRef(); + + useEffect(() => { + syncNavigationActiveWorkspace(navigationRef); + const unsubscribeState = navigationRef.addListener("state", () => { + syncNavigationActiveWorkspace(navigationRef); + }); + const unsubscribeReady = navigationRef.addListener("ready" as never, () => { + syncNavigationActiveWorkspace(navigationRef); + }); + return () => { + unsubscribeState(); + unsubscribeReady(); + }; + }, [navigationRef]); + + return null; +} + function LoadingView({ message }: { message?: string } = {}) { return ( + diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a128b0136..9fe3ce2aa 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -20,7 +20,7 @@ import { type ReactElement, type MutableRefObject, } from 'react' -import { router, useGlobalSearchParams, usePathname } from 'expo-router' +import { router, usePathname } from 'expo-router' import { navigateToWorkspace } from '@/hooks/use-workspace-navigation' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' @@ -35,10 +35,8 @@ import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { buildHostNewAgentRoute, buildHostWorkspaceRoute, - decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, } from '@/utils/host-routes' -import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity' import { type SidebarProjectEntry, type SidebarWorkspaceEntry, @@ -73,6 +71,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' +import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -1212,10 +1211,7 @@ export function SidebarWorkspaceList({ const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isNative = Platform.OS !== 'web' const pathname = usePathname() - const routeParams = useGlobalSearchParams<{ - serverId?: string | string[] - workspaceId?: string | string[] - }>() + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() const isTauri = getIsTauri() const altDown = useKeyboardShortcutsStore((state) => state.altDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) @@ -1226,40 +1222,8 @@ export function SidebarWorkspaceList({ const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder) const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder) - const activeWorkspaceSelection = useMemo(() => { - if (!pathname) { - return null - } - if (!parseHostWorkspaceRouteFromPathname(pathname)) { - return null - } - - const serverValue = Array.isArray(routeParams.serverId) - ? routeParams.serverId[0] - : routeParams.serverId - const workspaceValue = Array.isArray(routeParams.workspaceId) - ? routeParams.workspaceId[0] - : routeParams.workspaceId - const serverId = serverValue?.trim() ?? '' - const workspaceId = normalizeWorkspaceIdentity( - workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null - ) - if (!serverId || !workspaceId) { - const parsed = parseHostWorkspaceRouteFromPathname(pathname) - if (!parsed) { - return null - } - return { - serverId: parsed.serverId, - workspaceId: parsed.workspaceId, - } - } - - return { - serverId, - workspaceId, - } - }, [pathname, routeParams.serverId, routeParams.workspaceId]) + const isWorkspaceRoute = useMemo(() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname]) + const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null const projectIconRequests = useMemo(() => { if (!serverId) { @@ -1409,7 +1373,7 @@ export function SidebarWorkspaceList({ displayName={item.projectName} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} serverId={serverId} - activeWorkspaceSelection={activeWorkspaceSelection} + activeWorkspaceSelection={effectiveActiveWorkspaceSelection} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} parentGestureRef={parentGestureRef} @@ -1425,8 +1389,8 @@ export function SidebarWorkspaceList({ ) }, [ - activeWorkspaceSelection, collapsedProjectKeys, + effectiveActiveWorkspaceSelection, handleCreateWorktree, handleWorkspaceReorder, onWorkspacePress, diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts new file mode 100644 index 000000000..16674f40e --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -0,0 +1,97 @@ +import { useSyncExternalStore } from "react"; +import { + decodeWorkspaceIdFromPathSegment, + parseHostWorkspaceRouteFromPathname, +} from "@/utils/host-routes"; + +interface ActiveWorkspaceSelection { + serverId: string; + workspaceId: string; +} + +type NavigationRouteLike = { + name?: unknown; + params?: unknown; + path?: unknown; +}; + +interface NavigationObserverRef { + current: { + getCurrentRoute(): unknown; + } | null; +} + +let snapshot: ActiveWorkspaceSelection | null = null; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ActiveWorkspaceSelection | null { + return snapshot; +} + +function emitIfChanged(next: ActiveWorkspaceSelection | null) { + if ( + snapshot?.serverId === next?.serverId && + snapshot?.workspaceId === next?.workspaceId + ) { + return; + } + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +function extractActiveWorkspaceFromRoute(route: NavigationRouteLike | undefined): ActiveWorkspaceSelection | null { + if (!route) { + return null; + } + + if (typeof route.path === "string") { + const parsed = parseHostWorkspaceRouteFromPathname(route.path); + if (parsed) { + return parsed; + } + } + + const params = + route.params && typeof route.params === "object" + ? (route.params as { + serverId?: string | string[]; + workspaceId?: string | string[]; + }) + : null; + const serverValue = Array.isArray(params?.serverId) ? params?.serverId[0] : params?.serverId; + const workspaceValue = Array.isArray(params?.workspaceId) + ? params?.workspaceId[0] + : params?.workspaceId; + const serverId = typeof serverValue === "string" ? serverValue.trim() : ""; + const workspaceId = + typeof workspaceValue === "string" + ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") + : ""; + + if (!serverId || !workspaceId) { + return null; + } + + return { serverId, workspaceId }; +} + +export function syncNavigationActiveWorkspace( + navigationRef: NavigationObserverRef +) { + emitIfChanged( + extractActiveWorkspaceFromRoute(navigationRef.current?.getCurrentRoute() as NavigationRouteLike | undefined) + ); +} + +export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}