* test: E2E coverage for directory picker, all-sessions, variant picker Extend the Maestro suite for the features merged into main today: DirectoryBrowserSheet's server-folder picker, the directory-less all-sessions-across-projects list (+ the #46/#48 open-across-project regression), and VariantPicker's reasoning-effort chip. - tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via the x-opencode-directory header) with a small fake tree, GET /project for the "Server Projects" section, POST /session honoring the directory header, GET /session/:id (needed to open a session from the all-sessions list), GET /provider variants for VariantPicker, and an optional --seed-sessions mode that pre-populates two sessions across two directories. --fail-auth mode is untouched. - .maestro/flows/directory-picker.yaml, all-sessions.yaml, variant-picker.yaml: three new flows, run in the same emulator session as the existing activation flows. - Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row, session list rows, the variant chip, and VariantPicker rows. - .github/workflows/activation-e2e.yml: two more mock server instances (4098 seeded, 4099 fresh) and three more maestro test steps. Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new mock endpoint curled against its real shape read from the app code, YAML validated. No Android emulator available locally to run the Maestro flows themselves. * test(mock): enforce per-directory session scoping so #46/#48 coverage can fail Review finding (HIGH): GET /session/:id and /session/:id/message ignored x-opencode-directory, so all-sessions.yaml could not fail if the directory threading fix regressed. The mock now mirrors the real server's per-directory workspace scoping: - GET /session/:id and GET /session/:id/message 404 unless the request's x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored session's directory. - GET /session without ?roots=true is scoped to the request's directory; loadSessions()'s directory-less roots=true call still returns everything. - Document the port-4099 shared-state coupling between directory-picker and variant-picker flows, and why all-sessions.yaml now has teeth (flow comment). Curl-verified: correct header 200, wrong/no header 404, scoped vs roots listing, create-then-open paths for all three flows, --fail-auth untouched. tsc clean, 108/108 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E * fix(e2e): widen connect-handshake wait past client's own 30s timeout Run 29546383612 (7cbd3a6, first real emulator execution of these flows) failed on activation-positive.yaml: "Assert that id: connection-status-dot is visible" timed out after the flow's 20s extendedWaitUntil, right after tapOn connect-submit-button. The mock server itself is fast (verified locally: health + project/current + path respond in ~30ms total), so this isn't a mock fidelity gap. But Quick Connect's testConnection()/addConnection() path chains up to 3 fetches (health, then project.current + path.get in parallel), and each individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 — strictly longer than the 20s the flow was willing to wait. A first-attempt emulator-to-host (10.0.2.2) connection that's merely slow to establish, rather than outright failing, would blow past the test's wait before the app's own client-side timeout even fires. Bump the connect -> connection-status-dot / "Connection Failed" waits from 20000 to 40000 across all 5 flows that share this pattern (activation-positive, activation-negative-401, all-sessions, directory-picker, variant-picker) so the wait is never shorter than the code path it's gating on. Assertions are unchanged — still requires the real dot / real error text, just with a timeout that isn't racing the client. Verified locally: typecheck clean, all 108 unit tests pass, YAML parses, mock server confirmed fast under direct curl. Emulator behavior itself (whether 40s consistently clears it) is unverified until the next CI run. * fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug Root cause of the activation-e2e failure (connect step timed out, ~0 requests reaching the mock): the 10.0.2.2 host alias is unreliable under the headless emulator-runner — the app's http://10.0.2.2:4096/global/health never completed, so connection-status-dot never rendered. - run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses each mock port (4096-4099) into the emulator's localhost, runs every flow with --debug-output, and dumps logcat on exit. - All flows now connect to 127.0.0.1:<port> (the adb reverse target). - Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future failures are diagnosable instead of blind. * fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input Root cause of every activation-e2e connect failure (proven by the app's own logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server unreachable'): the port field defaults to useState("4096"), and the flow's eraseText + inputText "4096" raced the controlled number-pad input, leaving "40966" — nothing listens there, so connect always failed. This was never a 10.0.2.2 / adb reverse issue. Fix: buildUrl already extracts host:port from the IP field, so enter 127.0.0.1:<port> there and remove the flaky port-field steps entirely. pastedPort overrides the default port state, so each flow's port is deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099 directory+variant). --------- Co-authored-by: engineer <engineer@gray-knight-m1.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1239 lines
39 KiB
TypeScript
1239 lines
39 KiB
TypeScript
import { useCallback, useMemo, 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"
|
|
import { groupByDirectory } from "../../src/lib/session-grouping"
|
|
import { nameOf } from "../../src/lib/path-utils"
|
|
|
|
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}
|
|
testID={`session-item-${session.id}`}
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
// Flattened list row — either a collapsible group header or a session.
|
|
// A single flat array keeps FlatList's refresh/empty-state handling as-is
|
|
// instead of switching to SectionList.
|
|
type ListRow =
|
|
| { type: "header"; directory: string; shortName: string; count: number; collapsed: boolean }
|
|
| { type: "session"; session: Session }
|
|
|
|
function GroupHeader({
|
|
row,
|
|
isDark,
|
|
onToggle,
|
|
}: {
|
|
row: { directory: string; shortName: string; count: number; collapsed: boolean }
|
|
isDark: boolean
|
|
onToggle: () => void
|
|
}) {
|
|
return (
|
|
<TouchableOpacity
|
|
style={[styles.groupHeader, isDark && styles.groupHeaderDark]}
|
|
onPress={onToggle}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="folder-outline" size={16} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
|
<Text style={[styles.groupHeaderText, isDark && styles.textDark]} numberOfLines={1}>
|
|
{row.shortName}
|
|
</Text>
|
|
<Text style={[styles.groupHeaderCount, isDark && styles.metaDark]}>{row.count}</Text>
|
|
<Ionicons
|
|
name={row.collapsed ? "chevron-forward" : "chevron-down"}
|
|
size={16}
|
|
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)
|
|
// Directories collapsed in the grouped session list. Empty by default —
|
|
// all groups start expanded (#67).
|
|
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
|
|
|
|
const toggleGroup = useCallback((directory: string) => {
|
|
setCollapsedDirs((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(directory)) next.delete(directory)
|
|
else next.add(directory)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
// Flatten sessions into header+item rows. Skip headers entirely when
|
|
// everything lives in one directory — a lone header adds noise, not clarity.
|
|
const rows = useMemo<ListRow[]>(() => {
|
|
const groups = groupByDirectory(sessions)
|
|
if (groups.length <= 1) {
|
|
return sessions.map((session) => ({ type: "session", session }))
|
|
}
|
|
const out: ListRow[] = []
|
|
for (const group of groups) {
|
|
const collapsed = collapsedDirs.has(group.directory)
|
|
out.push({
|
|
type: "header",
|
|
directory: group.directory,
|
|
shortName: nameOf(group.directory) || group.directory,
|
|
count: group.items.length,
|
|
collapsed,
|
|
})
|
|
if (!collapsed) {
|
|
for (const session of group.items) out.push({ type: "session", session })
|
|
}
|
|
}
|
|
return out
|
|
}, [sessions, collapsedDirs])
|
|
|
|
// 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={rows}
|
|
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
|
|
renderItem={({ item: row }) =>
|
|
row.type === "header" ? (
|
|
<GroupHeader row={row} isDark={isDark} onToggle={() => toggleGroup(row.directory)} />
|
|
) : (
|
|
<SessionItem
|
|
session={row.session}
|
|
isDark={isDark}
|
|
onRename={() => handleRename(row.session)}
|
|
onDelete={() => handleDelete(row.session)}
|
|
/>
|
|
)
|
|
}
|
|
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}
|
|
testID="browse-folders-button"
|
|
>
|
|
<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,
|
|
},
|
|
groupHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
backgroundColor: "#f5f5f5",
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#e5e5e5",
|
|
},
|
|
groupHeaderDark: {
|
|
backgroundColor: "#151515",
|
|
borderBottomColor: "#1a1a1a",
|
|
},
|
|
groupHeaderText: {
|
|
flex: 1,
|
|
fontSize: 13,
|
|
fontWeight: "600",
|
|
color: "#0a0a0a",
|
|
},
|
|
groupHeaderCount: {
|
|
fontSize: 12,
|
|
color: "#666666",
|
|
},
|
|
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",
|
|
},
|
|
})
|