| 1 | import { useFocusEffect } from "@react-navigation/native"; |
| 2 | import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 3 | import { |
| 4 | BackHandler, |
| 5 | Keyboard, |
| 6 | Platform, |
| 7 | Pressable, |
| 8 | TextInput, |
| 9 | View, |
| 10 | useWindowDimensions, |
| 11 | type TextInputInstance, |
| 12 | } from "react-native"; |
| 13 | import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | |
| 17 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 18 | import { GlassSurface } from "../../components/GlassSurface"; |
| 19 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 20 | import { WorkspaceDock } from "./WorkspaceDock"; |
| 21 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; |
| 22 | import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state"; |
| 23 | |
| 24 | const TABS: ReadonlyArray<{ id: WorkspaceTab; label: string; icon: AppSymbolName }> = [ |
| 25 | { id: "threads", label: "Threads", icon: "text.bubble" }, |
| 26 | { id: "pullRequests", label: "PRs", icon: "arrow.triangle.pull" }, |
| 27 | ]; |
| 28 | |
| 29 | function ToolbarButton(props: { |
| 30 | label: string; |
| 31 | icon: AppSymbolName; |
| 32 | onPress: () => void; |
| 33 | size?: number; |
| 34 | }) { |
| 35 | const size = props.size ?? WORKSPACE_TOOLBAR.height; |
| 36 | return ( |
| 37 | <GlassSurface style={{ width: size, height: size, borderRadius: size / 2 }}> |
| 38 | <Pressable |
| 39 | accessibilityRole="button" |
| 40 | accessibilityLabel={props.label} |
| 41 | onPress={props.onPress} |
| 42 | className="flex-1 items-center justify-center" |
| 43 | > |
| 44 | <SymbolView name={props.icon} size={22} tintColorClassName="accent-icon" /> |
| 45 | </Pressable> |
| 46 | </GlassSurface> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | export function WorkspaceToolbar(props: { |
| 51 | readonly sidebar?: boolean; |
| 52 | readonly onNewTask: () => void; |
| 53 | }) { |
| 54 | const workspace = useWorkspaceTabs(); |
| 55 | const input = useRef<TextInputInstance>(null); |
| 56 | const [focusRequest, setFocusRequest] = useState(0); |
| 57 | const insets = useSafeAreaInsets(); |
| 58 | const { width } = useWindowDimensions(); |
| 59 | const edge = width < 360 ? 12 : WORKSPACE_TOOLBAR.edge; |
| 60 | const gap = width < 360 ? 6 : WORKSPACE_TOOLBAR.gap; |
| 61 | const keyboardVisible = useKeyboardState((state) => state.isVisible); |
| 62 | const inputStyle = useResolveClassNames("text-foreground font-sans text-base"); |
| 63 | const placeholderStyle = useResolveClassNames("text-foreground-muted"); |
| 64 | const focusSearch = useCallback(() => { |
| 65 | if (!workspace) return false; |
| 66 | const tab = workspace.state.selectedTab; |
| 67 | if (tab !== "threads" && tab !== "pullRequests") return false; |
| 68 | workspace.dispatch({ type: "scroll", tab, offset: workspace.getScrollOffset(tab) }); |
| 69 | workspace.dispatch({ type: "search" }); |
| 70 | setFocusRequest((request) => request + 1); |
| 71 | return true; |
| 72 | }, [workspace]); |
| 73 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 74 | useFocusEffect(useCallback(() => () => Keyboard.dismiss(), [])); |
| 75 | const cancelSearch = useCallback(() => { |
| 76 | Keyboard.dismiss(); |
| 77 | if (workspace) |
| 78 | workspace.recordScrollOffset( |
| 79 | workspace.state.selectedTab, |
| 80 | workspace.state.tabs[workspace.state.selectedTab].searchEntryOffset, |
| 81 | ); |
| 82 | workspace?.dispatch({ type: "cancel" }); |
| 83 | }, [workspace]); |
| 84 | const handleBack = useEffectEvent(() => { |
| 85 | if (!workspace || workspace.state.tabs[workspace.state.selectedTab].searchMode === "resting") |
| 86 | return false; |
| 87 | if (keyboardVisible) Keyboard.dismiss(); |
| 88 | else cancelSearch(); |
| 89 | return true; |
| 90 | }); |
| 91 | useEffect(() => { |
| 92 | if (Platform.OS !== "android") return; |
| 93 | const subscription = BackHandler.addEventListener("hardwareBackPress", handleBack); |
| 94 | return () => subscription.remove(); |
| 95 | }, []); |
| 96 | useEffect(() => { |
| 97 | if (focusRequest > 0) input.current?.focus(); |
| 98 | }, [focusRequest]); |
| 99 | if (!workspace) return null; |
| 100 | const { state, dispatch } = workspace; |
| 101 | const tab = state.tabs[state.selectedTab]; |
| 102 | const activeTab = TABS.find((item) => item.id === state.selectedTab)!; |
| 103 | const searching = tab.searchMode !== "resting"; |
| 104 | const expanded = tab.searchMode === "expanded" && !keyboardVisible; |
| 105 | const showNavigation = !searching || expanded; |
| 106 | const bottom = Math.max(insets.bottom, 12); |
| 107 | |
| 108 | return ( |
| 109 | <KeyboardStickyView |
| 110 | pointerEvents="box-none" |
| 111 | className="absolute inset-x-0 bottom-0" |
| 112 | offset={{ closed: 0, opened: bottom - 8 }} |
| 113 | > |
| 114 | <View |
| 115 | pointerEvents="box-none" |
| 116 | style={{ paddingHorizontal: edge, paddingBottom: bottom, gap: 10 }} |
| 117 | > |
| 118 | {searching ? ( |
| 119 | <View className="flex-row items-center" style={{ gap }}> |
| 120 | {!keyboardVisible && !expanded ? ( |
| 121 | <ToolbarButton |
| 122 | label={`Show tabs, ${activeTab.label} selected`} |
| 123 | icon={activeTab.icon} |
| 124 | size={48} |
| 125 | onPress={() => dispatch({ type: "expand" })} |
| 126 | /> |
| 127 | ) : null} |
| 128 | <GlassSurface className="min-w-0 flex-1" style={{ height: 48, borderRadius: 24 }}> |
| 129 | <View className="flex-1 flex-row items-center pl-4"> |
| 130 | <SymbolView |
| 131 | name="magnifyingglass" |
| 132 | size={20} |
| 133 | tintColorClassName="accent-icon-muted" |
| 134 | /> |
| 135 | <TextInput |
| 136 | ref={input} |
| 137 | accessibilityLabel={`Search ${activeTab.label}`} |
| 138 | placeholder={`Search ${activeTab.label === "PRs" ? "pull requests" : "threads"}`} |
| 139 | placeholderTextColor={placeholderStyle.color} |
| 140 | value={tab.query} |
| 141 | onChangeText={(query) => dispatch({ type: "query", query })} |
| 142 | onFocus={() => dispatch({ type: "search" })} |
| 143 | onSubmitEditing={() => Keyboard.dismiss()} |
| 144 | autoCapitalize="none" |
| 145 | autoCorrect={false} |
| 146 | returnKeyType="search" |
| 147 | maxLength={state.selectedTab === "pullRequests" ? 200 : undefined} |
| 148 | style={[ |
| 149 | inputStyle, |
| 150 | { flex: 1, minWidth: 0, paddingHorizontal: 8, paddingVertical: 0 }, |
| 151 | ]} |
| 152 | /> |
| 153 | {tab.query ? ( |
| 154 | <Pressable |
| 155 | accessibilityRole="button" |
| 156 | accessibilityLabel="Clear search" |
| 157 | onPress={() => { |
| 158 | dispatch({ type: "query", query: "" }); |
| 159 | focusSearch(); |
| 160 | }} |
| 161 | className="h-11 w-11 items-center justify-center" |
| 162 | > |
| 163 | <SymbolView |
| 164 | name="xmark.circle.fill" |
| 165 | size={18} |
| 166 | tintColorClassName="accent-icon-muted" |
| 167 | /> |
| 168 | </Pressable> |
| 169 | ) : null} |
| 170 | </View> |
| 171 | </GlassSurface> |
| 172 | <ToolbarButton label="Cancel search" icon="xmark" size={48} onPress={cancelSearch} /> |
| 173 | </View> |
| 174 | ) : null} |
| 175 | {showNavigation ? <WorkspaceDock onNewTask={props.onNewTask} /> : null} |
| 176 | </View> |
| 177 | </KeyboardStickyView> |
| 178 | ); |
| 179 | } |