* fix(auth): stop infinite SSE retry on 401/403 and surface auth failures Root cause (Sentry OPENCODE-MOBILE-1, 309 events / 65 users): auth is static HTTP Basic and no code path treated 401 specially. The SSE reconnect loop in events.ts retried on a fixed backoff regardless of cause, so a bad password spammed Sentry and drained battery forever with zero user feedback. Advanced-mode connection save also had no pre-flight check and silently persisted bad credentials as the active connection. - src/lib/api-error.ts: new pure ApiAuthError/isAuthStatus/isAuthError module (node --test covered) so 401/403 are distinguishable from other failures. - src/lib/sdk.ts: request()/events() now throw ApiAuthError for 401/403 instead of a generic Error. - src/stores/events.ts: the SSE loop stops retrying on an auth error and sets a new `authError` flag instead of reconnecting forever; other errors keep the existing backoff. Fires connection_failed (source: sse, error_class: unauthorized) so it's visible in the existing funnel. - app/(tabs)/index.tsx: sessions screen shows an "Authentication Failed" state with a link to the connection edit screen when authError is set. - app/connection/[id].tsx: saving edited credentials for the active connection now reconnects SSE immediately instead of requiring an app restart. - app/connection/add.tsx: Advanced-mode save now runs the same testConnection pre-flight as Quick Connect and shows the same "Connection Failed" alert (with diagnostics/share-report) instead of silently saving bad credentials. Closes #76 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E * fix(auth): add Retry button on 401 error state, widen ConnectionTestSource - Authentication Failed screen now offers Retry alongside Check Credentials, calling events store's connect() directly to restart the SSE state machine on transient 401s without leaving the app. - Widen ConnectionTestSource to include 'sse' (events.ts:389's connection_failed track call) and note the activation funnel only filters on source=onboarding. Addresses PR #79 review follow-ups. --------- Co-authored-by: engineer <engineer@gray-knight-m1.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1135 lines
36 KiB
TypeScript
1135 lines
36 KiB
TypeScript
import { useCallback, useState, useRef, useEffect } from "react"
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
useColorScheme,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
Modal,
|
|
TextInput,
|
|
Alert,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from "react-native"
|
|
import { router, useFocusEffect } from "expo-router"
|
|
import { Ionicons } from "@expo/vector-icons"
|
|
import { useSessions } from "../../src/stores/sessions"
|
|
import { useConnections } from "../../src/stores/connections"
|
|
import { useEvents } from "../../src/stores/events"
|
|
import { useCatalog } from "../../src/stores/catalog"
|
|
import type BottomSheet from "@gorhom/bottom-sheet"
|
|
import type { Session, Project } from "../../src/lib/sdk"
|
|
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
|
|
|
|
function formatTime(timestamp: number): string {
|
|
const date = new Date(timestamp)
|
|
const now = new Date()
|
|
const diff = now.getTime() - date.getTime()
|
|
|
|
if (diff < 60000) return "Just now"
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
|
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
|
|
|
|
return date.toLocaleDateString()
|
|
}
|
|
|
|
function SessionItem({
|
|
session,
|
|
isDark,
|
|
onRename,
|
|
onDelete,
|
|
}: {
|
|
session: Session
|
|
isDark: boolean
|
|
onRename: () => void
|
|
onDelete: () => void
|
|
}) {
|
|
const onPress = () => {
|
|
router.push({
|
|
pathname: `/session/[id]`,
|
|
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
|
})
|
|
}
|
|
|
|
const onLongPress = () => {
|
|
Alert.alert(session.title || "Untitled Session", undefined, [
|
|
{ text: "Cancel", style: "cancel" },
|
|
{ text: "Rename", onPress: onRename },
|
|
{ text: "Delete", style: "destructive", onPress: onDelete },
|
|
])
|
|
}
|
|
|
|
// Extract short directory name from session
|
|
const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
>
|
|
<View style={styles.sessionContent}>
|
|
<View style={styles.sessionHeader}>
|
|
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
|
{session.title || "Untitled Session"}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.sessionMetaRow}>
|
|
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
|
{formatTime(session.time.updated)}
|
|
{/* summary is always present but files defaults to 0 until the
|
|
server populates it — only show the count when it's meaningful,
|
|
matching the SessionInfo panel's `summary.files > 0` guard (#55) */}
|
|
{session.summary && session.summary.files > 0 && ` · ${session.summary.files} files`}
|
|
</Text>
|
|
{shortDir && (
|
|
<View style={styles.sessionDirBadge}>
|
|
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
|
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
|
|
</TouchableOpacity>
|
|
)
|
|
}
|
|
|
|
// Get short directory name (last folder or project name)
|
|
function getShortPath(
|
|
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
|
|
): string {
|
|
if (!project) return ""
|
|
if (project.name) return project.name
|
|
if (!project.path?.absolute) return ""
|
|
const parts = project.path.absolute.split("/").filter(Boolean)
|
|
return parts[parts.length - 1] || project.path.absolute
|
|
}
|
|
|
|
export default function SessionsScreen() {
|
|
const colorScheme = useColorScheme()
|
|
const isDark = colorScheme === "dark"
|
|
const [showNewSession, setShowNewSession] = useState(false)
|
|
const [customDir, setCustomDir] = useState("")
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const [renaming, setRenaming] = useState<Session | null>(null)
|
|
const [renameText, setRenameText] = useState("")
|
|
const renamingInFlight = useRef(false)
|
|
const [serverProjects, setServerProjects] = useState<Project[]>([])
|
|
|
|
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
|
const {
|
|
activeConnection,
|
|
client,
|
|
currentProject,
|
|
serverHome,
|
|
refreshProject,
|
|
clientForDirectory,
|
|
switchDirectory,
|
|
addRecentDirectory,
|
|
recentDirectories,
|
|
} = useConnections()
|
|
const authError = useEvents((s) => s.authError)
|
|
const reconnect = useEvents((s) => s.connect)
|
|
const loadCatalog = useCatalog((s) => s.load)
|
|
const dirSheetRef = useRef<BottomSheet>(null)
|
|
const browserSheetRef = useRef<BottomSheet>(null)
|
|
const [browseStartDir, setBrowseStartDir] = useState<string | null>(null)
|
|
// Shared folder browser is opened either to pick a directory for a new
|
|
// session, or to switch the active connection's directory.
|
|
const [browseMode, setBrowseMode] = useState<"create" | "switch">("create")
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
// Fetch server-known projects when the new session modal opens
|
|
useEffect(() => {
|
|
if (!showNewSession || !client) return
|
|
client.project
|
|
.list()
|
|
.then(setServerProjects)
|
|
.catch(() => setServerProjects([]))
|
|
}, [showNewSession, client])
|
|
|
|
const handleSwitchDirectory = useCallback(
|
|
async (dir?: string) => {
|
|
await switchDirectory(dir)
|
|
loadSessions()
|
|
refreshProject()
|
|
loadCatalog()
|
|
},
|
|
[switchDirectory, loadSessions, refreshProject, loadCatalog],
|
|
)
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (client) {
|
|
loadSessions()
|
|
refreshProject()
|
|
}
|
|
}, [client, loadSessions, refreshProject]),
|
|
)
|
|
|
|
const onRefresh = useCallback(async () => {
|
|
setRefreshing(true)
|
|
try {
|
|
await Promise.all([loadSessions(), refreshProject()])
|
|
} catch (err) {
|
|
console.error("Refresh failed:", err)
|
|
} finally {
|
|
setRefreshing(false)
|
|
}
|
|
}, [loadSessions, refreshProject])
|
|
|
|
const handleRename = useCallback((session: Session) => {
|
|
setRenameText(session.title || "")
|
|
setRenaming(session)
|
|
}, [])
|
|
|
|
const submitRename = useCallback(async () => {
|
|
const title = renameText.trim()
|
|
if (!title || !renaming || renamingInFlight.current) return
|
|
const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client
|
|
if (!renameClient) return
|
|
renamingInFlight.current = true
|
|
try {
|
|
await renameClient.session.update(renaming.id, { title })
|
|
setRenaming(null)
|
|
setRenameText("")
|
|
loadSessions()
|
|
} catch (err) {
|
|
console.error("Rename failed:", err)
|
|
Alert.alert("Rename failed", "Could not rename the session. Please try again.")
|
|
} finally {
|
|
renamingInFlight.current = false
|
|
}
|
|
}, [renaming, renameText, client, clientForDirectory, loadSessions])
|
|
|
|
const handleDelete = useCallback(
|
|
(session: Session) => {
|
|
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Delete",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
try {
|
|
await deleteSession(session.id)
|
|
} catch (err) {
|
|
console.error("Delete failed:", err)
|
|
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
|
|
}
|
|
},
|
|
},
|
|
])
|
|
},
|
|
[deleteSession],
|
|
)
|
|
|
|
const onCreateSession = async () => {
|
|
const session = await createSession()
|
|
if (session) {
|
|
router.push({
|
|
pathname: `/session/[id]`,
|
|
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
|
})
|
|
}
|
|
}
|
|
|
|
const onCreateInDirectory = async (dir?: string) => {
|
|
if (!activeConnection) return
|
|
|
|
setIsCreating(true)
|
|
|
|
// If a custom directory is specified, use a one-off client for that directory
|
|
// so we don't mutate the connection's default project
|
|
if (dir && dir.trim()) {
|
|
const dirClient = clientForDirectory(dir.trim())
|
|
if (!dirClient) {
|
|
setIsCreating(false)
|
|
return
|
|
}
|
|
try {
|
|
const session = await dirClient.session.create({})
|
|
addRecentDirectory(dir.trim())
|
|
setIsCreating(false)
|
|
setShowNewSession(false)
|
|
setCustomDir("")
|
|
if (session) {
|
|
router.push({
|
|
pathname: `/session/[id]`,
|
|
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to create session in directory:", error)
|
|
Alert.alert("Error", "Failed to create session in that directory.")
|
|
setIsCreating(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
const session = await createSession()
|
|
setIsCreating(false)
|
|
setShowNewSession(false)
|
|
setCustomDir("")
|
|
if (session) {
|
|
router.push({
|
|
pathname: `/session/[id]`,
|
|
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
|
})
|
|
}
|
|
}
|
|
|
|
// The browser sheet is a sibling of the New Session <Modal>. A native RN
|
|
// Modal layers above everything in the React root (including bottom-sheet
|
|
// portals), so the modal must be closed before the sheet is shown; this ref
|
|
// remembers to bring it back if the user cancels without picking a folder.
|
|
const restoreNewSessionOnDismiss = useRef(false)
|
|
|
|
const openBrowser = useCallback(
|
|
(startDir: string | null, mode: "create" | "switch") => {
|
|
setBrowseStartDir(startDir || serverHome || null)
|
|
setBrowseMode(mode)
|
|
if (mode === "create" && showNewSession) {
|
|
restoreNewSessionOnDismiss.current = true
|
|
setShowNewSession(false)
|
|
}
|
|
browserSheetRef.current?.expand()
|
|
},
|
|
[serverHome, showNewSession],
|
|
)
|
|
|
|
const onBrowserSelect = useCallback(
|
|
(directory: string) => {
|
|
restoreNewSessionOnDismiss.current = false
|
|
if (browseMode === "switch") {
|
|
handleSwitchDirectory(directory)
|
|
dirSheetRef.current?.close()
|
|
} else {
|
|
onCreateInDirectory(directory)
|
|
}
|
|
},
|
|
[browseMode, handleSwitchDirectory, onCreateInDirectory],
|
|
)
|
|
|
|
const onBrowserDismiss = useCallback(() => {
|
|
if (restoreNewSessionOnDismiss.current) {
|
|
restoreNewSessionOnDismiss.current = false
|
|
setShowNewSession(true)
|
|
}
|
|
}, [])
|
|
|
|
const onFabPress = () => {
|
|
// Quick create in current project
|
|
onCreateSession()
|
|
}
|
|
|
|
const onFabLongPress = () => {
|
|
// Show modal with more options
|
|
setCustomDir("")
|
|
setShowNewSession(true)
|
|
}
|
|
|
|
if (!activeConnection) {
|
|
return (
|
|
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
|
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
|
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
|
|
<TouchableOpacity
|
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
|
onPress={() => router.push("/connection/add")}
|
|
testID="add-connection-button"
|
|
>
|
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
// The SSE loop stopped retrying because the server rejected our
|
|
// credentials (401/403) — no amount of pull-to-refresh fixes that, so
|
|
// point the user straight at the fix instead of a spinner that never
|
|
// resolves (issue #76).
|
|
if (authError) {
|
|
return (
|
|
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
|
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>Authentication Failed</Text>
|
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
|
{activeConnection.name} rejected your credentials. Check the username and password to reconnect.
|
|
</Text>
|
|
<View style={styles.authErrorButtonRow}>
|
|
<TouchableOpacity
|
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
|
onPress={() => router.push(`/connection/${activeConnection.id}`)}
|
|
testID="fix-connection-button"
|
|
>
|
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Check Credentials</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
|
onPress={() => {
|
|
// authError is cleared inside connect() itself once the retry
|
|
// attempt starts (see src/stores/events.ts), so a manual
|
|
// set() here isn't needed — just kick the SSE state machine.
|
|
reconnect()
|
|
}}
|
|
testID="retry-connection-button"
|
|
>
|
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Retry</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const shortPath = getShortPath(currentProject)
|
|
|
|
return (
|
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
|
{/* Connection indicator — tap to switch project */}
|
|
<TouchableOpacity
|
|
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
|
|
onPress={() => dirSheetRef.current?.expand()}
|
|
onLongPress={() => router.push("/(tabs)/connections")}
|
|
activeOpacity={0.7}
|
|
testID="connection-status-bar"
|
|
>
|
|
<View style={styles.connectionInfo}>
|
|
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} testID="connection-status-dot" />
|
|
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
|
|
{activeConnection.name}
|
|
</Text>
|
|
{shortPath && (
|
|
<>
|
|
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
|
|
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
|
|
{shortPath}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
|
|
</TouchableOpacity>
|
|
|
|
{error && (
|
|
<View style={styles.errorBar}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<FlatList
|
|
data={sessions}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<SessionItem
|
|
session={item}
|
|
isDark={isDark}
|
|
onRename={() => handleRename(item)}
|
|
onDelete={() => handleDelete(item)}
|
|
/>
|
|
)}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
}
|
|
ListEmptyComponent={
|
|
isLoading ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyList}>
|
|
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
|
|
</View>
|
|
)
|
|
}
|
|
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
|
|
/>
|
|
|
|
{/* FAB to create new session */}
|
|
<TouchableOpacity
|
|
style={[styles.fab, isDark && styles.fabDark]}
|
|
onPress={onFabPress}
|
|
onLongPress={onFabLongPress}
|
|
delayLongPress={500}
|
|
testID="new-session-fab"
|
|
>
|
|
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
|
</TouchableOpacity>
|
|
|
|
{/* New Session Info Modal */}
|
|
<Modal visible={showNewSession} animationType="slide" transparent>
|
|
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
|
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
|
|
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
|
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
|
|
{/* Current directory — tapping creates session immediately */}
|
|
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Project</Text>
|
|
<TouchableOpacity
|
|
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
|
|
onPress={() => onCreateInDirectory()}
|
|
disabled={isCreating}
|
|
>
|
|
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
|
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
|
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
|
|
</Text>
|
|
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Recent projects */}
|
|
{recentDirectories.length > 0 && (
|
|
<>
|
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
|
Recent Projects
|
|
</Text>
|
|
{recentDirectories.map((dir) => {
|
|
const short = dir.split("/").filter(Boolean).pop() || dir
|
|
const isCurrent =
|
|
dir === (currentProject?.path?.absolute || activeConnection?.directory)
|
|
return (
|
|
<TouchableOpacity
|
|
key={dir}
|
|
style={[
|
|
styles.projectRow,
|
|
isDark && styles.projectRowDark,
|
|
isCurrent && styles.projectRowActive,
|
|
]}
|
|
onPress={() => onCreateInDirectory(dir)}
|
|
disabled={isCreating}
|
|
>
|
|
<Ionicons
|
|
name="folder-outline"
|
|
size={18}
|
|
color={isCurrent ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
|
|
/>
|
|
<View style={styles.projectRowContent}>
|
|
<Text
|
|
style={[
|
|
styles.projectRowName,
|
|
isDark && styles.textDark,
|
|
isCurrent && styles.projectRowNameActive,
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{short}
|
|
</Text>
|
|
<Text style={[styles.projectRowPath, isDark && styles.metaDark]} numberOfLines={1}>
|
|
{dir}
|
|
</Text>
|
|
</View>
|
|
{isCurrent && <Ionicons name="checkmark-circle" size={18} color="#8b5cf6" />}
|
|
</TouchableOpacity>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* Server-known projects (excluding current) */}
|
|
{serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && (
|
|
<>
|
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
|
Server Projects
|
|
</Text>
|
|
{serverProjects
|
|
.filter((p) => p.path?.absolute !== currentProject?.path?.absolute)
|
|
.map((p) => {
|
|
const short = p.name || p.path?.absolute?.split("/").filter(Boolean).pop() || p.id
|
|
return (
|
|
<TouchableOpacity
|
|
key={p.id}
|
|
style={[styles.projectRow, isDark && styles.projectRowDark]}
|
|
onPress={() => onCreateInDirectory(p.path?.absolute)}
|
|
disabled={isCreating}
|
|
>
|
|
<Ionicons name="code-slash-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
|
<View style={styles.projectRowContent}>
|
|
<Text style={[styles.projectRowName, isDark && styles.textDark]} numberOfLines={1}>
|
|
{short}
|
|
</Text>
|
|
{p.path?.absolute && (
|
|
<Text style={[styles.projectRowPath, isDark && styles.metaDark]} numberOfLines={1}>
|
|
{p.path.absolute}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* Browse the server's filesystem instead of typing a path */}
|
|
<TouchableOpacity
|
|
style={[styles.projectRow, isDark && styles.projectRowDark, { marginTop: 16 }]}
|
|
onPress={() =>
|
|
openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create")
|
|
}
|
|
disabled={isCreating}
|
|
>
|
|
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
|
<View style={styles.projectRowContent}>
|
|
<Text style={[styles.projectRowName, isDark && styles.textDark]}>Browse Folders…</Text>
|
|
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
|
|
Explore the server's filesystem
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Manual path input fallback */}
|
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
|
Enter Path Manually
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
|
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
|
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
|
value={customDir}
|
|
onChangeText={(text) => {
|
|
// Expand ~ to server home directory
|
|
if (serverHome && text.startsWith("~/")) {
|
|
setCustomDir(serverHome + text.slice(1))
|
|
} else if (serverHome && text === "~") {
|
|
setCustomDir(serverHome)
|
|
} else {
|
|
setCustomDir(text)
|
|
}
|
|
}}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
{/* Quick path shortcuts */}
|
|
{serverHome && (
|
|
<View style={styles.pathChips}>
|
|
<TouchableOpacity
|
|
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
|
onPress={() => setCustomDir(serverHome)}
|
|
>
|
|
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
|
onPress={() => setCustomDir(serverHome + "/")}
|
|
>
|
|
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
<View style={styles.modalActions}>
|
|
{customDir.trim() ? (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.modalButton,
|
|
styles.modalButtonPrimary,
|
|
isDark && styles.modalButtonPrimaryDark,
|
|
styles.modalButtonFull,
|
|
]}
|
|
onPress={() => onCreateInDirectory(customDir)}
|
|
disabled={isCreating}
|
|
>
|
|
{isCreating ? (
|
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
|
) : (
|
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
|
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
) : (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.modalButton,
|
|
styles.modalButtonPrimary,
|
|
isDark && styles.modalButtonPrimaryDark,
|
|
styles.modalButtonFull,
|
|
]}
|
|
onPress={() => onCreateInDirectory()}
|
|
disabled={isCreating}
|
|
>
|
|
{isCreating ? (
|
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
|
) : (
|
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
|
Create Session
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
|
|
{/* Rename modal */}
|
|
<Modal visible={!!renaming} animationType="fade" transparent>
|
|
<KeyboardAvoidingView
|
|
style={[styles.modalOverlay, { justifyContent: "center" }]}
|
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
>
|
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
|
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
|
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
|
|
<TextInput
|
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
|
value={renameText}
|
|
onChangeText={setRenameText}
|
|
onSubmitEditing={submitRename}
|
|
returnKeyType="done"
|
|
autoFocus
|
|
selectTextOnFocus
|
|
autoCapitalize="sentences"
|
|
autoCorrect={false}
|
|
/>
|
|
<View style={styles.renameActions}>
|
|
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
|
<Text style={styles.renameBtnCancelText}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
|
onPress={submitRename}
|
|
disabled={!renameText.trim()}
|
|
>
|
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
|
|
{/* Directory switcher bottom sheet */}
|
|
<DirectorySwitcher
|
|
sheetRef={dirSheetRef}
|
|
current={activeConnection?.directory}
|
|
recents={recentDirectories}
|
|
serverHome={serverHome}
|
|
isDark={isDark}
|
|
onSwitch={handleSwitchDirectory}
|
|
onBrowse={() =>
|
|
openBrowser(activeConnection?.directory || currentProject?.path?.absolute || null, "switch")
|
|
}
|
|
/>
|
|
|
|
{/* Browsable folder picker — used for both "new session in..." and
|
|
"switch project directory" flows (see browseMode). */}
|
|
<DirectoryBrowserSheet
|
|
sheetRef={browserSheetRef}
|
|
startDirectory={browseStartDir}
|
|
clientForDirectory={clientForDirectory}
|
|
isDark={isDark}
|
|
onSelect={onBrowserSelect}
|
|
onDismiss={onBrowserDismiss}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
containerDark: {
|
|
backgroundColor: "#0a0a0a",
|
|
},
|
|
connectionBar: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#e5e5e5",
|
|
},
|
|
connectionBarDark: {
|
|
borderBottomColor: "#1a1a1a",
|
|
},
|
|
connectionInfo: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
},
|
|
connectionDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
},
|
|
connectionName: {
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
connectionUrl: {
|
|
fontSize: 12,
|
|
color: "#666666",
|
|
},
|
|
projectPath: {
|
|
fontSize: 13,
|
|
color: "#666666",
|
|
flex: 1,
|
|
},
|
|
errorBar: {
|
|
backgroundColor: "#fef2f2",
|
|
padding: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#fecaca",
|
|
},
|
|
errorText: {
|
|
color: "#dc2626",
|
|
fontSize: 14,
|
|
},
|
|
sessionItem: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
padding: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#e5e5e5",
|
|
},
|
|
sessionItemDark: {
|
|
borderBottomColor: "#1a1a1a",
|
|
},
|
|
sessionContent: {
|
|
flex: 1,
|
|
},
|
|
sessionHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginBottom: 2,
|
|
},
|
|
sessionTitle: {
|
|
fontSize: 16,
|
|
fontWeight: "500",
|
|
color: "#0a0a0a",
|
|
marginBottom: 4,
|
|
},
|
|
textDark: {
|
|
color: "#ffffff",
|
|
},
|
|
sessionMeta: {
|
|
fontSize: 13,
|
|
color: "#666666",
|
|
},
|
|
sessionMetaRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
sessionDirBadge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
backgroundColor: "#f5f5f5",
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
},
|
|
sessionDirText: {
|
|
fontSize: 11,
|
|
color: "#666666",
|
|
},
|
|
metaDark: {
|
|
color: "#888888",
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
padding: 32,
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 20,
|
|
fontWeight: "600",
|
|
marginTop: 16,
|
|
color: "#0a0a0a",
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: 14,
|
|
color: "#666666",
|
|
marginTop: 8,
|
|
textAlign: "center",
|
|
},
|
|
addButton: {
|
|
backgroundColor: "#0a0a0a",
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 12,
|
|
borderRadius: 8,
|
|
marginTop: 24,
|
|
},
|
|
authErrorButtonRow: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
},
|
|
addButtonDark: {
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
addButtonText: {
|
|
color: "#ffffff",
|
|
fontWeight: "600",
|
|
},
|
|
addButtonTextDark: {
|
|
color: "#0a0a0a",
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingVertical: 64,
|
|
},
|
|
emptyList: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingVertical: 64,
|
|
},
|
|
emptyListText: {
|
|
fontSize: 16,
|
|
color: "#666666",
|
|
},
|
|
emptyContent: {
|
|
flex: 1,
|
|
},
|
|
fab: {
|
|
position: "absolute",
|
|
right: 16,
|
|
bottom: 16,
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
backgroundColor: "#0a0a0a",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 4,
|
|
elevation: 5,
|
|
},
|
|
fabDark: {
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
// Modal styles
|
|
modalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
justifyContent: "flex-end",
|
|
},
|
|
modalDismiss: {
|
|
flex: 1,
|
|
},
|
|
modalContent: {
|
|
backgroundColor: "#ffffff",
|
|
borderTopLeftRadius: 20,
|
|
borderTopRightRadius: 20,
|
|
padding: 20,
|
|
paddingBottom: 40,
|
|
},
|
|
modalContentDark: {
|
|
backgroundColor: "#1a1a1a",
|
|
},
|
|
modalHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 20,
|
|
},
|
|
modalTitle: {
|
|
fontSize: 20,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
modalBody: {
|
|
marginBottom: 24,
|
|
},
|
|
modalScrollBody: {
|
|
maxHeight: 420,
|
|
marginBottom: 16,
|
|
},
|
|
projectRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 11,
|
|
borderRadius: 10,
|
|
backgroundColor: "#f5f5f5",
|
|
marginBottom: 6,
|
|
},
|
|
projectRowDark: {
|
|
backgroundColor: "#2a2a2a",
|
|
},
|
|
projectRowActive: {
|
|
backgroundColor: "#f5f3ff",
|
|
},
|
|
projectRowContent: {
|
|
flex: 1,
|
|
},
|
|
projectRowName: {
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
projectRowNameActive: {
|
|
color: "#8b5cf6",
|
|
},
|
|
projectRowPath: {
|
|
fontSize: 11,
|
|
color: "#999999",
|
|
marginTop: 1,
|
|
},
|
|
modalLabel: {
|
|
fontSize: 13,
|
|
fontWeight: "600",
|
|
color: "#666666",
|
|
marginBottom: 8,
|
|
textTransform: "uppercase",
|
|
},
|
|
modalDirBox: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
backgroundColor: "#f5f5f5",
|
|
padding: 16,
|
|
borderRadius: 12,
|
|
},
|
|
modalDirBoxDark: {
|
|
backgroundColor: "#2a2a2a",
|
|
},
|
|
modalDirText: {
|
|
fontSize: 15,
|
|
color: "#0a0a0a",
|
|
flex: 1,
|
|
},
|
|
modalInput: {
|
|
backgroundColor: "#f5f5f5",
|
|
borderRadius: 12,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
fontSize: 15,
|
|
color: "#0a0a0a",
|
|
},
|
|
modalInputDark: {
|
|
backgroundColor: "#2a2a2a",
|
|
color: "#ffffff",
|
|
},
|
|
pathChips: {
|
|
flexDirection: "row",
|
|
gap: 8,
|
|
marginTop: 8,
|
|
},
|
|
pathChip: {
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
backgroundColor: "#e8e5f0",
|
|
borderRadius: 16,
|
|
},
|
|
pathChipDark: {
|
|
backgroundColor: "#2a2040",
|
|
},
|
|
pathChipText: {
|
|
fontSize: 13,
|
|
fontWeight: "600",
|
|
color: "#6d28d9",
|
|
},
|
|
pathChipTextDark: {
|
|
color: "#c4b5fd",
|
|
},
|
|
modalHint: {
|
|
fontSize: 13,
|
|
color: "#666666",
|
|
marginTop: 12,
|
|
},
|
|
modalActions: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
},
|
|
modalButton: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
padding: 16,
|
|
borderRadius: 12,
|
|
},
|
|
modalButtonSecondary: {
|
|
backgroundColor: "#f5f5f5",
|
|
},
|
|
modalButtonSecondaryDark: {
|
|
backgroundColor: "#2a2a2a",
|
|
},
|
|
modalButtonPrimary: {
|
|
backgroundColor: "#0a0a0a",
|
|
},
|
|
modalButtonPrimaryDark: {
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
modalButtonText: {
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
modalButtonTextPrimary: {
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
color: "#ffffff",
|
|
},
|
|
modalButtonTextPrimaryDark: {
|
|
color: "#0a0a0a",
|
|
},
|
|
modalButtonFull: {
|
|
flex: 0,
|
|
width: "100%",
|
|
},
|
|
// Rename modal
|
|
renameCard: {
|
|
backgroundColor: "#ffffff",
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
marginHorizontal: 32,
|
|
gap: 16,
|
|
},
|
|
renameCardDark: {
|
|
backgroundColor: "#1a1a1a",
|
|
},
|
|
renameTitle: {
|
|
fontSize: 17,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
renameActions: {
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
gap: 12,
|
|
},
|
|
renameBtn: {
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
},
|
|
renameBtnCancel: {
|
|
backgroundColor: "transparent",
|
|
},
|
|
renameBtnCancelText: {
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
color: "#888888",
|
|
},
|
|
})
|