diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 4277b5f..4203cd1 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -1,6 +1,9 @@ import Foundation import Security import NucleicProtocol +#if canImport(UIKit) +import UIKit +#endif /// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK /// reconnect), a display name, and the transport + connection hint from the QR — LAN @@ -42,11 +45,22 @@ enum IdentityStore { static func deviceID() -> String { let defaults = UserDefaults.standard if let existing = defaults.string(forKey: deviceIDKey) { return existing } - let id = "iphone-" + UUID().uuidString.prefix(8).lowercased() + let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased() defaults.set(id, forKey: deviceIDKey) return id } + /// Idiom-tagged prefix so the host lists a paired device with the right kind/icon + /// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install + /// keeps whatever id it already persisted, so upgrading a phone never changes its identity. + private static var deviceIDPrefix: String { + #if canImport(UIKit) + return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-" + #else + return "iphone-" + #endif + } + static func loadPairedHost() -> PairedHost? { guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } return try? JSONDecoder().decode(PairedHost.self, from: data) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 68398c0..f7e2215 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -290,6 +290,17 @@ final class RemoteStore: ObservableObject { + if (!token) throw new AuthError("missing bearer token") return verify(token) } + diff --git a/auth/session.ts b/auth/session.ts + new file mode 100644 + --- /dev/null + +++ b/auth/session.ts + @@ -0,0 +1,6 @@ + +export interface Session { + + userId: string + + issuedAt: number + +} + + + +export const SESSION_TTL = 3600 """) } @@ -648,11 +659,17 @@ final class RemoteStore: ObservableObject { ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)") } - func closeOpen() { - if let id = openSessionID { - send(.unsubscribe(id)) - markOpened(id) // everything up to now has been seen - } + /// Close a session's live subscription. `id` names *which* session is closing — the detail + /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls + /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always + /// unsubscribe the named session but only tear down the shared open-state when it still + /// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with + /// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged). + func closeOpen(_ id: SessionID? = nil) { + guard let target = id ?? openSessionID else { return } + send(.unsubscribe(target)) + markOpened(target) // everything up to now has been seen + guard openSessionID == target else { return } openSessionID = nil openEvents = [] openApprovals = [] diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 7a5fc6f..267c7c1 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -36,8 +36,6 @@ struct NucleicRemoteApp: App { struct RootView: View { @EnvironmentObject var store: RemoteStore - // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. - @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 // Appearance preferences — same storage keys and semantics as the Mac's Settings → // Appearance, so both devices honor the same choices. @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @@ -52,30 +50,15 @@ struct RootView: View { DemoBanner() Group { if store.isPaired { - TabView(selection: $tab) { - HomeView() - .tabItem { Label("Home", systemImage: "house") }.tag(0) - SessionsView() - .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } - .badge(store.needsYouCount).tag(1) - ProjectsView() - .tabItem { Label("Projects", systemImage: "folder") }.tag(2) - TodosView() - .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) - SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) - } + // Width-adaptive shell: the iPhone's TabView on compact width, the Mac's + // sidebar+detail split on a regular-width iPad (see AdaptiveRootView). + AdaptiveRootView() } else { PairingIntroView() } } } .overlay(alignment: .bottom) { ErrorBubble() } - // A notification tap routes to its session: jump to the Sessions tab, where - // `SessionsView` consumes `pendingRoute` and pushes the detail. - .onChange(of: store.pendingRoute) { _, route in - if route != nil { tab = 1 } - } .tint(Palette.accent) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift new file mode 100644 index 0000000..653c521 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -0,0 +1,213 @@ +import SwiftUI +import NucleicProtocol + +/// Chooses the navigation shell by width so one universal binary serves both idioms: +/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`. +/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split. +/// +/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host +/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip +/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open +/// transcript survive it. +struct AdaptiveRootView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + if sizeClass == .regular { + SplitRootView() + } else { + CompactRootView() + } + } +} + +// MARK: - Compact (iPhone / narrow multitasking) + +/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A +/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` +/// and pushes the detail. +struct CompactRootView: View { + @EnvironmentObject var store: RemoteStore + // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. + @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 + + var body: some View { + TabView(selection: $tab) { + HomeView() + .tabItem { Label("Home", systemImage: "house") }.tag(0) + SessionsView() + .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } + .badge(store.needsYouCount).tag(1) + ProjectsView() + .tabItem { Label("Projects", systemImage: "folder") }.tag(2) + TodosView() + .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) + } + // A notification tap routes to its session by jumping to the Sessions tab. + .onChange(of: store.pendingRoute) { _, route in + if route != nil { tab = 1 } + } + } +} + +// MARK: - Regular (iPad sidebar + detail) + +/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the +/// live sessions themselves (grouped by project). Projects are reached through the `projects` +/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer. +enum SidebarItem: Hashable { + case home + case projects + case todos + case settings + case session(SessionID) +} + +/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading +/// column carries the destinations + the project→session tree, and whose detail is the selected +/// session (or a destination view). Selection lives here and binds the same `openSessionID` +/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one +/// session for another fires `open`/`closeOpen` exactly as a push/pop would. +struct SplitRootView: View { + @EnvironmentObject var store: RemoteStore + @State private var selection: SidebarItem? = .home + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + SplitSidebar(selection: $selection) + } detail: { + SplitDetail(selection: selection) + } + .navigationSplitViewStyle(.balanced) + // Notification tap → select its session directly (the regular-width analogue of the + // compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed. + .onChange(of: store.pendingRoute) { _, route in + guard let route else { return } + selection = .session(route) + store.pendingRoute = nil + } + .onAppear { + // Offline UI previews (NUCLEIC_DEMO): open straight into one session. + if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty { + selection = .session(SessionID(rawValue: raw)) + } + } + } +} + +/// The leading column: global destinations, then the live sessions grouped under their projects +/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent +/// connection status sits in the footer — one home for it, versus the phone's per-tab inset. +private struct SplitSidebar: View { + @EnvironmentObject var store: RemoteStore + @Binding var selection: SidebarItem? + + /// Live sessions grouped under their project (dashboard order), with anything whose project + /// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort. + private var projectGroups: [(name: String, rows: [WireSessionSummary])] { + let live = store.liveSessions.sorted { + let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1) + return a == b ? $0.updatedAt > $1.updatedAt : a < b + } + var groups: [(name: String, rows: [WireSessionSummary])] = [] + var placed = Set() + for project in store.dashboard.projects { + let rows = live.filter { $0.projectID == project.id.rawValue } + guard !rows.isEmpty else { continue } + groups.append((project.name, rows)) + rows.forEach { placed.insert($0.sessionID) } + } + let orphans = live.filter { !placed.contains($0.sessionID) } + if !orphans.isEmpty { groups.append(("Other", orphans)) } + return groups + } + + var body: some View { + List(selection: $selection) { + Section { + Label("Home", systemImage: "house").tag(SidebarItem.home) + Label("Projects", systemImage: "folder").tag(SidebarItem.projects) + Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos) + Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings) + } + if projectGroups.isEmpty { + Section("Sessions") { + Text(store.connectivity.isLive + ? "Start a session from Home or the Mac to see it here." + : "Waiting to connect to \(store.hostName)…") + .font(.callout).foregroundStyle(.secondary) + } + } else { + ForEach(projectGroups, id: \.name) { group in + Section(group.name) { + ForEach(group.rows, id: \.sessionID) { summary in + SessionRow(summary: summary, showProjectName: false) + .tag(SidebarItem.session(summary.sessionID)) + .listRowBackground(SessionRowWash(summary: summary)) + .swipeActions(edge: .leading) { + Button { + store.setFavorite(summary.sessionID, !summary.favorite) + } label: { + Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") + }.tint(.yellow) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.deleteSession(summary.sessionID) + } label: { Label("Delete", systemImage: "trash") } + Button { + store.setArchived(summary.sessionID, !summary.archived) + } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") + }.tint(.gray) + } + } + } + } + } + } + .listStyle(.sidebar) + .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { store.refreshSessions() } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + } + } + .safeAreaInset(edge: .bottom) { + ConnectionChip().padding(.bottom, 10) + } + } +} + +/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged +/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so +/// switching sessions remounts and drives that lifecycle. The destination views already embed +/// their own `NavigationStack`, so they're shown directly. +private struct SplitDetail: View { + let selection: SidebarItem? + + var body: some View { + switch selection { + case .home, .none: + HomeView() + case .projects: + ProjectsView() + case .todos: + TodosView() + case .settings: + SettingsView() + case .session(let id): + NavigationStack { + SessionDetailView(sessionID: id) + } + .id(id) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 28f6941..f1bd9c7 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -86,6 +86,9 @@ struct HomeView: View { StartChatComposer() } .padding() + // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather + // than a stretched phone; a no-op at phone width. + .readableColumn() } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) diff --git a/NucleicRemote/NucleicRemote/Views/Layout.swift b/NucleicRemote/NucleicRemote/Views/Layout.swift new file mode 100644 index 0000000..efdd553 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Layout.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// Width tuning for the universal layout. In a wide iPad detail column, content designed for a +/// phone runs edge-to-edge and loses the Mac's comfortable reading measure; `readableColumn` +/// caps its width and centers it. It's a no-op on the phone and on iPad compact multitasking, +/// where the available width is already below the cap — so the iPhone layout is unchanged. +extension View { + func readableColumn(_ maxWidth: CGFloat = 820) -> some View { + frame(maxWidth: maxWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 64473d3..5cad92b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -71,7 +71,9 @@ struct SessionDetailView: View { Button("Cancel", role: .cancel) {} } .onAppear { store.open(sessionID) } - .onDisappear { store.closeOpen() } + // Pass our own id so an iPad split-view A→B switch (which may mount B before A + // disappears) unsubscribes A without tearing down B's just-opened state. + .onDisappear { store.closeOpen(sessionID) } } /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). @@ -297,6 +299,9 @@ struct TranscriptList: View { } } .padding() + // Cap the transcript to a readable measure on a wide iPad so lines don't run + // edge-to-edge on a 13-inch screen; a no-op at phone width. + .readableColumn() } // A drag on the transcript dismisses the keyboard, so a tall multiline composer can // be put away without leaving the session. @@ -310,25 +315,26 @@ struct TranscriptList: View { } } -/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list -/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`. +/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file +/// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's +/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the +/// wide detail column. Same wire, no new protocol. struct SessionDiffView: View { let diff: WireSessionDiff? let loading: Bool + /// Below this available width the two-pane diff would leave the patch too cramped, so we + /// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the + /// stack on a phone or a narrow portrait split — based on real width, not just size class. + private let twoPaneMinWidth: CGFloat = 700 var body: some View { if let diff, diff.stat.filesChanged > 0 { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - fileList(diff) - PatchText(patch: diff.patch) - if diff.truncated { - Label("Patch truncated — open the Mac for the rest.", - systemImage: "scissors") - .font(.caption).foregroundStyle(.secondary) - } + GeometryReader { geo in + if geo.size.width >= twoPaneMinWidth { + SplitDiffView(diff: diff) + } else { + stacked(diff) } - .padding() } } else if loading { ProgressView("Fetching diff…") @@ -338,28 +344,122 @@ struct SessionDiffView: View { } } - private func fileList(_ diff: WireSessionDiff) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)") - .font(.subheadline.weight(.semibold)) - ForEach(diff.files) { file in - HStack(spacing: 8) { - Image(systemName: statusIcon(file.status)) - .font(.caption) - .foregroundStyle(statusColor(file.status)) - Text(file.path) - .font(.caption.monospaced()) - .lineLimit(1).truncationMode(.middle) - Spacer(minLength: 8) - Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success) - Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger) + /// Phone form: the file summary over one scrolling unified patch. + private func stacked(_ diff: WireSessionDiff) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + DiffStatHeader(stat: diff.stat) + ForEach(diff.files) { DiffFileRow(file: $0) } } + .card() + PatchText(patch: diff.patch) + if diff.truncated { TruncatedPatchNote() } + } + .padding() + .readableColumn() + } + } +} + +/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected +/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose +/// single-select tap handling is unreliable outside an edit-mode / split-view context). +private struct SplitDiffView: View { + let diff: WireSessionDiff + @State private var selected: String? + + /// The combined patch split into per-file sections, keyed by file path. + private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) } + private var current: String? { selected ?? diff.files.first?.path } + + var body: some View { + HStack(spacing: 0) { + fileList + .frame(width: 280) + .background(Color(.secondarySystemBackground)) + Divider() + patchPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onAppear { if selected == nil { selected = diff.files.first?.path } } + // Keep the selection valid as the diff refetches (files can appear/vanish between turns). + .onChange(of: diff.files) { _, files in + if selected == nil || !files.contains(where: { $0.path == selected }) { + selected = files.first?.path } } - .card() } - private func statusIcon(_ status: String) -> String { + private var fileList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + DiffStatHeader(stat: diff.stat) + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Divider() + ForEach(diff.files) { file in + Button { selected = file.path } label: { + DiffFileRow(file: file) + .padding(.horizontal, 12).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(current == file.path + ? Palette.accent.opacity(0.15) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if diff.truncated { TruncatedPatchNote().padding(12) } + } + } + } + + @ViewBuilder + private var patchPane: some View { + if let path = current, let patch = sections[path], !patch.isEmpty { + ScrollView { PatchText(patch: patch).padding() } + } else if current != nil { + // A file with no textual hunk (binary, or a section dropped by patch truncation). + ContentUnavailableView( + "No preview", systemImage: "doc", + description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").") + ) + } else { + ContentUnavailableView("Select a file", systemImage: "sidebar.left") + } + } +} + +/// The "N files changed · +A −R" header shared by both diff forms. +private struct DiffStatHeader: View { + let stat: DiffStat + var body: some View { + Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)") + .font(.subheadline.weight(.semibold)) + } +} + +/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the +/// iPad file list. +struct DiffFileRow: View { + let file: WireFileDiff + var body: some View { + HStack(spacing: 8) { + Image(systemName: DiffStatus.icon(file.status)) + .font(.caption) + .foregroundStyle(DiffStatus.color(file.status)) + Text(file.path) + .font(.caption.monospaced()) + .lineLimit(1).truncationMode(.middle) + Spacer(minLength: 8) + Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success) + Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger) + } + } +} + +enum DiffStatus { + static func icon(_ status: String) -> String { switch status { case "added", "untracked": "plus.circle" case "deleted": "minus.circle" @@ -367,8 +467,7 @@ struct SessionDiffView: View { default: "pencil.circle" } } - - private func statusColor(_ status: String) -> Color { + static func color(_ status: String) -> Color { switch status { case "added", "untracked": Palette.success case "deleted": Palette.danger @@ -377,6 +476,57 @@ struct SessionDiffView: View { } } +private struct TruncatedPatchNote: View { + var body: some View { + Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors") + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view. +enum UnifiedPatch { + /// Map from file path to that file's slice of the unified patch. Sections begin at a + /// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally + /// (the host emits patch and files together, in order); otherwise we key each section by the + /// new path parsed from its header. Callers fall back to a "no preview" state on a miss. + static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] { + guard patch.contains("diff --git ") else { + // A single-file patch with no git header: attribute the whole thing to the sole file. + return files.count == 1 ? [files[0].path: patch] : [:] + } + var chunks: [String] = [] + var current: [Substring] = [] + for line in patch.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("diff --git ") { + if !current.isEmpty { chunks.append(current.joined(separator: "\n")) } + current = [line] + } else if !current.isEmpty { + current.append(line) + } + } + if !current.isEmpty { chunks.append(current.joined(separator: "\n")) } + + var result: [String: String] = [:] + if chunks.count == files.count { + for (file, chunk) in zip(files, chunks) { result[file.path] = chunk } + } else { + for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk } + } + return result + } + + /// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is + /// absent or the path is quoted/spaced in a way we don't split cleanly. + private static func newPath(_ chunk: String) -> String? { + guard let header = chunk.split(separator: "\n", maxSplits: 1).first, + header.hasPrefix("diff --git "), + let bRange = header.range(of: " b/") else { return nil } + let path = header[bRange.upperBound...] + return path.isEmpty ? nil : String(path) + } +} + /// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll /// horizontally as a block so long lines don't wrap into noise. private struct PatchText: View {