sync: project quota, provider status, todo triage, and full diffs to the phone
Four Mac surfaces the iOS remote couldn't render now ride the wire, forward-compatibly (decodeIfPresent defaults; unknown HostMsg tags already decode to .unknown): - DashboardSnapshot gains `usage` (WireSubscriptionUsage — the Mac's 5-hour/weekly quota gauges) and `statusFeeds` (WireStatusFeed — active provider incidents); the host re-pushes the dashboard when a poll changes either. - WireTodo gains `triage` (raw TriageLevel name); the phone renders the Mac's TriageBadge honoring the encouraging/classic label setting. - ClientMsg.fetchDiff / HostMsg.sessionDiff deliver the full worktree patch on demand (capped at 512 KB with a truncated flag), gated on the new WireCapabilities.canFetchDiff so a new phone never sends it to an old host. iOS renders a file list + colored unified patch, falling back to the diffstat summary against older hosts. Round-trip and legacy-decode tests cover the new fields; the full suite passes apart from the pre-existing fake-backend fixture gaps and the flaky nvrsion lock-domain test (same failure rate on the base commit). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -6,7 +6,9 @@ import NucleicProtocol
|
||||
struct SessionDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let sessionID: SessionID
|
||||
@State private var tab = 0
|
||||
// Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for
|
||||
// offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
|
||||
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0
|
||||
@State private var draft = ""
|
||||
@State private var showRename = false
|
||||
@State private var renameDraft = ""
|
||||
@@ -33,7 +35,11 @@ struct SessionDetailView: View {
|
||||
|
||||
if tab == 0 {
|
||||
TranscriptList(events: store.openEvents)
|
||||
} else 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)
|
||||
}
|
||||
|
||||
@@ -304,6 +310,112 @@ 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`.
|
||||
struct SessionDiffView: View {
|
||||
let diff: WireSessionDiff?
|
||||
let loading: Bool
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else if loading {
|
||||
ProgressView("Fetching diff…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ContentUnavailableView("No changes yet", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.card()
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: String) -> String {
|
||||
switch status {
|
||||
case "added", "untracked": "plus.circle"
|
||||
case "deleted": "minus.circle"
|
||||
case "renamed": "arrow.right.circle"
|
||||
default: "pencil.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "added", "untracked": Palette.success
|
||||
case "deleted": Palette.danger
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user