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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user