1import { useFocusEffect } from "@react-navigation/native";
2import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
3import {
4 BackHandler,
5 Keyboard,
6 Platform,
7 Pressable,
8 TextInput,
9 View,
10 useWindowDimensions,
11 type TextInputInstance,
12} from "react-native";
13import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15import { useResolveClassNames } from "uniwind";
16
17import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
18import { GlassSurface } from "../../components/GlassSurface";
19import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
20import { WorkspaceDock } from "./WorkspaceDock";
21import { useWorkspaceTabs } from "./WorkspaceTabsContext";
22import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state";
23
24const 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
29function 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
50export 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}