diff --git a/app/projects/[id]/index.tsx b/app/projects/[id]/index.tsx index f634b46..f7e5485 100644 --- a/app/projects/[id]/index.tsx +++ b/app/projects/[id]/index.tsx @@ -1,12 +1,14 @@ import { Stack, useLocalSearchParams } from 'expo-router'; -import { useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import PagerView from 'react-native-pager-view'; import { GitSheet } from '@/components/git/GitSheet'; import { HeaderIconButton } from '@/components/HeaderIconButton'; import { TabKindPlaceholder } from '@/components/TabKindPlaceholder'; +import { buildTerminalTheme } from '@/components/terminal/buildTerminalTheme'; import { TerminalView } from '@/components/terminal/TerminalView'; -import { WorkspaceTabStrip } from '@/components/WorkspaceTabStrip'; +import { WorkspaceTabStrip, type WorkspaceTabStripHandle } from '@/components/WorkspaceTabStrip'; import { client, findArea, @@ -17,6 +19,7 @@ import { useWorkspaceStore, } from '@/state'; import { useTokens } from '@/theme'; +import type { Tab } from '@/transport'; export default function WorkspaceScreen() { const tokens = useTokens(); @@ -29,6 +32,23 @@ export default function WorkspaceScreen() { const fetchPhase = useWorkspaceStore((s) => s.fetchPhase); const fetchError = useWorkspaceStore((s) => s.fetchError); + const lastTheme = useDevicesStore((s) => s.lastAppliedTheme); + const activePairing = useDevicesStore((s) => { + const did = s.activeDeviceId; + if (!did) return null; + return s.devices.find((d) => d.id === did)?.pairing ?? null; + }); + const terminalBg = useMemo(() => { + const device = activePairing + ? { + themeFg: activePairing.themeFg, + themeBg: activePairing.themeBg, + themePalette: activePairing.themePalette, + } + : lastTheme; + return buildTerminalTheme(device, tokens).background; + }, [activePairing, lastTheme, tokens]); + useWorkspace(id); const allTabs = workspace ? flattenTabs(workspace.root) : []; @@ -36,27 +56,42 @@ export default function WorkspaceScreen() { ? findArea(workspace.root, workspace.focusedAreaID) ?? null : null; const activeTabId = focusedArea?.activeTabID; - const activeEntry = activeTabId ? allTabs.find((e) => e.tab.id === activeTabId) : undefined; - const activeTab = activeEntry?.tab; + const activeIndex = activeTabId ? allTabs.findIndex((e) => e.tab.id === activeTabId) : -1; const headerTitle = project?.name ?? 'Workspace'; - const onSelectTab = (tabId: string) => { - if (!id) return; - if (activeTabId === tabId) return; + const pagerRef = useRef(null); + const stripRef = useRef(null); + const lastSyncedIndexRef = useRef(activeIndex); - const target = allTabs.find((e) => e.tab.id === tabId); - if (!target) return; + useEffect(() => { + if (activeIndex < 0) return; + if (activeIndex === lastSyncedIndexRef.current) return; + lastSyncedIndexRef.current = activeIndex; + pagerRef.current?.setPage(activeIndex); + stripRef.current?.scrollToIndex(activeIndex, true); + }, [activeIndex]); - useWorkspaceStore.getState().selectTabLocal(target.areaId, tabId); + const selectTabAt = (index: number) => { + if (!id) return; + const target = allTabs[index]; + if (!target) return; + if (target.tab.id === activeTabId) return; + lastSyncedIndexRef.current = index; + useWorkspaceStore.getState().selectTabLocal(target.areaId, target.tab.id); client .request('selectTab', { type: 'selectTab', - value: { projectID: id, areaID: target.areaId, tabID: tabId }, + value: { projectID: id, areaID: target.areaId, tabID: target.tab.id }, }) .catch(() => {}); }; + const onSelectTab = (tabId: string) => { + const idx = allTabs.findIndex((e) => e.tab.id === tabId); + if (idx >= 0) selectTabAt(idx); + }; + const headerGitButton = () => ( ); + const initialPage = activeIndex >= 0 ? activeIndex : 0; + return ( @@ -95,23 +132,39 @@ export default function WorkspaceScreen() { ) : ( <> e.tab)} activeTabId={activeTabId} onSelect={onSelectTab} /> - - {activeTab ? ( - activeTab.kind === 'terminal' && activeTab.paneID ? ( - - ) : ( - - ) - ) : ( - - No active tab. - - )} - + e.tab.id).join('|')} + ref={pagerRef} + style={styles.body} + initialPage={initialPage} + offscreenPageLimit={1} + onPageScroll={(e) => { + const { position, offset } = e.nativeEvent; + stripRef.current?.scrollToIndex(position + offset, false); + }} + onPageSelected={(e) => selectTabAt(e.nativeEvent.position)}> + {allTabs.map((entry, index) => { + const isActive = index === activeIndex; + return ( + + {entry.tab.kind === 'terminal' && entry.tab.paneID ? ( + isActive ? ( + + ) : ( + + ) + ) : ( + + )} + + ); + })} + )} @@ -122,11 +175,28 @@ function Centered({ children, tokens }: { children: React.ReactNode; tokens: Ret return {children}; } +function TerminalPagePlaceholder({ tab, background }: { tab: Tab; background: string }) { + const tokens = useTokens(); + return ( + + + {tab.title ? ( + + {tab.title} + + ) : null} + + ); +} + const styles = StyleSheet.create({ root: { flex: 1 }, body: { flex: 1 }, + page: { flex: 1 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, gap: 10 }, title: { fontSize: 20, fontWeight: '600' }, hint: { fontSize: 14, textAlign: 'center' }, errorBody: { fontSize: 14, textAlign: 'center' }, + terminalPlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }, + terminalPlaceholderLabel: { fontSize: 13, fontWeight: '500' }, }); diff --git a/package-lock.json b/package-lock.json index 82ab4c3..f89f138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-native-gesture-handler": "~2.28.0", "react-native-iap": "^15.2.3", "react-native-keyboard-controller": "1.18.5", + "react-native-pager-view": "6.9.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -10534,6 +10535,16 @@ "react-native": "*" } }, + "node_modules/react-native-pager-view": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.9.1.tgz", + "integrity": "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-reanimated": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", diff --git a/package.json b/package.json index 0b06ffd..7231070 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-native-gesture-handler": "~2.28.0", "react-native-iap": "^15.2.3", "react-native-keyboard-controller": "1.18.5", + "react-native-pager-view": "6.9.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/src/components/WorkspaceTabStrip.tsx b/src/components/WorkspaceTabStrip.tsx index 79272de..8b73490 100644 --- a/src/components/WorkspaceTabStrip.tsx +++ b/src/components/WorkspaceTabStrip.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { type LayoutChangeEvent, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useTokens } from '@/theme'; import type { Tab, TabKind } from '@/transport'; @@ -10,21 +11,86 @@ type Props = { onSelect: (tabId: string) => void; }; -export function WorkspaceTabStrip({ tabs, activeTabId, onSelect }: Props) { +export type WorkspaceTabStripHandle = { + scrollToIndex: (fractionalIndex: number, animated: boolean) => void; +}; + +export const WorkspaceTabStrip = forwardRef(function WorkspaceTabStrip( + { tabs, activeTabId, onSelect }, + ref, +) { const tokens = useTokens(); + const scrollRef = useRef(null); + const tabLayoutsRef = useRef>({}); + const viewportWidthRef = useRef(0); + const [previewTabId, setPreviewTabId] = useState(null); + + const onTabLayout = (id: string, e: LayoutChangeEvent) => { + const { x, width } = e.nativeEvent.layout; + tabLayoutsRef.current[id] = { x, width }; + }; + + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + + useImperativeHandle( + ref, + () => ({ + scrollToIndex: (fractionalIndex, animated) => { + const currentTabs = tabsRef.current; + const nearest = Math.max(0, Math.min(currentTabs.length - 1, Math.round(fractionalIndex))); + const nearestTab = currentTabs[nearest]; + setPreviewTabId(nearestTab ? nearestTab.id : null); + + const vw = viewportWidthRef.current; + if (vw === 0) return; + const lo = Math.floor(fractionalIndex); + const hi = Math.ceil(fractionalIndex); + const loTab = currentTabs[lo]; + if (!loTab) return; + const loLayout = tabLayoutsRef.current[loTab.id]; + if (!loLayout) return; + const loCenter = loLayout.x + loLayout.width / 2; + let center = loCenter; + const hiTab = currentTabs[hi]; + if (hiTab && hi !== lo) { + const hiLayout = tabLayoutsRef.current[hiTab.id]; + if (hiLayout) { + const hiCenter = hiLayout.x + hiLayout.width / 2; + const t = fractionalIndex - lo; + center = loCenter + (hiCenter - loCenter) * t; + } + } + scrollRef.current?.scrollTo({ x: Math.max(0, center - vw / 2), animated }); + }, + }), + [], + ); + + useEffect(() => { + setPreviewTabId(null); + }, [activeTabId]); + + const visiblyActiveId = previewTabId ?? activeTabId; + return ( { + viewportWidthRef.current = e.nativeEvent.layout.width; + }} contentContainerStyle={styles.row}> {tabs.map((tab) => { - const active = tab.id === activeTabId; + const active = tab.id === visiblyActiveId; return ( onSelect(tab.id)} - disabled={active} + disabled={tab.id === activeTabId} + onLayout={(e) => onTabLayout(tab.id, e)} style={({ pressed }) => [ styles.tab, { @@ -55,7 +121,7 @@ export function WorkspaceTabStrip({ tabs, activeTabId, onSelect }: Props) { ); -} +}); function iconForKind(kind: TabKind): keyof typeof Ionicons.glyphMap { switch (kind) { diff --git a/src/components/terminal/TerminalView.tsx b/src/components/terminal/TerminalView.tsx index 24076af..fe9fe48 100644 --- a/src/components/terminal/TerminalView.tsx +++ b/src/components/terminal/TerminalView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; @@ -147,11 +147,27 @@ export function TerminalView({ paneId }: Props) { setInputValue(''); }, []); + const keyboardVisibleRef = useRef(false); + useEffect(() => { + const showSub = Keyboard.addListener('keyboardDidShow', () => { + keyboardVisibleRef.current = true; + }); + const hideSub = Keyboard.addListener('keyboardDidHide', () => { + keyboardVisibleRef.current = false; + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + const handleTap = useCallback(() => { - const node = inputRef.current; - if (!node) return; - if (node.isFocused()) node.blur(); - else node.focus(); + if (keyboardVisibleRef.current) { + Keyboard.dismiss(); + inputRef.current?.blur(); + return; + } + inputRef.current?.focus(); }, []); const { height } = useReanimatedKeyboardAnimation();