mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
fix: terminal output duplication on mobile
Remove duplicate onPtyData call in pty.ts that caused every daemon-backed PTY data event to be dispatched twice through dataListeners, doubling all terminal output sent to mobile clients. Also: clean up registerSubscriptionCleanup to evict stale listeners on reconnect, add regression tests, fix CI lint/type errors, add mobile UX improvements (worktree creation, settings, about screen). Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -23,7 +23,9 @@ export default function RootLayout() {
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ title: 'Pair Host' }} />
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Globe } from 'lucide-react-native'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
function GithubIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>About</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.brand}>
|
||||
<OrcaLogo size={28} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://onOrca.dev')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Website</Text>
|
||||
<Text style={styles.rowValue}>onOrca.dev</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<Text style={styles.rowLabel}>GitHub</Text>
|
||||
<Text style={styles.rowValue}>stablyai/orca</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://x.com/orca_build')}
|
||||
>
|
||||
<XIcon />
|
||||
<Text style={styles.rowLabel}>X</Text>
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
brand: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
brandSub: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowValue: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.accentBlue
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
+177
-23
@@ -22,7 +22,9 @@ import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
Moon
|
||||
Moon,
|
||||
Filter,
|
||||
Check
|
||||
} from 'lucide-react-native'
|
||||
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
|
||||
@@ -33,6 +35,7 @@ import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
|
||||
import { AgentSpinner } from '../../../src/components/AgentSpinner'
|
||||
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
||||
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { colors, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import {
|
||||
loadPinnedIds,
|
||||
@@ -57,9 +60,14 @@ type Worktree = {
|
||||
}
|
||||
|
||||
type SortMode = 'smart' | 'name' | 'recent'
|
||||
type FilterMode = 'all' | 'active'
|
||||
type _FilterMode = 'all' | 'active'
|
||||
type GroupMode = 'none' | 'repo' | 'prStatus'
|
||||
|
||||
type FilterState = {
|
||||
activeOnly: boolean
|
||||
selectedRepos: Set<string>
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connecting: 'Connecting…',
|
||||
handshaking: 'Securing…',
|
||||
@@ -114,11 +122,14 @@ function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] {
|
||||
})
|
||||
}
|
||||
|
||||
function filterWorktrees(worktrees: Worktree[], filter: FilterMode, search: string): Worktree[] {
|
||||
function filterWorktrees(worktrees: Worktree[], filters: FilterState, search: string): Worktree[] {
|
||||
let result = worktrees
|
||||
if (filter === 'active') {
|
||||
if (filters.activeOnly) {
|
||||
result = result.filter(isWorktreeActive)
|
||||
}
|
||||
if (filters.selectedRepos.size > 0) {
|
||||
result = result.filter((w) => filters.selectedRepos.has(w.repo))
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
result = result.filter(
|
||||
@@ -162,12 +173,12 @@ function isWorktreePinned(w: Worktree, localPins: Set<string>): boolean {
|
||||
function buildSections(
|
||||
worktrees: Worktree[],
|
||||
sortMode: SortMode,
|
||||
filterMode: FilterMode,
|
||||
filters: FilterState,
|
||||
search: string,
|
||||
groupMode: GroupMode,
|
||||
pinnedIds: Set<string>
|
||||
): Section[] {
|
||||
const filtered = filterWorktrees(worktrees, filterMode, search)
|
||||
const filtered = filterWorktrees(worktrees, filters, search)
|
||||
const sorted = sortWorktrees(filtered, sortMode)
|
||||
|
||||
const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds))
|
||||
@@ -232,12 +243,16 @@ export default function HostScreen() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [sortMode, setSortMode] = useState<SortMode>('smart')
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all')
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
activeOnly: false,
|
||||
selectedRepos: new Set()
|
||||
})
|
||||
const [groupMode, setGroupMode] = useState<GroupMode>('none')
|
||||
|
||||
// Modals
|
||||
const [showSortPicker, setShowSortPicker] = useState(false)
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
@@ -255,7 +270,10 @@ export default function HostScreen() {
|
||||
const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)])
|
||||
setPinnedIds(pins)
|
||||
setSortMode(prefs.sortMode as SortMode)
|
||||
setFilterMode(prefs.filterMode as FilterMode)
|
||||
setFilters({
|
||||
activeOnly: prefs.filterMode === 'active',
|
||||
selectedRepos: new Set(prefs.selectedRepos ?? [])
|
||||
})
|
||||
setGroupMode(prefs.groupMode as GroupMode)
|
||||
setCollapsedGroups(new Set(prefs.collapsedGroups))
|
||||
setPrefsLoaded(true)
|
||||
@@ -391,14 +409,43 @@ export default function HostScreen() {
|
||||
[hostId]
|
||||
)
|
||||
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setFilterMode((prev) => {
|
||||
const next: FilterMode = prev === 'all' ? 'active' : 'all'
|
||||
if (hostId) void savePreferences(hostId, { filterMode: next })
|
||||
const toggleActiveFilter = useCallback(() => {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev, activeOnly: !prev.activeOnly }
|
||||
if (hostId)
|
||||
void savePreferences(hostId, {
|
||||
filterMode: next.activeOnly ? 'active' : 'all'
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [hostId])
|
||||
|
||||
const toggleRepoFilter = useCallback(
|
||||
(repo: string) => {
|
||||
setFilters((prev) => {
|
||||
const next = new Set(prev.selectedRepos)
|
||||
if (next.has(repo)) next.delete(repo)
|
||||
else next.add(repo)
|
||||
const updated = { ...prev, selectedRepos: next }
|
||||
if (hostId) void savePreferences(hostId, { selectedRepos: [...next] })
|
||||
return updated
|
||||
})
|
||||
},
|
||||
[hostId]
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({ activeOnly: false, selectedRepos: new Set() })
|
||||
if (hostId) void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] })
|
||||
}, [hostId])
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0
|
||||
if (filters.activeOnly) count++
|
||||
count += filters.selectedRepos.size
|
||||
return count
|
||||
}, [filters])
|
||||
|
||||
const handleGroupChange = useCallback(
|
||||
(value: GroupMode) => {
|
||||
setGroupMode(value)
|
||||
@@ -412,6 +459,14 @@ export default function HostScreen() {
|
||||
? lastKnownWorktrees
|
||||
: worktrees
|
||||
|
||||
const uniqueRepos = useMemo(() => {
|
||||
const repos = new Map<string, string>()
|
||||
for (const w of displayWorktrees) {
|
||||
if (!repos.has(w.repo)) repos.set(w.repo, repoColor(w.repo))
|
||||
}
|
||||
return [...repos.entries()].map(([name, color]) => ({ name, color }))
|
||||
}, [displayWorktrees])
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
(title: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
@@ -426,8 +481,8 @@ export default function HostScreen() {
|
||||
)
|
||||
|
||||
const rawSections = useMemo(
|
||||
() => buildSections(displayWorktrees, sortMode, filterMode, search, groupMode, pinnedIds),
|
||||
[displayWorktrees, sortMode, filterMode, search, groupMode, pinnedIds]
|
||||
() => buildSections(displayWorktrees, sortMode, filters, search, groupMode, pinnedIds),
|
||||
[displayWorktrees, sortMode, filters, search, groupMode, pinnedIds]
|
||||
)
|
||||
|
||||
const sections = useMemo(
|
||||
@@ -470,16 +525,17 @@ export default function HostScreen() {
|
||||
{/* Filter/sort/group toolbar */}
|
||||
<View style={styles.toolbar}>
|
||||
<Pressable
|
||||
style={[styles.filterChip, filterMode === 'active' && styles.filterChipActive]}
|
||||
onPress={handleFilterToggle}
|
||||
style={[styles.filterChip, activeFilterCount > 0 && styles.filterChipActive]}
|
||||
onPress={() => setShowFilterModal(true)}
|
||||
>
|
||||
<Filter
|
||||
size={12}
|
||||
color={activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterChipText,
|
||||
filterMode === 'active' && styles.filterChipTextActive
|
||||
]}
|
||||
style={[styles.filterChipText, activeFilterCount > 0 && styles.filterChipTextActive]}
|
||||
>
|
||||
Active
|
||||
Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -574,8 +630,8 @@ export default function HostScreen() {
|
||||
<Text style={styles.emptyText}>
|
||||
{search
|
||||
? 'No matching worktrees'
|
||||
: filterMode === 'active'
|
||||
? 'No active worktrees'
|
||||
: activeFilterCount > 0
|
||||
? 'No worktrees match filters'
|
||||
: 'No worktrees'}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -697,6 +753,48 @@ export default function HostScreen() {
|
||||
onClose={() => setShowGroupPicker(false)}
|
||||
/>
|
||||
|
||||
{/* Filter modal — matches desktop's Status + Repositories dropdown */}
|
||||
<BottomDrawer visible={showFilterModal} onClose={() => setShowFilterModal(false)}>
|
||||
<View style={styles.filterModalHeader}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
{activeFilterCount > 0 && (
|
||||
<Pressable onPress={clearFilters}>
|
||||
<Text style={styles.clearFiltersText}>Clear filters</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.filterSectionLabel}>Status</Text>
|
||||
<View style={styles.filterGroup}>
|
||||
<Pressable style={styles.filterRow} onPress={toggleActiveFilter}>
|
||||
<Text style={styles.filterRowText}>Active only</Text>
|
||||
{filters.activeOnly && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{uniqueRepos.length > 1 && (
|
||||
<>
|
||||
<Text style={styles.filterSectionLabel}>Repositories</Text>
|
||||
<View style={styles.filterGroup}>
|
||||
{uniqueRepos.map((repo, i) => (
|
||||
<View key={repo.name}>
|
||||
{i > 0 && <View style={styles.filterSeparator} />}
|
||||
<Pressable style={styles.filterRow} onPress={() => toggleRepoFilter(repo.name)}>
|
||||
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
|
||||
<Text style={styles.filterRowText} numberOfLines={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{filters.selectedRepos.has(repo.name) && (
|
||||
<Check size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Worktree long-press action sheet */}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null && confirmDelete == null}
|
||||
@@ -873,6 +971,9 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
@@ -1061,5 +1162,58 @@ const styles = StyleSheet.create({
|
||||
minWidth: 16,
|
||||
textAlign: 'right',
|
||||
paddingTop: 3
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.accentBlue
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
}
|
||||
})
|
||||
|
||||
@@ -170,6 +170,7 @@ export default function SessionScreen() {
|
||||
const locallyCreatedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
|
||||
const sendingRef = useRef(false)
|
||||
|
||||
const canSend = connState === 'connected' && activeHandle != null
|
||||
|
||||
@@ -511,6 +512,12 @@ export default function SessionScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === 'connected') {
|
||||
// Why: on reconnect the RPC client auto-resends terminal.subscribe,
|
||||
// creating new server-side handlers. Clear local subscription state
|
||||
// so subscribeToTerminal's guards don't block fresh subscriptions,
|
||||
// and clear xterm buffers so the new scrollback snapshot replaces
|
||||
// stale content (including data that arrived while disconnected).
|
||||
clearTerminalCache()
|
||||
setTerminalsLoaded(false)
|
||||
void (async () => {
|
||||
// Why: worktree.create already asks the desktop renderer to activate
|
||||
@@ -592,7 +599,8 @@ export default function SessionScreen() {
|
||||
)
|
||||
|
||||
async function handleSend() {
|
||||
if (!client || !activeHandle) return
|
||||
if (!client || !activeHandle || sendingRef.current) return
|
||||
sendingRef.current = true
|
||||
|
||||
const text = input
|
||||
setInput('')
|
||||
@@ -605,6 +613,8 @@ export default function SessionScreen() {
|
||||
})
|
||||
} catch {
|
||||
setInput(text)
|
||||
} finally {
|
||||
sendingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-9
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import { MoreHorizontal, QrCode, Server } from 'lucide-react-native'
|
||||
import { MoreHorizontal, QrCode, Server, Settings } from 'lucide-react-native'
|
||||
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
@@ -206,6 +206,11 @@ export default function HomeScreen() {
|
||||
]}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
/>
|
||||
|
||||
<Pressable style={styles.settingsButton} onPress={() => router.push('/settings')}>
|
||||
<Settings size={16} color={colors.textMuted} />
|
||||
<Text style={styles.settingsText}>Settings</Text>
|
||||
</Pressable>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -228,14 +233,6 @@ const styles = StyleSheet.create({
|
||||
minWidth: 0
|
||||
},
|
||||
logoMark: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
brandName: {
|
||||
@@ -393,5 +390,19 @@ const styles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
marginTop: 2
|
||||
},
|
||||
settingsButton: {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
settingsText: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
||||
+87
-35
@@ -1,21 +1,21 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Monitor, Settings, Smartphone } from 'lucide-react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { decodePairingUrl } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { PairingOffer } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
function Step({ number, icon, text }: { number: number; icon: React.ReactNode; text: string }) {
|
||||
function Step({ number, text }: { number: number; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepBadge}>
|
||||
<Text style={styles.stepNumber}>{number}</Text>
|
||||
</View>
|
||||
<View style={styles.stepIcon}>{icon}</View>
|
||||
<Text style={styles.stepText}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
@@ -23,6 +23,7 @@ function Step({ number, icon, text }: { number: number; icon: React.ReactNode; t
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
@@ -94,9 +95,11 @@ export default function PairScanScreen() {
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
@@ -104,7 +107,10 @@ export default function PairScanScreen() {
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.title}>Camera Permission</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
@@ -119,32 +125,32 @@ export default function PairScanScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.steps}>
|
||||
<Step
|
||||
number={1}
|
||||
icon={<Monitor size={14} color={colors.textSecondary} />}
|
||||
text="Open Orca on your computer"
|
||||
/>
|
||||
<Step
|
||||
number={2}
|
||||
icon={<Settings size={14} color={colors.textSecondary} />}
|
||||
text="Go to Settings → Mobile"
|
||||
/>
|
||||
<Step
|
||||
number={3}
|
||||
icon={<Smartphone size={14} color={colors.textSecondary} />}
|
||||
text="Point this camera at the QR code"
|
||||
/>
|
||||
<Step number={1} text="Open Orca on your computer" />
|
||||
<Step number={2} text="Go to Settings → Mobile" />
|
||||
<Step number={3} text="Scan the QR code" />
|
||||
</View>
|
||||
|
||||
{status === 'scanning' && (
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.cameraWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.reticle} pointerEvents="none">
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
@@ -172,9 +178,18 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
@@ -194,20 +209,57 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepIcon: {
|
||||
width: 20,
|
||||
alignItems: 'center'
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
flex: 1
|
||||
color: colors.textSecondary
|
||||
},
|
||||
camera: {
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: '30%',
|
||||
left: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: '30%',
|
||||
right: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: '30%',
|
||||
left: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: '30%',
|
||||
right: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Info } from 'lucide-react-native'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/about')}
|
||||
>
|
||||
<Info size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>About</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useCallback, useEffect } from 'react'
|
||||
import { Modal, View, Pressable, StyleSheet, Platform, useWindowDimensions } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
@@ -13,7 +14,7 @@ import Animated, {
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const DISMISS_THRESHOLD = 80
|
||||
const SPRING_CONFIG = { damping: 20, stiffness: 300 }
|
||||
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
|
||||
// Why: negative translateY (pulling up) is damped with a rubber-band factor
|
||||
// so the drawer resists upward dragging — a subtle polish touch that signals
|
||||
// the drawer cannot expand further.
|
||||
@@ -29,6 +30,7 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
const translateY = useSharedValue(0)
|
||||
const backdropOpacity = useSharedValue(0)
|
||||
const { height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -51,9 +53,13 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
|
||||
translateY.value = withTiming(screenHeight, { duration: 200 })
|
||||
backdropOpacity.value = withTiming(0, { duration: 200 })
|
||||
runOnJS(dismiss)()
|
||||
const velocity = Math.max(e.velocityY, 800)
|
||||
const remaining = screenHeight - e.translationY
|
||||
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
|
||||
translateY.value = withTiming(screenHeight, { duration }, () => {
|
||||
runOnJS(dismiss)()
|
||||
})
|
||||
backdropOpacity.value = withTiming(0, { duration })
|
||||
} else {
|
||||
translateY.value = withSpring(0, SPRING_CONFIG)
|
||||
}
|
||||
@@ -76,9 +82,12 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
|
||||
<View style={styles.anchor} pointerEvents="box-none">
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={[styles.drawer, drawerStyle]}>
|
||||
<Animated.View
|
||||
style={[styles.drawer, { paddingBottom: insets.bottom + spacing.lg }, drawerStyle]}
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
{children}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
@@ -104,7 +113,6 @@ const styles = StyleSheet.create({
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.xl + spacing.md,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
@@ -124,5 +132,13 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
opacity: 0.4
|
||||
},
|
||||
bottomExtension: {
|
||||
position: 'absolute',
|
||||
bottom: -100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 100,
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Switch,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
FlatList
|
||||
} from 'react-native'
|
||||
import { ChevronDown, Check, Terminal } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native'
|
||||
import Svg, { Path, G } from 'react-native-svg'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
@@ -262,6 +263,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupCommand, setSetupCommand] = useState<string | null>(null)
|
||||
const [setupSource, setSetupSource] = useState<string | null>(null)
|
||||
const [runSetup, setRunSetup] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -269,6 +275,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
useEffect(() => {
|
||||
if (!visible || !client) return
|
||||
setName('')
|
||||
setNote('')
|
||||
setShowAdvanced(false)
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
setRunSetup(true)
|
||||
setError('')
|
||||
setCreating(false)
|
||||
setShowRepoPicker(false)
|
||||
@@ -296,8 +307,37 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
})()
|
||||
}, [visible, client])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !selectedRepo) {
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('repo.hooks', {
|
||||
repo: `id:${selectedRepo.id}`
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as {
|
||||
hooks: { scripts: { setup?: string } } | null
|
||||
source: string | null
|
||||
setupRunPolicy: string
|
||||
}
|
||||
const cmd = result.hooks?.scripts.setup ?? null
|
||||
setSetupCommand(cmd)
|
||||
setSetupSource(result.source)
|
||||
setRunSetup(result.setupRunPolicy !== 'skip-by-default')
|
||||
}
|
||||
} catch {
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
}
|
||||
})()
|
||||
}, [client, selectedRepo])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!client || !selectedRepo || !name.trim()) return
|
||||
if (!client || !selectedRepo) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
|
||||
@@ -305,36 +345,42 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
const command =
|
||||
selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined
|
||||
|
||||
const response = await client.sendRequest('worktree.create', {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${selectedRepo.id}`,
|
||||
name: name.trim(),
|
||||
startupCommand: command
|
||||
})
|
||||
startupCommand: command,
|
||||
setupDecision: runSetup ? 'inherit' : 'skip'
|
||||
}
|
||||
if (name.trim()) params.name = name.trim()
|
||||
if (note.trim()) params.comment = note.trim()
|
||||
|
||||
const response = await client.sendRequest('worktree.create', params)
|
||||
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
const worktreeId = result.worktree.id
|
||||
|
||||
onClose()
|
||||
onCreated(worktreeId, name.trim())
|
||||
onCreated(worktreeId, name.trim() || 'New workspace')
|
||||
} else {
|
||||
setError(response.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create worktree')
|
||||
setError(e instanceof Error ? e.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canCreate = selectedRepo != null && name.trim().length > 0 && !creating
|
||||
const canCreate = selectedRepo != null && !creating
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>New Worktree</Text>
|
||||
<Text style={styles.subtitle}>Pick a repository and agent</Text>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pick a repository and agent to spin up a new workspace.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -347,7 +393,6 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Repository */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={() => setShowRepoPicker(true)}>
|
||||
@@ -361,9 +406,10 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<Text style={styles.label}>
|
||||
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
@@ -371,7 +417,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
setName(t)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="my-feature"
|
||||
placeholder="Workspace name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -383,7 +429,6 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Agent */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={() => setShowAgentPicker(true)}>
|
||||
@@ -395,12 +440,65 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.advancedToggle} onPress={() => setShowAdvanced(!showAdvanced)}>
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Note</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder="Write a note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{setupCommand ? (
|
||||
<View style={styles.field}>
|
||||
<View style={styles.setupHeader}>
|
||||
<Text style={styles.label}>Setup script</Text>
|
||||
{setupSource && (
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={styles.sourceBadgeText}>
|
||||
{setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.setupBox}>
|
||||
<View style={styles.setupToggleRow}>
|
||||
<Text style={styles.setupToggleLabel}>Run setup command</Text>
|
||||
<Switch
|
||||
value={runSetup}
|
||||
onValueChange={setRunSetup}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
style={styles.setupSwitch}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.setupCommandBlock}>
|
||||
<Text style={styles.setupCommand}>{setupCommand}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.cancelButton} onPress={onClose}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.createButton, !canCreate && styles.createButtonDisabled]}
|
||||
disabled={!canCreate}
|
||||
@@ -409,7 +507,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
{creating ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>Create</Text>
|
||||
<Text style={styles.createText}>Create Workspace</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -468,13 +566,15 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
labelHint: {
|
||||
fontWeight: '400',
|
||||
color: colors.textMuted
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -509,22 +609,72 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
advancedText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
sourceBadge: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: spacing.xs + 2,
|
||||
paddingVertical: 2
|
||||
},
|
||||
sourceBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
setupBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md
|
||||
},
|
||||
setupToggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupToggleLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupSwitch: {
|
||||
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
|
||||
},
|
||||
setupCommandBlock: {
|
||||
backgroundColor: colors.bgBase,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupCommand: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
|
||||
@@ -8,13 +8,15 @@ export type HostPreferences = {
|
||||
filterMode: string
|
||||
groupMode: string
|
||||
collapsedGroups: string[]
|
||||
selectedRepos: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: HostPreferences = {
|
||||
sortMode: 'smart',
|
||||
filterMode: 'all',
|
||||
groupMode: 'none',
|
||||
collapsedGroups: []
|
||||
collapsedGroups: [],
|
||||
selectedRepos: []
|
||||
}
|
||||
|
||||
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
|
||||
|
||||
@@ -424,9 +424,6 @@ export function registerPtyHandlers(
|
||||
pendingData.clear()
|
||||
return
|
||||
}
|
||||
if (!isLocalProvider) {
|
||||
runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||||
}
|
||||
const existing = pendingData.get(payload.id)
|
||||
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
|
||||
if (!flushTimer) {
|
||||
|
||||
@@ -1922,4 +1922,87 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribeToTerminalData', () => {
|
||||
it('delivers PTY data to a subscriber', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
|
||||
const chunks: string[] = []
|
||||
runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data))
|
||||
|
||||
runtime.onPtyData('pty-1', 'hello\n', 100)
|
||||
runtime.onPtyData('pty-1', 'world\n', 101)
|
||||
|
||||
expect(chunks).toEqual(['hello\n', 'world\n'])
|
||||
})
|
||||
|
||||
it('unsubscribe removes the listener so data is not delivered twice', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
|
||||
const chunks: string[] = []
|
||||
const unsub = runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data))
|
||||
|
||||
runtime.onPtyData('pty-1', 'before\n', 100)
|
||||
unsub()
|
||||
runtime.onPtyData('pty-1', 'after\n', 101)
|
||||
|
||||
expect(chunks).toEqual(['before\n'])
|
||||
})
|
||||
|
||||
it('delivers data exactly once per listener even with multiple subscribers', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
|
||||
const chunksA: string[] = []
|
||||
const chunksB: string[] = []
|
||||
const unsubA = runtime.subscribeToTerminalData('pty-1', (data) => chunksA.push(data))
|
||||
runtime.subscribeToTerminalData('pty-1', (data) => chunksB.push(data))
|
||||
|
||||
runtime.onPtyData('pty-1', 'hello\n', 100)
|
||||
unsubA()
|
||||
runtime.onPtyData('pty-1', 'world\n', 101)
|
||||
|
||||
expect(chunksA).toEqual(['hello\n'])
|
||||
expect(chunksB).toEqual(['hello\n', 'world\n'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerSubscriptionCleanup', () => {
|
||||
it('cleans up the previous subscription when re-registering the same id', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
|
||||
const chunksA: string[] = []
|
||||
const chunksB: string[] = []
|
||||
const unsubA = runtime.subscribeToTerminalData('pty-1', (data) => chunksA.push(data))
|
||||
runtime.registerSubscriptionCleanup('sub-1', () => unsubA())
|
||||
|
||||
const unsubB = runtime.subscribeToTerminalData('pty-1', (data) => chunksB.push(data))
|
||||
runtime.registerSubscriptionCleanup('sub-1', () => unsubB())
|
||||
|
||||
runtime.onPtyData('pty-1', 'after-resubscribe\n', 100)
|
||||
|
||||
// Why: only the second listener should receive data. The first was
|
||||
// cleaned up when registerSubscriptionCleanup replaced 'sub-1'.
|
||||
expect(chunksA).toEqual([])
|
||||
expect(chunksB).toEqual(['after-resubscribe\n'])
|
||||
})
|
||||
|
||||
it('cleanupSubscription removes the listener', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
|
||||
const chunks: string[] = []
|
||||
const unsub = runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data))
|
||||
runtime.registerSubscriptionCleanup('sub-1', () => unsub())
|
||||
|
||||
runtime.onPtyData('pty-1', 'before\n', 100)
|
||||
runtime.cleanupSubscription('sub-1')
|
||||
runtime.onPtyData('pty-1', 'after\n', 101)
|
||||
|
||||
expect(chunks).toEqual(['before\n'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,7 +88,14 @@ import {
|
||||
searchBaseRefs
|
||||
} from '../git/repo'
|
||||
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
|
||||
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
getEffectiveSetupRunPolicy,
|
||||
hasHooksFile,
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
import { REPO_COLORS } from '../../shared/constants'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -696,6 +703,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
|
||||
// Why: mobile clients reconnect frequently (phone lock, network switch).
|
||||
// The RPC client re-sends terminal.subscribe on reconnect, creating a new
|
||||
// handler before the old one is cleaned up. Without this, the old data
|
||||
// listener leaks in dataListeners and duplicates every PTY data event.
|
||||
const existing = this.subscriptionCleanups.get(subscriptionId)
|
||||
if (existing) {
|
||||
existing()
|
||||
}
|
||||
this.subscriptionCleanups.set(subscriptionId, cleanup)
|
||||
}
|
||||
|
||||
@@ -1430,6 +1445,19 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async getRepoHooks(repoSelector: string) {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const hasFile = hasHooksFile(repo.path)
|
||||
const hooks = getEffectiveHooks(repo)
|
||||
const setupRunPolicy = getEffectiveSetupRunPolicy(repo)
|
||||
return {
|
||||
hasHooksFile: hasFile,
|
||||
hooks,
|
||||
setupRunPolicy,
|
||||
source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null
|
||||
}
|
||||
}
|
||||
|
||||
async listManagedWorktrees(
|
||||
repoSelector?: string,
|
||||
limit = DEFAULT_WORKTREE_LIST_LIMIT
|
||||
@@ -1476,6 +1504,7 @@ export class OrcaRuntimeService {
|
||||
linkedIssue?: number | null
|
||||
comment?: string
|
||||
runHooks?: boolean
|
||||
setupDecision?: 'run' | 'skip' | 'inherit'
|
||||
}): Promise<CreateWorktreeResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -1563,7 +1592,14 @@ export class OrcaRuntimeService {
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
let warning: string | undefined
|
||||
const hooks = getEffectiveHooks(repo)
|
||||
if (hooks?.scripts.setup && args.runHooks === true) {
|
||||
// Why: setupDecision lets mobile/CLI callers control whether the setup
|
||||
// script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default)
|
||||
// defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps
|
||||
// to 'run' for backwards compatibility with the desktop create flow.
|
||||
const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit')
|
||||
const shouldRunSetup =
|
||||
hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
|
||||
if (shouldRunSetup && hooks?.scripts.setup) {
|
||||
if (this.authoritativeWindowId !== null) {
|
||||
try {
|
||||
// Why: CLI-created worktrees must use the same runner-script path as the
|
||||
|
||||
@@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
params: RepoSearchRefs,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.searchRepoRefs(params.repo, params.query, params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.hooks',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -28,14 +28,17 @@ const WorktreeCreate = z.object({
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree name')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
runHooks: OptionalBoolean
|
||||
runHooks: OptionalBoolean,
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.enum(['run', 'skip', 'inherit']).optional())
|
||||
})
|
||||
|
||||
const WorktreeSet = WorktreeSelector.extend({
|
||||
@@ -78,11 +81,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: params.repo,
|
||||
name: params.name,
|
||||
name: params.name ?? '',
|
||||
baseBranch: params.baseBranch,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment,
|
||||
runHooks: params.runHooks === true
|
||||
runHooks: params.runHooks === true,
|
||||
setupDecision: params.setupDecision
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -853,6 +853,18 @@ export type PreloadApi = {
|
||||
runtime: {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
|
||||
getStatus: () => Promise<RuntimeStatus>
|
||||
getTerminalFitOverrides: () => Promise<
|
||||
{ ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[]
|
||||
>
|
||||
restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }>
|
||||
onTerminalFitOverrideChanged: (
|
||||
callback: (event: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
) => () => void
|
||||
}
|
||||
rateLimits: {
|
||||
get: () => Promise<RateLimitState>
|
||||
|
||||
@@ -797,6 +797,10 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
@@ -982,6 +986,10 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
@@ -1162,6 +1170,10 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
@@ -1340,6 +1352,10 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
listPortForwards: () => Promise.resolve([]),
|
||||
@@ -1547,6 +1563,10 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ export function useIpcEvents(): void {
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => {
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => {
|
||||
void (async () => {
|
||||
// Why: fetch worktrees first so the activation helper can resolve
|
||||
// the CLI-created worktree via findWorktreeById — it arrived from
|
||||
@@ -180,7 +180,7 @@ export function useIpcEvents(): void {
|
||||
// ones. This records the visit in the back/forward history stack
|
||||
// (recordWorktreeVisit), without which the nav buttons would
|
||||
// ignore the CLI-driven workspace switch.
|
||||
activateAndRevealWorktree(worktreeId, { setup })
|
||||
activateAndRevealWorktree(worktreeId, { setup, startup })
|
||||
})().catch((error) => {
|
||||
console.error('Failed to activate CLI-created worktree:', error)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user