diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx index 14cc36b..444d884 100644 --- a/app/incident/[id].tsx +++ b/app/incident/[id].tsx @@ -10,10 +10,9 @@ import { import { useLocalSearchParams, router, useNavigation } from "expo-router"; import * as Clipboard from "expo-clipboard"; import * as SecureStore from "expo-secure-store"; -import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db"; +import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db"; import { renderMarkdown } from "@/lib/markdown"; -import { pushIncidentToGit } from "@/lib/git"; -import type { GitProvider } from "@/lib/git"; +import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; @@ -81,60 +80,21 @@ export default function IncidentDetailScreen() { async function handleGitPush() { if (!incident) return; - const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] = - await Promise.all([ - SecureStore.getItemAsync("git_provider"), - SecureStore.getItemAsync("md_template"), - SecureStore.getItemAsync("gitea_url"), - SecureStore.getItemAsync("gitea_token"), - SecureStore.getItemAsync("gitea_owner"), - SecureStore.getItemAsync("gitea_repo"), - SecureStore.getItemAsync("github_token"), - SecureStore.getItemAsync("github_owner"), - SecureStore.getItemAsync("github_repo"), - SecureStore.getItemAsync("gitlab_url"), - SecureStore.getItemAsync("gitlab_token"), - SecureStore.getItemAsync("gitlab_owner"), - SecureStore.getItemAsync("gitlab_repo"), - ]); - - const p = (provider ?? "gitea") as GitProvider; - - let url: string | undefined; - let token: string | null; - let owner: string | null; - let repo: string | null; - - if (p === "gitea") { - url = giteaUrl ?? undefined; - token = giteaToken; - owner = giteaOwner; - repo = giteaRepo; - } else if (p === "github") { - token = githubToken; - owner = githubOwner; - repo = githubRepo; - } else { - url = gitlabUrl ?? undefined; - token = gitlabToken; - owner = gitlabOwner; - repo = gitlabRepo; - } - - const needsUrl = p === "gitea" || p === "gitlab"; - if (!token || !owner || !repo || (needsUrl && !url)) { - Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`); + const [config, template] = await Promise.all([ + loadGitConfig(), + SecureStore.getItemAsync("md_template"), + ]); + if (!config) { + Alert.alert("Git not configured", "Set up a git provider in Settings first."); return; } - setPushing(true); - const result = await pushIncidentToGit( - incident, - { provider: p, url, token, owner, repo }, - template ?? undefined - ); + const result = await pushIncidentToGit(incident, config, template ?? undefined); setPushing(false); if (result.success) { + const now = new Date().toISOString(); + await markIncidentPushed(incident.id); + setIncident((prev) => prev ? { ...prev, gitPushedAt: now } : prev); Alert.alert("Pushed", result.url ?? "Push successful."); } else { Alert.alert("Push failed", result.error ?? "Unknown error"); diff --git a/app/index.tsx b/app/index.tsx index 3d87170..9c026dc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -5,16 +5,32 @@ import { FlatList, Pressable, ActivityIndicator, + Alert, } from "react-native"; import { router, useFocusEffect } from "expo-router"; -import { getIncidents } from "@/lib/db"; +import * as SecureStore from "expo-secure-store"; +import { getIncidents, markIncidentPushed } from "@/lib/db"; +import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; +import type { GitConfig } from "@/lib/git"; import { useSettings } from "@/hooks/useSettings"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; +function gitDotColor(inc: Incident): string { + if (!inc.gitPushedAt) return Colors.danger; + if (inc.gitPushedAt < inc.updatedAt) return Colors.warning; + return Colors.success; +} + export default function HomeScreen() { const [incidents, setIncidents] = useState([]); const [loading, setLoading] = useState(true); + const [gitConfig, setGitConfig] = useState(null); + const [mdTemplate, setMdTemplate] = useState(undefined); + const [selectionMode, setSelectionMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [bulkPushing, setBulkPushing] = useState(false); + const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 }); const { settings, ready } = useSettings(); const didRedirect = useRef(false); @@ -28,24 +44,99 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { - loadIncidents(); + load(); }, []) ); - async function loadIncidents() { + async function load() { setLoading(true); - const data = await getIncidents(10); + const [data, config, tpl] = await Promise.all([ + getIncidents(100), + loadGitConfig(), + SecureStore.getItemAsync("md_template"), + ]); setIncidents(data); + setGitConfig(config); + setMdTemplate(tpl ?? undefined); setLoading(false); } + function enterSelectionMode(id: string) { + setSelectionMode(true); + setSelected(new Set([id])); + } + + function toggleSelection(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function exitSelectionMode() { + setSelectionMode(false); + setSelected(new Set()); + } + + function selectAllUnpushed() { + const ids = incidents + .filter((i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt) + .map((i) => i.id); + setSelected(new Set(ids)); + } + + const unpushedCount = incidents.filter( + (i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt + ).length; + + async function handleBulkPush() { + if (!gitConfig) { + Alert.alert("Git not configured", "Set up a git provider in Settings first."); + return; + } + const toPush = incidents.filter((i) => selected.has(i.id)); + if (toPush.length === 0) return; + + setBulkPushing(true); + setBulkProgress({ done: 0, total: toPush.length }); + + let ok = 0; + let fail = 0; + const now = new Date().toISOString(); + + for (let i = 0; i < toPush.length; i++) { + const inc = toPush[i]; + const result = await pushIncidentToGit(inc, gitConfig, mdTemplate); + if (result.success) { + await markIncidentPushed(inc.id); + setIncidents((prev) => + prev.map((x) => (x.id === inc.id ? { ...x, gitPushedAt: now } : x)) + ); + ok++; + } else { + fail++; + } + setBulkProgress({ done: i + 1, total: toPush.length }); + } + + setBulkPushing(false); + exitSelectionMode(); + + if (fail === 0) { + Alert.alert("Done", `${ok} incident${ok > 1 ? "s" : ""} pushed.`); + } else { + Alert.alert("Partial", `${ok} pushed, ${fail} failed.`); + } + } + + const gitConfigured = gitConfig !== null; + return ( {loading ? ( - + ) : ( } - renderItem={({ item }) => ( - router.push(`/incident/${item.id}`)} - style={{ - backgroundColor: Colors.surface, - borderRadius: 8, - padding: 16, - marginBottom: 10, - borderLeftWidth: 3, - borderLeftColor: - item.status === "open" ? Colors.warning : Colors.success, - }} - > - { + const isSelected = selected.has(item.id); + return ( + + selectionMode + ? toggleSelection(item.id) + : router.push(`/incident/${item.id}`) + } + onLongPress={() => !selectionMode && enterSelectionMode(item.id)} style={{ + backgroundColor: isSelected ? Colors.surface2 : Colors.surface, + borderRadius: 8, + padding: 16, + marginBottom: 10, + borderLeftWidth: 3, + borderLeftColor: + item.status === "open" ? Colors.warning : Colors.success, flexDirection: "row", - justifyContent: "space-between", alignItems: "center", - marginBottom: 4, }} > - - {item.title || "Untitled"} - - - + - {item.status} + + {item.title || "Untitled"} + + + {gitConfigured && !selectionMode && ( + + )} + + + {item.status} + + + + + + {item.service || "—"} · {item.createdAt.slice(0, 10)} - - - {item.service || "—"} · {item.createdAt.slice(0, 10)} - - - )} + + {selectionMode && ( + + {isSelected && ( + + ✓ + + )} + + )} + + ); + }} /> )} - {/* FAB */} - router.push("/new")} - style={{ - position: "absolute", - bottom: 24, - right: 24, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: Colors.primary, - alignItems: "center", - justifyContent: "center", - elevation: 6, - }} - > - - + + + + )} + + {/* Selection mode action bar */} + {selectionMode && ( + + {/* Quick-select shortcuts */} + + {gitConfigured && unpushedCount > 0 && ( + + + Select unpushed ({unpushedCount}) + + + )} + setSelected(new Set(incidents.map((i) => i.id)))} + style={{ + flex: 1, + borderRadius: 6, + borderWidth: 1, + borderColor: Colors.border, + padding: 8, + alignItems: "center", + }} + > + + Select all ({incidents.length}) + + + + + {/* Main actions */} + + + + Cancel + + + + + {bulkPushing + ? `Pushing ${bulkProgress.done}/${bulkProgress.total}…` + : `Push ${selected.size > 0 ? selected.size : ""} selected`} + + + + + )} ); } diff --git a/lib/db.ts b/lib/db.ts index 4283abc..4d43f33 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -45,6 +45,13 @@ async function migrate(db: SQLite.SQLiteDatabase): Promise { INSERT INTO schema_version (version) VALUES (1); `); } + + if (current < 2) { + await db.execAsync(` + ALTER TABLE incidents ADD COLUMN git_pushed_at TEXT; + INSERT INTO schema_version (version) VALUES (2); + `); + } } // --- row mapper --- @@ -60,6 +67,7 @@ interface IncidentRow { created_at: string; updated_at: string; tags: string; + git_pushed_at: string | null; } function rowToIncident(row: IncidentRow): Incident { @@ -74,6 +82,7 @@ function rowToIncident(row: IncidentRow): Incident { createdAt: row.created_at, updatedAt: row.updated_at, tags: JSON.parse(row.tags) as string[], + gitPushedAt: row.git_pushed_at ?? null, }; } @@ -107,6 +116,7 @@ export async function createIncident(draft: IncidentDraft): Promise { id, createdAt: now, updatedAt: now, + gitPushedAt: null, }; } @@ -163,6 +173,14 @@ export async function deleteIncident(id: string): Promise { await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]); } +export async function markIncidentPushed(id: string): Promise { + const db = await getDb(); + await db.runAsync( + "UPDATE incidents SET git_pushed_at = ? WHERE id = ?", + [new Date().toISOString(), id] + ); +} + export async function getMaxTitleNumber(prefix: string): Promise { const db = await getDb(); const rows = await db.getAllAsync<{ title: string }>( diff --git a/lib/git.ts b/lib/git.ts index 8763d2d..f0920a0 100644 --- a/lib/git.ts +++ b/lib/git.ts @@ -1,3 +1,4 @@ +import * as SecureStore from "expo-secure-store"; import { incidentToFilename, renderMarkdown } from "./markdown"; import type { Incident } from "@/types/incident"; @@ -19,6 +20,44 @@ export interface PushResult { const TIMEOUT_MS = 15_000; +export async function loadGitConfig(): Promise { + const [provider, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] = + await Promise.all([ + SecureStore.getItemAsync("git_provider"), + SecureStore.getItemAsync("gitea_url"), + SecureStore.getItemAsync("gitea_token"), + SecureStore.getItemAsync("gitea_owner"), + SecureStore.getItemAsync("gitea_repo"), + SecureStore.getItemAsync("github_token"), + SecureStore.getItemAsync("github_owner"), + SecureStore.getItemAsync("github_repo"), + SecureStore.getItemAsync("gitlab_url"), + SecureStore.getItemAsync("gitlab_token"), + SecureStore.getItemAsync("gitlab_owner"), + SecureStore.getItemAsync("gitlab_repo"), + ]); + + const p = (provider ?? "gitea") as GitProvider; + let url: string | undefined; + let token: string | null; + let owner: string | null; + let repo: string | null; + + if (p === "gitea") { + url = gu ?? undefined; + token = gt; owner = go; repo = gr; + } else if (p === "github") { + token = ghu; owner = ghown; repo = ghrepo; + } else { + url = glu ?? undefined; + token = glt; owner = glo; repo = glr; + } + + const needsUrl = p === "gitea" || p === "gitlab"; + if (!token || !owner || !repo || (needsUrl && !url)) return null; + return { provider: p, url, token, owner, repo }; +} + function fetchWithTimeout(url: string, init: RequestInit): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); diff --git a/types/incident.ts b/types/incident.ts index d5af87e..d823777 100644 --- a/types/incident.ts +++ b/types/incident.ts @@ -11,6 +11,7 @@ export interface Incident { createdAt: string; updatedAt: string; tags: string[]; + gitPushedAt: string | null; } -export type IncidentDraft = Omit; +export type IncidentDraft = Omit;