Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
T
abkslmandNucleic e23758a0e4 Scroll Position Adjustment
Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87
Co-authored-by: Nucleic <[email protected]>
2026-07-06 00:33:00 -07:00

811 lines
39 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicProtocol
/// One session: a full-height transcript with a status-driven action bar floating over it on
/// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on
/// appear, unsubscribes on disappear.
struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
// Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews,
// matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
@State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
@State private var draft = ""
@State private var showRename = false
@State private var renameDraft = ""
@State private var showIntegrate = false
@State private var showDiscard = false
// Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to
// read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
@State private var scrollToBottomRequest = 0
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
}
var body: some View {
TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest)
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
// in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below toggling `store.openSessionID`, not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// 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) }
.background { interruptShortcut }
}
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
/// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies.
@ViewBuilder
private var interruptShortcut: some View {
if store.canControl, summary?.status == .running {
Button("Interrupt") { store.interrupt(sessionID) }
.keyboardShortcut(".", modifiers: .command)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
private var controlled: Bool {
store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false
}
private var modelBinding: Binding<String?> {
Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
}
private var effortBinding: Binding<String> {
Binding(
get: {
let fallback = store.modelCatalog.fallbackEffort.isEmpty
? MobileEfforts.fallback : store.modelCatalog.fallbackEffort
return summary?.effort ?? fallback
},
set: { store.setSessionEffort(sessionID, $0) })
}
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
/// from the transcript exactly as the Mac header does.
private var contextPercent: Int? {
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in
switch event.kind {
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
case .usage(let usage): return usage.contextInputTokens
default: return nil
}
}.first { $0 > 0 }
guard let used else { return nil }
let window = store.modelCatalog.contextWindow(summary?.model)
guard window > 0 else { return nil }
return min(100, Int((Double(used) / Double(window)) * 100))
}
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
/// session header, folded into the glass chat bar so they don't cost a strip of transcript
/// height. Reads current state from the summary; each change is a control intent.
@ViewBuilder
private func controlRow(_ summary: WireSessionSummary) -> some View {
HStack(spacing: 10) {
// A session's backend is fixed at creation, so only same-backend models are offered.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
// No explicit model yet → use the session backend's default, so the menu
// shows that backend's effort range rather than collapsing to one level.
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
controlled: controlled,
// Orchestra is a one-way latch for an existing chat — the host won't turn it
// off, so disable the other levels here to match.
lockWhenOrchestra: true)
Spacer(minLength: 4)
if let percent = contextPercent {
// Weight and primary ink (not `.secondary`) so the metric stays legible over
// whatever the Liquid Glass bar is blurring through.
Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
.font(.caption2.weight(.semibold)).foregroundStyle(.primary)
.help("Context window used")
}
// Locked on for a Nucleic Control project — those chats always run autonomously, and
// the host keeps Auto on regardless of what the phone sends, so show it locked on to
// match rather than offering a toggle that wouldn't take.
Toggle(isOn: Binding(get: { controlled || summary.auto },
set: { store.setSessionAuto(sessionID, $0) })) {
Label("Auto", systemImage: (controlled || summary.auto) ? "bolt.fill" : "bolt.slash")
}
.toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold))
.disabled(controlled)
.help(controlled
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
: "Auto-approve safe actions; destructive ones still ask.")
}
}
/// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions.
@ViewBuilder
private var sessionMenu: some View {
Menu {
Button { showDiff = true } label: {
Label(diffLabel, systemImage: "plus.forwardslash.minus")
}
if let summary, store.canControl {
Divider()
sessionActions(summary)
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
/// "View changes" with the live +/− counts when the host projects them, so the menu hints
/// whether there's anything to look at before opening the sheet.
private var diffLabel: String {
guard let stat = summary?.diffStat, stat.filesChanged > 0 else { return "View changes" }
return "View changes · +\(stat.added) −\(stat.removed)"
}
/// The diff, presented as a sheet from the ⋯ menu — a viewer you visit, not a mode the
/// chat lives in.
private var diffSheet: some View {
NavigationStack {
Group {
if store.capabilities.canFetchDiff || store.openDiff != nil {
SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
.onAppear { store.fetchDiff(sessionID) }
} else {
// Older host with no on-demand diff — the stat summary is all it projects.
DiffSummaryView(diffStat: summary?.diffStat)
}
}
.navigationTitle("Changes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { showDiff = false }
}
}
}
}
@ViewBuilder
private func sessionActions(_ summary: WireSessionSummary) -> some View {
Button { renameDraft = summary.title; showRename = true } label: {
Label("Rename", systemImage: "pencil")
}
Button { store.setFavorite(sessionID, !summary.favorite) } label: {
Label(summary.favorite ? "Unfavorite" : "Favorite",
systemImage: summary.favorite ? "star.slash" : "star")
}
// Autoship is a Nucleic Control capability — offered only for Control projects, and
// the host couples it with auto-approval (enabling it turns Auto on).
if controlled {
Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
}
}
if summary.status == .running {
Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") }
}
Section {
Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") }
Button(role: .destructive) { showDiscard = true } label: {
Label("Discard…", systemImage: "xmark.bin")
}
Button { store.setArchived(sessionID, !summary.archived) } label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}
}
Button(role: .destructive) { store.deleteSession(sessionID) } label: {
Label("Delete", systemImage: "trash")
}
}
/// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
/// indicator, the session controls, and the composer — or the approval card when a gate is
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
private var actionArea: some View {
VStack(spacing: 8) {
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
// disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner }
actionContent
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
/// The offline notice over the transcript: the chat's history is cached and readable, but the
/// composer + controls below stay disabled until the Mac that owns it is reachable again.
private var disconnectedBanner: some View {
HStack(spacing: 10) {
Image(systemName: "wifi.slash").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text("Disconnected").font(.footnote.weight(.semibold))
Text("Showing saved history · reconnect to send")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Button("Reconnect") { store.reconnect() }
.font(.caption.weight(.semibold))
.buttonStyle(.borderless)
.disabled(!store.isPaired)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14).padding(.vertical, 10)
.glassSurface(cornerRadius: 20)
}
@ViewBuilder
private var actionContent: some View {
Group {
if let approval = store.openApprovals.first {
// AskUserQuestion isn't a permission gate — it collects the user's answers and
// needs them folded back into `updatedInput`. Render the picker (matching the
// Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would
// reply with no selection and make the CLI report the question went unanswered.
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionCardView(approval: approval, questions: questions)
} else {
ApprovalCardView(approval: approval)
}
} else if let status = summary?.status {
let running = status == .running
let canCompose = running || status == .awaitingInput || status.isTerminal
let queued = summary?.queuedMessages ?? []
if canCompose || store.canControl || !queued.isEmpty {
VStack(spacing: 10) {
// Follow-ups submitted mid-turn, held by the host to send when the current
// turn finishes. Each is shown as its own pill (with its attachments) and —
// at control scope — can be cancelled individually, matching the Mac
// composer. (They still send as one combined turn.)
if !queued.isEmpty {
VStack(spacing: 6) {
ForEach(queued) { message in
queuedMessageRow(message)
}
}
}
if running {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Agent is working…").font(.footnote).foregroundStyle(.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if store.canControl, let summary { controlRow(summary) }
if canCompose {
HStack(alignment: .bottom, spacing: 8) {
// No keyboard-accessory Done button here (it floats awkwardly
// over the glass bar on iOS 26) — a drag on the transcript
// dismisses the keyboard instead (`scrollDismissesKeyboard`).
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...4)
.padding(.vertical, 3)
Button {
store.sendInput(draft, to: sessionID)
draft = ""
} label: {
Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
.font(.title2)
}
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
// Mac — plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
}
}
}
}
/// A queued follow-up shown above the composer: its text (or attachment names) with the
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
@ViewBuilder
private func queuedMessageRow(_ message: QueuedMessage) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "clock").foregroundStyle(.secondary)
Text(queuedRowText(message)).lineLimit(1).truncationMode(.tail)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
if store.canControl {
Button {
// Refill the composer for editing (text only — the phone can't stage the
// host's files) unless the user has already started typing; then drop it.
if draft.trimmingCharacters(in: .whitespaces).isEmpty { draft = message.text }
store.cancelQueuedMessage(sessionID, message.id)
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!store.connectivity.isLive)
}
}
.font(.footnote)
if !message.attachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(message.attachments) { attachment in
Label(attachment.filename, systemImage: "doc")
.font(.caption2)
.lineLimit(1)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(.quaternary, in: .capsule)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8).padding(.vertical, 6)
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10))
}
/// The one-line label for a queued pill: the message text, or — for an attachment-only
/// message — the attachment names (the chips below repeat them), so it's never blank.
private func queuedRowText(_ message: QueuedMessage) -> String {
if !message.text.isEmpty { return message.text }
return message.attachments.map(\.filename).joined(separator: ", ")
}
}
struct TranscriptList: View {
let events: [AgentEvent]
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
/// next" that jumps to the bottom even if they'd scrolled up to read history.
var scrollToBottomRequest: Int = 0
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
/// True while the user is parked at (within a hair of) the bottom, so live output keeps
/// following; once they scroll up it flips false, following stops, and the jump-to-bottom
/// chevron appears. Starts true — a freshly opened chat is anchored at the bottom.
@State private var isScrolledToBottom = true
/// True for a short window right after the transcript appears, while it runs its first
/// layout passes and its events fill in. During it we accept only "at bottom" scroll
/// readings, so the settling layout can't strand the chat above its true bottom or flash the
/// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`.
@State private var transcriptSettling = true
/// Total scroll-content height, tracked live from the scroll view's geometry, but only while
/// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest.
@State private var transcriptContentHeight: CGFloat = 0
private var items: [TranscriptItem] {
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
}
/// How much content may still sit below the viewport's bottom edge and still count as "at
/// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors
/// the Mac's `bottomFollowThreshold`.
private let bottomFollowThreshold: CGFloat = 24
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(items) { item in
TranscriptRow(item: item).id(item.id)
}
}
.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.
.scrollDismissesKeyboard(.interactively)
// The single-argument `defaultScrollAnchor(.bottom)` governs both the initial offset
// and how the scroll view re-pins on content-size changes — it sticks the bottom edge
// in view as the transcript grows. That native stickiness is what we want while the
// user is parked at the bottom (the chat opens at the end and follows live output),
// but it's also what yanked them back down whenever anything changed while they'd
// scrolled up to read history. So gate the anchor itself: drop it to `nil` the moment
// they scroll away, which lets new content land off-screen below instead of dragging
// the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
// Track the live scroll position straight from the scroll view's geometry: how much
// content still sits below the viewport bottom. Within the slack threshold means the
// user is parked at the end (live output keeps following); scrolling up flips this
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron.
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
<= bottomFollowThreshold
} action: { _, atBottom in
// While the chat is opening the layout grows over a few passes and the scroll
// offset lags each growth by a frame — sampling that frame reads "not at bottom"
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the
// open window accept only "at bottom" readings; honor real scroll-ups after.
if transcriptSettling {
if atBottom { isScrolledToBottom = true }
} else {
isScrolledToBottom = atBottom
}
}
// Detect when the opening layout has come to rest: track the content height while
// settling and let `.task(id:)` debounce it — the task restarts on every change and
// only reaches its body once the height has held steady. Freezing the tracker after
// settling keeps live streaming (which changes the height every frame) from re-arming.
.onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in
if transcriptSettling { transcriptContentHeight = height }
}
.task(id: transcriptContentHeight) {
guard transcriptSettling, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
transcriptSettling = false
}
// Coalescing means item count lags event count; key the autoscroll on the raw stream
// so every streamed delta keeps the view pinned to the bottom — but only while the
// user is already parked there. Scrolling up to read history is never yanked down.
.onChange(of: events.count) {
if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) }
}
// An explicit jump — the chevron or sending a message — always wins.
.onChange(of: scrollToBottomRequest) { scrollToLast(proxy) }
// Floating chevron above the chat bar, shown only while scrolled up; tap to jump back
// to the latest output. Mirrors the Mac's `JumpToBottomButton`.
.overlay(alignment: .bottom) {
ZStack {
if !isScrolledToBottom {
JumpToBottomButton { scrollToLast(proxy) }
.padding(.bottom, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
}
}
}
/// Scroll the last message to the bottom edge; `animated: false` during the open-settle
/// window so a chat that's still laying out snaps to the tail instead of easing into place.
private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) {
guard let last = items.last else { return }
if animated {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
} else {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
/// The floating "jump to the latest" chevron shown above the chat bar while the user has
/// scrolled up away from the bottom of the transcript. The mobile twin of the Mac's
/// `JumpToBottomButton`.
private struct JumpToBottomButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Palette.accent)
.frame(width: 30, height: 30)
.background(.regularMaterial, in: .circle)
.overlay(Circle().strokeBorder(.separator, lineWidth: 0.5))
.shadow(color: .black.opacity(0.18), radius: 4, y: 1)
}
.buttonStyle(.plain)
.accessibilityLabel("Jump to the latest message")
}
}
/// The diff sheet's content, 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 {
GeometryReader { geo in
if geo.size.width >= twoPaneMinWidth {
SplitDiffView(diff: diff)
} else {
stacked(diff)
}
}
} else if loading {
ProgressView("Fetching diff…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
/// 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
}
}
}
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)
.hoverEffect(.highlight)
}
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"
case "renamed": "arrow.right.circle"
default: "pencil.circle"
}
}
static func color(_ status: String) -> Color {
switch status {
case "added", "untracked": Palette.success
case "deleted": Palette.danger
default: .secondary
}
}
}
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 {
let patch: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
.font(.caption.monospaced())
.foregroundStyle(lineColor(line))
.frame(maxWidth: .infinity, alignment: .leading)
.background(lineBackground(line))
}
}
.textSelection(.enabled)
.padding(8)
}
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
private func lineColor(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary }
if line.hasPrefix("+") { return Palette.success }
if line.hasPrefix("-") { return Palette.danger }
if line.hasPrefix("@@") { return Palette.active }
if line.hasPrefix("diff ") { return .secondary }
return Palette.primaryText
}
private func lineBackground(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear }
if line.hasPrefix("+") { return Palette.success.opacity(0.10) }
if line.hasPrefix("-") { return Palette.danger.opacity(0.10) }
return .clear
}
}
struct DiffSummaryView: View {
let diffStat: DiffStat?
var body: some View {
VStack(spacing: 12) {
if let diff = diffStat, diff.filesChanged > 0 {
Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
.font(.headline)
HStack(spacing: 16) {
Text("+\(diff.added)").foregroundStyle(.green)
Text("−\(diff.removed)").foregroundStyle(.red)
}
.font(.title3.monospacedDigit())
Text("Open the Mac for the full patch.")
.font(.footnote).foregroundStyle(.secondary)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}