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:
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
.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)
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
.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,26 +315,27 @@ 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…")
|
||||
.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) {
|
||||
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
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)
|
||||
}
|
||||
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: statusIcon(file.status))
|
||||
Image(systemName: DiffStatus.icon(file.status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor(file.status))
|
||||
.foregroundStyle(DiffStatus.color(file.status))
|
||||
Text(file.path)
|
||||
.font(.caption.monospaced())
|
||||
.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 {
|
||||
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