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
@@ -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 {