iPad: adaptive sidebar+detail shell and Mac-style diff (remote Phases 1-2)

Grow the universal NucleicRemote app into a width-adaptive shell so a
regular-width iPad renders the macOS sidebar+detail IA while the iPhone
keeps its TabView -- both over the same RemoteStore projection (one host
authority, N renderers).

Phase 1 (adaptive shell):
- AdaptiveRootView branches on horizontalSizeClass: CompactRootView (the
  existing iPhone TabView, moved verbatim) vs SplitRootView
  (NavigationSplitView) on regular width.
- SplitSidebar: Home/Projects/To-dos/Settings destinations + sessions
  grouped under their projects (attention-sorted), connection chip footer.
- SplitDetail selects a destination or a session; a selected session reuses
  SessionDetailView keyed .id(sessionID) so switching drives open/close.
- RemoteStore.closeOpen(_:) is now id-guarded so a split-view A->B switch
  (onAppear(B) before onDisappear(A)) can't tear down B's fresh subscription.
- IdentityStore.deviceID idiom-tags the prefix (ipad-/iphone-) for new
  installs so the host lists a paired iPad correctly.

Phase 2 (width tuning + diff):
- readableColumn() caps+centers Home and the transcript on wide layouts;
  a no-op at phone/portrait width.
- SessionDiffView switches on available width (GeometryReader): a Mac-style
  two-pane diff (file list + selected file's patch) on wide/landscape, the
  phone stack otherwise. UnifiedPatch splits the combined patch per file.
  Read-only, same wire, no protocol change.
- Demo diff fixture now carries both files' patches.

iPhone layout and behavior unchanged. Builds clean; verified in the iPad
simulator (demo mode).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-03 23:59:29 -07:00
co-authored by Claude Opus 4.8
parent 6a268abbb9
commit 34aa01a724
7 changed files with 450 additions and 58 deletions
@@ -1,6 +1,9 @@
import Foundation import Foundation
import Security import Security
import NucleicProtocol 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 /// 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 /// reconnect), a display name, and the transport + connection hint from the QR — LAN
@@ -42,11 +45,22 @@ enum IdentityStore {
static func deviceID() -> String { static func deviceID() -> String {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: deviceIDKey) { return existing } 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) defaults.set(id, forKey: deviceIDKey)
return id 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? { static func loadPairedHost() -> PairedHost? {
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
return try? JSONDecoder().decode(PairedHost.self, from: data) return try? JSONDecoder().decode(PairedHost.self, from: data)
@@ -290,6 +290,17 @@ final class RemoteStore: ObservableObject {
+ if (!token) throw new AuthError("missing bearer token") + if (!token) throw new AuthError("missing bearer token")
return verify(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)") ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
} }
func closeOpen() { /// Close a session's live subscription. `id` names *which* session is closing — the detail
if let id = openSessionID { /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
send(.unsubscribe(id)) /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
markOpened(id) // everything up to now has been seen /// 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 openSessionID = nil
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
@@ -36,8 +36,6 @@ struct NucleicRemoteApp: App {
struct RootView: View { struct RootView: View {
@EnvironmentObject var store: RemoteStore @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 preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices. // Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@@ -52,30 +50,15 @@ struct RootView: View {
DemoBanner() DemoBanner()
Group { Group {
if store.isPaired { if store.isPaired {
TabView(selection: $tab) { // Width-adaptive shell: the iPhone's TabView on compact width, the Mac's
HomeView() // sidebar+detail split on a regular-width iPad (see AdaptiveRootView).
.tabItem { Label("Home", systemImage: "house") }.tag(0) AdaptiveRootView()
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)
}
} else { } else {
PairingIntroView() PairingIntroView()
} }
} }
} }
.overlay(alignment: .bottom) { ErrorBubble() } .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) .tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
@@ -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<SessionID>()
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)
}
}
}
@@ -86,6 +86,9 @@ struct HomeView: View {
StartChatComposer() StartChatComposer()
} }
.padding() .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") .navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -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)
}
}
@@ -71,7 +71,9 @@ struct SessionDetailView: View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
.onAppear { store.open(sessionID) } .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). /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
@@ -297,6 +299,9 @@ struct TranscriptList: View {
} }
} }
.padding() .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 // A drag on the transcript dismisses the keyboard, so a tall multiline composer can
// be put away without leaving the session. // be put away without leaving the session.
@@ -310,26 +315,27 @@ struct TranscriptList: View {
} }
} }
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list /// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`. /// 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 { struct SessionDiffView: View {
let diff: WireSessionDiff? let diff: WireSessionDiff?
let loading: Bool 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 { var body: some View {
if let diff, diff.stat.filesChanged > 0 { if let diff, diff.stat.filesChanged > 0 {
ScrollView { GeometryReader { geo in
VStack(alignment: .leading, spacing: 14) { if geo.size.width >= twoPaneMinWidth {
fileList(diff) SplitDiffView(diff: diff)
PatchText(patch: diff.patch) } else {
if diff.truncated { stacked(diff)
Label("Patch truncated — open the Mac for the rest.",
systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
} }
} }
.padding()
}
} else if loading { } else if loading {
ProgressView("Fetching diff…") ProgressView("Fetching diff…")
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -338,15 +344,110 @@ struct SessionDiffView: View {
} }
} }
private func fileList(_ diff: WireSessionDiff) -> some View { /// 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) { VStack(alignment: .leading, spacing: 6) {
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)") DiffStatHeader(stat: diff.stat)
.font(.subheadline.weight(.semibold)) 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
}
}
}
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 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) { HStack(spacing: 8) {
Image(systemName: statusIcon(file.status)) Image(systemName: DiffStatus.icon(file.status))
.font(.caption) .font(.caption)
.foregroundStyle(statusColor(file.status)) .foregroundStyle(DiffStatus.color(file.status))
Text(file.path) Text(file.path)
.font(.caption.monospaced()) .font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle) .lineLimit(1).truncationMode(.middle)
@@ -356,10 +457,9 @@ struct SessionDiffView: View {
} }
} }
} }
.card()
}
private func statusIcon(_ status: String) -> String { enum DiffStatus {
static func icon(_ status: String) -> String {
switch status { switch status {
case "added", "untracked": "plus.circle" case "added", "untracked": "plus.circle"
case "deleted": "minus.circle" case "deleted": "minus.circle"
@@ -367,8 +467,7 @@ struct SessionDiffView: View {
default: "pencil.circle" default: "pencil.circle"
} }
} }
static func color(_ status: String) -> Color {
private func statusColor(_ status: String) -> Color {
switch status { switch status {
case "added", "untracked": Palette.success case "added", "untracked": Palette.success
case "deleted": Palette.danger 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 /// 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. /// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View { private struct PatchText: View {