Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 94 additions & 24 deletions app/projects/[id]/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +19,7 @@ import {
useWorkspaceStore,
} from '@/state';
import { useTokens } from '@/theme';
import type { Tab } from '@/transport';

export default function WorkspaceScreen() {
const tokens = useTokens();
Expand All @@ -29,34 +32,66 @@ 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) : [];
const focusedArea = workspace
? 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<PagerView>(null);
const stripRef = useRef<WorkspaceTabStripHandle>(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 = () => (
<HeaderIconButton
icon="git-branch-outline"
Expand All @@ -65,6 +100,8 @@ export default function WorkspaceScreen() {
/>
);

const initialPage = activeIndex >= 0 ? activeIndex : 0;

return (
<View style={[styles.root, { backgroundColor: tokens.surface.primary }]}>
<Stack.Screen options={{ title: headerTitle, headerRight: headerGitButton }} />
Expand Down Expand Up @@ -95,23 +132,39 @@ export default function WorkspaceScreen() {
) : (
<>
<WorkspaceTabStrip
ref={stripRef}
tabs={allTabs.map((e) => e.tab)}
activeTabId={activeTabId}
onSelect={onSelectTab}
/>
<View style={styles.body}>
{activeTab ? (
activeTab.kind === 'terminal' && activeTab.paneID ? (
<TerminalView key={activeTab.paneID} paneId={activeTab.paneID} />
) : (
<TabKindPlaceholder tab={activeTab} />
)
) : (
<Centered tokens={tokens}>
<Text style={[styles.hint, { color: tokens.text.muted }]}>No active tab.</Text>
</Centered>
)}
</View>
<PagerView
key={allTabs.map((e) => 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 (
<View key={entry.tab.id} style={styles.page}>
{entry.tab.kind === 'terminal' && entry.tab.paneID ? (
isActive ? (
<TerminalView paneId={entry.tab.paneID} />
) : (
<TerminalPagePlaceholder tab={entry.tab} background={terminalBg} />
)
) : (
<TabKindPlaceholder tab={entry.tab} />
)}
</View>
);
})}
</PagerView>
</>
)}
</View>
Expand All @@ -122,11 +175,28 @@ function Centered({ children, tokens }: { children: React.ReactNode; tokens: Ret
return <View style={[styles.center, { backgroundColor: tokens.surface.primary }]}>{children}</View>;
}

function TerminalPagePlaceholder({ tab, background }: { tab: Tab; background: string }) {
const tokens = useTokens();
return (
<View style={[styles.terminalPlaceholder, { backgroundColor: background }]}>
<ActivityIndicator color={tokens.text.muted} />
{tab.title ? (
<Text style={[styles.terminalPlaceholderLabel, { color: tokens.text.muted }]} numberOfLines={1}>
{tab.title}
</Text>
) : null}
</View>
);
}

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' },
});
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 71 additions & 5 deletions src/components/WorkspaceTabStrip.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<WorkspaceTabStripHandle, Props>(function WorkspaceTabStrip(
{ tabs, activeTabId, onSelect },
ref,
) {
const tokens = useTokens();
const scrollRef = useRef<ScrollView>(null);
const tabLayoutsRef = useRef<Record<string, { x: number; width: number }>>({});
const viewportWidthRef = useRef(0);
const [previewTabId, setPreviewTabId] = useState<string | null>(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 (
<View style={[styles.bar, { borderBottomColor: tokens.border.subtle }]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
onLayout={(e) => {
viewportWidthRef.current = e.nativeEvent.layout.width;
}}
contentContainerStyle={styles.row}>
{tabs.map((tab) => {
const active = tab.id === activeTabId;
const active = tab.id === visiblyActiveId;
return (
<Pressable
key={tab.id}
onPress={() => onSelect(tab.id)}
disabled={active}
disabled={tab.id === activeTabId}
onLayout={(e) => onTabLayout(tab.id, e)}
style={({ pressed }) => [
styles.tab,
{
Expand Down Expand Up @@ -55,7 +121,7 @@ export function WorkspaceTabStrip({ tabs, activeTabId, onSelect }: Props) {
</ScrollView>
</View>
);
}
});

function iconForKind(kind: TabKind): keyof typeof Ionicons.glyphMap {
switch (kind) {
Expand Down
26 changes: 21 additions & 5 deletions src/components/terminal/TerminalView.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
Loading