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 = []
|
||||
|
||||
Reference in New Issue
Block a user