diff --git a/app/_layout.tsx b/app/_layout.tsx index dedeab5..2708d2e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,6 +3,7 @@ import { Pressable, Text } from "react-native"; import { Stack, router } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import "../global.css"; import { Colors } from "@/constants/theme"; import { useSettings } from "@/hooks/useSettings"; @@ -18,6 +19,7 @@ export default function RootLayout() { }, [ready, settings.onboardingDone]); return ( + + ); } diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx index 444d884..aab98f3 100644 --- a/app/incident/[id].tsx +++ b/app/incident/[id].tsx @@ -8,11 +8,13 @@ import { Share, } from "react-native"; import { useLocalSearchParams, router, useNavigation } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Clipboard from "expo-clipboard"; import * as SecureStore from "expo-secure-store"; import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db"; import { renderMarkdown } from "@/lib/markdown"; -import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; +import { pushIncidentToGit, loadGitConfig, deleteIncidentFromGit } from "@/lib/git"; +import type { GitConfig } from "@/lib/git"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; @@ -41,11 +43,14 @@ export default function IncidentDetailScreen() { const navigation = useNavigation(); const [incident, setIncident] = useState(null); const [pushing, setPushing] = useState(false); + const [gitConfig, setGitConfig] = useState(null); + const insets = useSafeAreaInsets(); useEffect(() => { if (id) { - getIncidentById(id).then((inc) => { + Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => { setIncident(inc); + setGitConfig(config); if (inc) { navigation.setOptions({ title: inc.title || "Incident" }); } @@ -55,21 +60,52 @@ export default function IncidentDetailScreen() { async function handleDelete() { if (!incident) return; - Alert.alert( - "Delete incident?", - "This action cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - await deleteIncident(incident.id); - router.replace("/"); + + const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null; + + const doDelete = async (deleteFromRepo: boolean) => { + if (deleteFromRepo && gitConfig) { + const result = await deleteIncidentFromGit(incident, gitConfig); + if (!result.success) { + Alert.alert("Repo deletion failed", result.error ?? "Unknown error"); + return; + } + } + await deleteIncident(incident.id); + router.replace("/"); + }; + + if (canDeleteFromRepo) { + Alert.alert( + "Delete incident?", + "This incident was pushed to the repository. Do you also want to delete the file from the repo?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Local only", + onPress: () => doDelete(false), }, - }, - ] - ); + { + text: "Local + Repo", + style: "destructive", + onPress: () => doDelete(true), + }, + ] + ); + } else { + Alert.alert( + "Delete incident?", + "This action cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => doDelete(false), + }, + ] + ); + } } async function handleResolve() { @@ -125,7 +161,7 @@ export default function IncidentDetailScreen() { return ( {/* Status badge */} diff --git a/app/index.tsx b/app/index.tsx index 9c026dc..e349476 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -8,6 +8,7 @@ import { Alert, } from "react-native"; import { router, useFocusEffect } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as SecureStore from "expo-secure-store"; import { getIncidents, markIncidentPushed } from "@/lib/db"; import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; @@ -132,6 +133,7 @@ export default function HomeScreen() { } const gitConfigured = gitConfig !== null; + const insets = useSafeAreaInsets(); return ( @@ -141,7 +143,7 @@ export default function HomeScreen() { item.id} - contentContainerStyle={{ padding: 16, paddingBottom: 96 }} + contentContainerStyle={{ padding: 16, paddingBottom: 96 + insets.bottom }} ListEmptyComponent={ router.push("/new")} style={{ position: "absolute", - bottom: 24, + bottom: 24 + insets.bottom, right: 24, width: 56, height: 56, @@ -302,6 +304,7 @@ export default function HomeScreen() { borderTopWidth: 1, borderTopColor: Colors.border, padding: 12, + paddingBottom: 12 + insets.bottom, gap: 10, }} > diff --git a/app/new.tsx b/app/new.tsx index 95acbb6..d7943d4 100644 --- a/app/new.tsx +++ b/app/new.tsx @@ -10,6 +10,7 @@ import { Alert, } from "react-native"; import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as SecureStore from "expo-secure-store"; import { createIncident, @@ -55,6 +56,7 @@ type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix"; export default function NewIncidentScreen() { const { editId } = useLocalSearchParams<{ editId?: string }>(); const navigation = useNavigation(); + const insets = useSafeAreaInsets(); const [form, setForm] = useState({ title: "", @@ -250,7 +252,7 @@ export default function NewIncidentScreen() { behavior={Platform.OS === "ios" ? "padding" : undefined} > {labelRow("Title *", "title")} diff --git a/app/settings.tsx b/app/settings.tsx index ae87c0a..790d0ca 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -7,6 +7,7 @@ import { Switch, Pressable, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as SecureStore from "expo-secure-store"; import { Colors } from "@/constants/theme"; import { DEFAULT_TEMPLATE } from "@/lib/markdown"; @@ -213,6 +214,7 @@ function Field({ } export default function SettingsScreen() { + const insets = useSafeAreaInsets(); const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [aiEnabled, setAiEnabled] = useState(false); @@ -315,7 +317,7 @@ export default function SettingsScreen() { return ( Preferences @@ -401,6 +403,7 @@ export default function SettingsScreen() { options={[ { key: "anthropic", label: "Anthropic" }, { key: "openai", label: "OpenAI" }, + { key: "mistral", label: "Mistral" }, { key: "perplexity", label: "Perplexity" }, { key: "ollama", label: "Ollama" }, { key: "openrouter", label: "OpenRouter" }, diff --git a/lib/ai.ts b/lib/ai.ts index dbf28f3..b48f44e 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -3,6 +3,7 @@ import * as SecureStore from "expo-secure-store"; export type AIProvider = | "anthropic" | "openai" + | "mistral" | "perplexity" | "ollama" | "openrouter" @@ -18,6 +19,7 @@ const KEYS = { export const AI_PROVIDER_BASE_URLS: Record = { anthropic: "https://api.anthropic.com", openai: "https://api.openai.com/v1", + mistral: "https://api.mistral.ai/v1", perplexity: "https://api.perplexity.ai", ollama: "http://localhost:11434/v1", openrouter: "https://openrouter.ai/api/v1", @@ -27,6 +29,7 @@ export const AI_PROVIDER_BASE_URLS: Record = { export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record = { anthropic: "claude-haiku-4-5-20251001", openai: "gpt-4o-mini", + mistral: "mistral-small-latest", perplexity: "llama-3.1-sonar-small-128k-online", ollama: "llama3.2", openrouter: "openai/gpt-4o-mini", diff --git a/lib/git.ts b/lib/git.ts index f0920a0..353ae0f 100644 --- a/lib/git.ts +++ b/lib/git.ts @@ -18,6 +18,11 @@ export interface PushResult { error?: string; } +export interface DeleteResult { + success: boolean; + error?: string; +} + const TIMEOUT_MS = 15_000; export async function loadGitConfig(): Promise { @@ -66,6 +71,20 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise { ); } +export async function deleteIncidentFromGit( + incident: Incident, + config: GitConfig +): Promise { + switch (config.provider) { + case "gitea": + return deleteGitea(incident, config); + case "github": + return deleteGitHub(incident, config); + case "gitlab": + return deleteGitLab(incident, config); + } +} + export async function pushIncidentToGit( incident: Incident, config: GitConfig, @@ -216,6 +235,88 @@ async function pushGitHub( } } +async function deleteGitea(incident: Incident, config: GitConfig): Promise { + if (!config.url) return { success: false, error: "Gitea instance URL is not set." }; + const filepath = incidentToFilename(incident); + const apiBase = config.url.replace(/\/$/, ""); + const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`; + try { + const existing = await fetchWithTimeout(endpoint, { + headers: { Authorization: `token ${config.token}` }, + }); + if (existing.status === 404) return { success: true }; + if (!existing.ok) return { success: false, error: `GET ${existing.status}` }; + const data = await existing.json() as { sha?: string }; + if (!data.sha) return { success: false, error: "No SHA returned by API." }; + + const res = await fetchWithTimeout(endpoint, { + method: "DELETE", + headers: { Authorization: `token ${config.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha, branch: "main" }), + }); + if (!res.ok) { + const body = await res.text(); + return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; + } + return { success: true }; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` }; + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +async function deleteGitHub(incident: Incident, config: GitConfig): Promise { + const filepath = incidentToFilename(incident); + const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`; + try { + const existing = await fetchWithTimeout(endpoint, { + headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json" }, + }); + if (existing.status === 404) return { success: true }; + if (!existing.ok) return { success: false, error: `GET ${existing.status}` }; + const data = await existing.json() as { sha?: string }; + if (!data.sha) return { success: false, error: "No SHA returned by API." }; + + const res = await fetchWithTimeout(endpoint, { + method: "DELETE", + headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" }, + body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha }), + }); + if (!res.ok) { + const body = await res.text(); + return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; + } + return { success: true }; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` }; + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +async function deleteGitLab(incident: Incident, config: GitConfig): Promise { + const filepath = incidentToFilename(incident); + const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, ""); + const projectId = encodeURIComponent(`${config.owner}/${config.repo}`); + const encodedPath = encodeURIComponent(filepath); + const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`; + try { + const res = await fetchWithTimeout(endpoint, { + method: "DELETE", + headers: { "PRIVATE-TOKEN": config.token, "Content-Type": "application/json" }, + body: JSON.stringify({ branch: "main", commit_message: `remove: ${incident.title}` }), + }); + if (res.status === 404) return { success: true }; + if (!res.ok) { + const body = await res.text(); + return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; + } + return { success: true }; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` }; + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +} + async function pushGitLab( incident: Incident, config: GitConfig,