diff --git a/app/application.ml b/app/application.ml index 303c3a6..ddfb7f1 100644 --- a/app/application.ml +++ b/app/application.ml @@ -312,6 +312,7 @@ type state = ; modal : modal ; confirmation_sequence : int64 ; environment : Journal_environment.snapshot + ; startup_presentation : Journal_startup.Presentation.t } let favorites_event state event = @@ -403,9 +404,19 @@ let initial_state = ; modal = No_modal ; confirmation_sequence = 0L ; environment = Journal_environment.fallback + ; startup_presentation = Journal_startup.Presentation.create () } ;; +let sync_startup_presentation state = + let startup_presentation = Journal_startup.Presentation.step state.startup_presentation + (Observe {manager=state.manager;graph=state.graph_state;graph_ready=state.graph_ready; + feed_loaded=state.feed_loaded;password_pending=state.e2ee_submission_pending; + graph_error=Option.is_some state.graph_error}) in + if startup_presentation == state.startup_presentation then state + else {state with startup_presentation} +;; + let current_composer_owner state = match state.modal with | Append_sheet -> @@ -1620,6 +1631,7 @@ type command = | Select_favorites | Favorites_retry | Refresh_catalog + | Startup_retry | Begin_online_recovery | Open_capture | Close_composer @@ -1957,6 +1969,7 @@ let media_label ~store ?(on_region = fun _ -> ()) ?(detail = false) + ?(known_images = []) state dispatch ~title @@ -1970,6 +1983,7 @@ let media_label ~scope ~title ~root + ~known_images ~on_event:(fun event -> dispatch.send (Media (scope, event))) child ;; @@ -2976,6 +2990,9 @@ let detail_page ~store:media_store ~on_region ~detail:true + ~known_images:(Option.fold ~none:[] + ~some:(fun file_type -> [Journal_model.id block, file_type]) + (Journal_model.asset_file_type block)) state dispatch ~title:(Journal_model.source block) @@ -3005,7 +3022,10 @@ let detail_page ~children:(rows detail) | None -> (match Journal_routes.route state.routes with - | Detail_loading -> V.loading ~centered:true ~message:"Loading block" () + | Detail_loading -> + if Journal_routes.loading_indicator state.routes + then V.loading ~centered:true ~message:"Loading block" () + else V.column [] | Missing_detail -> Presentation.unavailable ~title:"Block unavailable" @@ -3153,17 +3173,13 @@ let manager_page state dispatch = && Option.is_none snapshot.local_deletion) state.manager in - let busy ?value ?(allow_graph_selection = false) title message = - V.column - ~spacing:12. - [ V.progress ?value ~style:(if Option.is_some value then Linear else Circular) () - ; V.text title - ; V.text message - ; (if allow_graph_selection && can_choose_graph then choose_graph else V.empty ()) - ] - |> V.frame ~max_width:Fill ~max_height:Fill - |> V.padding ~insets:(Ui.Layout.Edge_insets.all 24.) - |> V.Body.static + let busy ?value ?(allow_graph_selection = false) _title message = + Journal_header.startup + ~loading:(Journal_startup.Presentation.loading_indicator state.startup_presentation) + ~initial:(Journal_startup.Presentation.initial_attempt state.startup_presentation) + ~active:(Journal_startup.Presentation.foreground state.startup_presentation) + ?value ~message + (if allow_graph_selection && can_choose_graph then choose_graph else V.empty ()) in let graph_picker snapshot = let refresh = @@ -3305,11 +3321,23 @@ let manager_page state dispatch = diagnostics ] in + let presentation = Journal_startup.Presentation.screen state.startup_presentation in let body = - match state.manager with - | None -> busy "Preparing your account" "" |> toolbar "Journals" [] - | Some snapshot -> - let startup = Journal_startup.derive ~snapshot ~graph:state.graph_state in + match presentation,state.manager with + | Account_failure,_ -> + unavailable ~title:"Unable to prepare your account" ~symbol:"person.crop.circle.badge.exclamationmark" + ~message:"Retry to check your account and open your journal." + ~actions:(capsule ~id:"journal-startup-retry" ~command:Startup_retry "Retry" "arrow.clockwise") + |> toolbar "Journals" [] + | Interaction {phase=Signed_out;_},None -> + unavailable ~title:"Sign in to open a graph" ~symbol:"person.crop.circle" + ~message:"Connect your account to access your graphs." ~actions:(V.empty ()) + |> toolbar "Journals" [] + | _,None -> busy "Loading journal" "" |> toolbar "" [] + | _,Some snapshot -> + let startup = match presentation with + | Interaction startup -> startup + | _ -> Journal_startup.derive ~snapshot ~graph:state.graph_state in (match startup.phase with | _ when state.e2ee_submission_pending -> unlock None | Awaiting_selection -> graph_picker snapshot @@ -3324,6 +3352,8 @@ let manager_page state dispatch = (Option.map (fun (error : Journal_startup.startup_error) -> error.message) startup.error) + | Signed_out when presentation=Waiting -> + busy ~allow_graph_selection:true "Loading journal" "" |> toolbar "" [] | Signed_out -> unavailable ~title:"Sign in to open a graph" @@ -3333,8 +3363,8 @@ let manager_page state dispatch = |> toolbar "Journals" [] | Loading_catalog -> busy ~allow_graph_selection:true "Loading your graphs" "" - |> toolbar "Journals" [] - | (Ready | Restoring_local) + |> toolbar "" [] + | _ when Option.is_some state.graph_error && state.graph_state.phase = Graph_open && state.graph_state.generation = snapshot.startup.graph_generation @@ -3354,10 +3384,10 @@ let manager_page state dispatch = |> V.Body.static |> toolbar "Journals" [] | Ready -> - busy ~allow_graph_selection:true "Opening journal" "" |> toolbar "Journals" [] + busy ~allow_graph_selection:true "Opening journal" "" |> toolbar "" [] | Restoring_local -> busy ~allow_graph_selection:true "Restoring your graph" "" - |> toolbar "Journals" [] + |> toolbar "" [] | Deleting_local -> let message = match snapshot.local_deletion with @@ -3367,7 +3397,7 @@ let manager_page state dispatch = "Clearing the saved selection" | None | Some (Deletion_failed _) -> "Deleting the local copy" in - busy message "" |> toolbar "Journals" [] + busy message "" |> toolbar "" [] | Bootstrapping -> let value, message = match state.bootstrap_progress with @@ -3386,7 +3416,7 @@ let manager_page state dispatch = value, Printf.sprintf "Downloaded %Ld bytes" progress.received_bytes in busy ?value ~allow_graph_selection:true "Downloading graph" message - |> toolbar "Journals" [] + |> toolbar "" [] | Failed -> let message, recovery = match startup.error with @@ -4661,10 +4691,14 @@ let start | Error _ -> Effect.ignore | Ok (Backgrounded _) -> calendar_foreground := false; - send_manager (Graph_service.Set_foreground false) + Effect.bind (set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Foreground false)})) + ~f:(fun () -> send_manager (Graph_service.Set_foreground false)) | Ok (Foreground_resumed _) -> calendar_foreground := true; - Effect.bind (sample_calendar ()) ~f:(function + Effect.bind (set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Foreground true)})) + ~f:(fun () -> Effect.bind (sample_calendar ()) ~f:(function | Error error -> Effect.many [ set_state (fun state -> @@ -4673,28 +4707,34 @@ let start ] | Ok calendar -> Effect.bind (install_calendar set_state calendar) ~f:(fun () -> - send_manager (Graph_service.Set_foreground true))) - in - let apply_authenticated_user payload = - match Journal_platform.decode_authenticated_user payload with - | Error _ -> Effect.ignore + send_manager (Graph_service.Set_foreground true)))) + in + let apply_authenticated_user ?ticket payload = + let key = Option.value ticket ~default:(Journal_startup.Presentation.account_ticket !state_ref.startup_presentation) in + if key <> Journal_startup.Presentation.account_ticket !state_ref.startup_presentation then Effect.ignore + else match Journal_platform.decode_authenticated_user payload with + | Error _ -> set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Account_error key)}) | Ok user_id -> - (match user_id with - | None -> sign_out_in_flight := true - | Some _ -> ()); - send_manager (Graph_service.Reconcile_authenticated_user { user_id }) + (match user_id with None -> sign_out_in_flight := true | Some _ -> ()); + Effect.bind (set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation + (Account(key,if user_id=None then No_user else User))})) + ~f:(fun () -> send_manager (Graph_service.Reconcile_authenticated_user { user_id })) in - let apply_local_account_binding result = + let apply_local_account_binding ticket result = match result with | Error _ -> Effect.ignore | Ok payload -> (match Journal_platform.decode_local_account_binding payload with - | Error _ | Ok None -> Effect.ignore + | Error _ | Ok None -> set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Account(ticket,No_local))}) | Ok (Some binding) -> if String.equal binding.managed_sync_origin !managed_sync_origin - then - send_manager - (Graph_service.Restore_local_account { user_id = binding.user_id }) + && ticket = Journal_startup.Presentation.account_ticket !state_ref.startup_presentation + then Effect.bind (set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Account(ticket,Local))})) + ~f:(fun () -> send_manager (Graph_service.Restore_local_account { user_id = binding.user_id })) else Effect.ignore) in let apply_platform payload = @@ -4712,12 +4752,15 @@ let start | Ok _ -> apply_network_lifecycle payload | Error _ -> apply_authenticated_user payload) in - let managed_startup = - platform_request Journal_platform.local_account_binding_request ~f:(fun binding -> - Effect.bind (apply_local_account_binding binding) ~f:(fun () -> + let managed_startup : unit Effect.t = fun () -> + let ticket = Journal_startup.Presentation.account_ticket !state_ref.startup_presentation in + Effect.run (platform_request Journal_platform.local_account_binding_request ~f:(fun binding -> + if ticket <> Journal_startup.Presentation.account_ticket !state_ref.startup_presentation then Effect.ignore + else Effect.bind (apply_local_account_binding ticket binding) ~f:(fun () -> platform_request Journal_platform.authenticated_user_request ~f:(function - | Error _ -> Effect.ignore - | Ok payload -> apply_authenticated_user payload))) + | Error _ -> set_state (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation (Account_error ticket)}) + | Ok payload -> apply_authenticated_user ~ticket payload)))) in let calendar_startup = Effect.bind (sample_calendar ()) ~f:(function @@ -4943,15 +4986,25 @@ let start if not state.graph_ready then None else - Option.bind (Journal_routes.detail state.routes) (fun detail -> - Option.map - (fun block_id -> Journal_detail.session_id detail, block_id) - (Journal_detail.next_automatic_load detail)) + match Journal_routes.initial_load state.routes with + | Some key -> Some (`Initial key) + | None -> + Option.bind (Journal_routes.detail state.routes) (fun detail -> + Option.map + (fun block_id -> `Visible (Journal_detail.session_id detail, block_id)) + (Journal_detail.next_automatic_load detail)) in let prev_detail_drain_key = ref (detail_drain_key initial_state) in let detail_drain_callback = function | None -> Effect.ignore - | Some (session, block_id) -> + | Some (`Initial key) -> + set_state_and_effect (fun state -> + if state.graph_ready && Journal_routes.initial_load state.routes = Some key + then + let routes, requests = Journal_routes.load_initial state.routes in + { state with routes }, Effect.many (List.map send requests) + else state, Effect.ignore) + | Some (`Visible (session, block_id)) -> set_state_and_effect (fun state -> match Journal_routes.detail state.routes with | Some detail when state.graph_ready && Journal_detail.session_id detail = session @@ -5065,6 +5118,30 @@ let start if !running then Journal_pump.enqueue pump thunk) ()) in + let startup_loading_key state = Journal_startup.Presentation.loading_request state.startup_presentation in + let prev_startup_loading_key = ref None in + let arm_startup_loading_timer key = + let started = Core.Time_ns.now () in + schedule_after 0.1 (fun () -> Effect.run (set_state (fun state -> + let elapsed = Core.Time_ns.(Span.to_sec (diff (now ()) started)) in + let next = Journal_startup.Presentation.step state.startup_presentation (Elapsed(key,elapsed)) in + if next == state.startup_presentation then state else {state with startup_presentation=next}))) + in + let detail_loading_key state = + state.graph_state.generation, Journal_routes.loading_requests state.routes + in + let prev_detail_loading_key = ref (detail_loading_key initial_state) in + let arm_detail_loading_timer graph_generation entry_id request_generation = + let started = Core.Time_ns.now () in + schedule_after 0.1 (fun () -> + Effect.run (set_state (fun state -> + if state.graph_state.generation <> graph_generation then state + else + let elapsed = Core.Time_ns.(Span.to_sec (diff (now ()) started)) in + let routes = Journal_routes.observe_loading_delay state.routes + ~entry_id ~request_generation ~elapsed in + if routes == state.routes then state else { state with routes }))) + in let arm_delete_timer mutation_id deadline = incr delete_timer_generation; let generation = !delete_timer_generation in @@ -5286,6 +5363,9 @@ let start | Native_payload (Ui.Event.Payload.Navigation_path_changed _) -> false | Asset_settings _ -> false | _ -> true -> Effect.ignore + | Startup_retry -> + Effect.bind (update (fun state -> {state with startup_presentation= + Journal_startup.Presentation.step state.startup_presentation Retry})) ~f:(fun () -> managed_startup) | Open_asset_settings -> update (fun state -> { state with asset_settings_open = true }) | Asset_settings text -> @@ -5732,7 +5812,8 @@ let start cancel_copy (); sign_out_in_flight := true; Effect.many - [ update (fun state -> Root_navigation.step state Account_cleared) + [ update (fun state -> let state=Root_navigation.step state Account_cleared in + {state with startup_presentation=Journal_startup.Presentation.step state.startup_presentation Signed_out}) ; send_manager (Graph_service.Reconcile_authenticated_user { user_id = None }) ] | Submit_e2ee_password -> @@ -6091,6 +6172,19 @@ let start then ( prev_timeline_drain_key := key; Effect.run (timeline_drain_callback key))); + (let key = startup_loading_key model in + if key <> !prev_startup_loading_key then ( + prev_startup_loading_key := key; + Option.iter arm_startup_loading_timer key)); + (let generation, requests = detail_loading_key model in + let previous_generation, previous_requests = !prev_detail_loading_key in + prev_detail_loading_key := generation, requests; + List.iter + (fun (entry_id, request_generation) -> + if generation <> previous_generation + || not (List.mem (entry_id, request_generation) previous_requests) + then arm_detail_loading_timer generation entry_id request_generation) + requests); (let key = detail_drain_key model in if not (!prev_detail_drain_key = key) then ( @@ -6172,7 +6266,7 @@ let start state_ref := model; Effect.run eff; state_ref := model; - let model = run_edge_callbacks model |> prepare_presentation in + let model = sync_startup_presentation model |> run_edge_callbacks |> prepare_presentation in state_ref := model; model | Platform_response (tag, result) -> @@ -6183,7 +6277,7 @@ let start | None -> ()); model | Environment_changed snapshot -> - prepare_presentation { model with environment = snapshot } + prepare_presentation (sync_startup_presentation { model with environment = snapshot }) in (* These subscriptions use complete presentation dependencies. They compare persistent owners rather than walking retained rows on every input event. *) @@ -6237,7 +6331,8 @@ let start if (not left.graph_ready) || not right.graph_ready then left == right else - left.graph_state.generation = right.graph_state.generation + Journal_startup.Presentation.equal_presentation left.startup_presentation right.startup_presentation + && left.graph_state.generation = right.graph_state.generation && left.environment.platform = right.environment.platform && Journal_routes.destination left.routes = Journal_routes.destination right.routes && same_actions left right @@ -6261,6 +6356,7 @@ let start let equal_detail_presentation left right = left.graph_state.generation = right.graph_state.generation && Journal_routes.route left.routes = Journal_routes.route right.routes + && Journal_routes.loading_indicator left.routes = Journal_routes.loading_indicator right.routes && Journal_routes.detail_request_generation left.routes = Journal_routes.detail_request_generation right.routes && Option.equal @@ -6469,9 +6565,9 @@ let start in let root = region "root" ~equal:equal_root_presentation (fun state -> - match state.graph_ready, state.manager with - | false, Some _ -> manager_page state dispatch - | false, None | true, _ -> + match Journal_startup.Presentation.screen state.startup_presentation with + | Waiting | Interaction _ | Account_failure -> manager_page state dispatch + | Timeline -> timeline_page ~header_signal:(Some header_signal) ~timeline_store:(Some timeline_store) @@ -6898,6 +6994,8 @@ let start view in app_cell := Some app; + prev_startup_loading_key := startup_loading_key initial_state; + Option.iter arm_startup_loading_timer !prev_startup_loading_key; let release_staging () = ignore (clear_account_drafts !state_ref); Queue.iter diff --git a/app/journal_detail.ml b/app/journal_detail.ml index f520385..005798f 100644 --- a/app/journal_detail.ml +++ b/app/journal_detail.ml @@ -67,6 +67,7 @@ type staged_delete = type event = | Observe_visible_range of int64 * int64 | Load_next + | Load_initial_next | Load_more of string | Loaded of int64 * Journal_graph_projection.detail | Load_failed of int64 * bool * string @@ -339,10 +340,43 @@ let snapshot (t : t) = let rows (t : t) = (snapshot t).values let read_pending (t : t) = (snapshot t).reading +let automatic_budget_available (t : t) = + t.automatic_reads < maximum_automatic_reads + && t.materialized_blocks + page_limit <= maximum_materialized_blocks +;; + +let next_unloaded_in t current_rows first last = + let rec find index = + if index >= last + then None + else ( + let id, depth = + match current_rows.indexed.(index) with + | Block { block; depth } -> Journal_model.id block, depth + | More { parent_id; depth; _ } -> parent_id, depth - 1 + in + let current = branch t id in + if depth < maximum_depth && needs_children current && current.error = None + then Some id + else find (index + 1)) + in + find first +;; + +let initial_staging (t : t) = + let current_rows = snapshot t in + if current_rows.reading + then `Waiting + else if not (automatic_budget_available t) + then `Ready + else + match next_unloaded_in t current_rows 0 (Array.length current_rows.indexed) with + | None -> `Ready + | Some id -> `Load id +;; + let next_automatic_load (t : t) = - if - t.automatic_reads >= maximum_automatic_reads - || t.materialized_blocks + page_limit > maximum_materialized_blocks + if not (automatic_budget_available t) then None else ( match t.visible_range with @@ -356,22 +390,7 @@ let next_automatic_load (t : t) = Int64.to_int (Int64.min (Int64.of_int (Array.length current_rows.indexed)) index) in - let last = bounded last in - let rec find index = - if index >= last - then None - else ( - let id, depth = - match current_rows.indexed.(index) with - | Block { block; depth } -> Journal_model.id block, depth - | More { parent_id; depth; _ } -> parent_id, depth - 1 - in - let current = branch t id in - if depth < maximum_depth && needs_children current && current.error = None - then Some id - else find (index + 1)) - in - find (bounded first))) + next_unloaded_in t current_rows (bounded first) (bounded last))) ;; let load ?(automatic = false) (t : t) id = @@ -423,6 +442,11 @@ let step (t : t) = function | None -> t, [] | Some id -> load ~automatic:true { t with automatic_reads = t.automatic_reads + 1 } id) + | Load_initial_next -> + (match initial_staging t with + | `Waiting | `Ready -> t, [] + | `Load id -> + load ~automatic:true { t with automatic_reads = t.automatic_reads + 1 } id) | Load_more id -> let next, requests = load t id in ( (if requests = [] @@ -643,6 +667,43 @@ let reconcile_children (t : t) (detail : Journal_graph_projection.detail) = }) ;; +let cancel_initial_reads (t : t) = + if not (Blocks.exists (fun _ (branch : branch) -> branch.pending <> None) t.branches) + then t + else + invalidate_rows + { t with + branches = + Blocks.map (fun (branch : branch) -> { branch with pending = None }) t.branches + } +;; + +let account_initial_update ~(previous : t) (t : t) = + let added = + if previous.blocks == t.blocks + then 0 + else + Blocks.fold + (fun id _ count -> if Blocks.mem id previous.blocks then count else count + 1) + t.blocks + 0 + in + let materialized_blocks = + max t.materialized_blocks (previous.materialized_blocks + added) + in + let automatic_reads = max t.automatic_reads previous.automatic_reads in + let t = + if materialized_blocks = t.materialized_blocks && automatic_reads = t.automatic_reads + then t + else { t with materialized_blocks; automatic_reads } + in + (* A shared update can consume a page already reserved by another branch. + Keep the spent attempt and expose explicit continuation instead. *) + if t.materialized_blocks + page_limit > maximum_materialized_blocks + then cancel_initial_reads t + else t +;; + let apply_child_created (t : t) ~child ~parent = if matching_child t.pending ~root_id:t.root_id ~child ~parent then ( @@ -682,7 +743,7 @@ let apply_child_created (t : t) ~child ~parent = else t ;; -let stage_delete (t : t) ~block_id = +let stage_delete ?(fence_reads = false) (t : t) ~block_id = match Blocks.find_opt block_id t.blocks with | None -> None | Some block -> @@ -702,6 +763,14 @@ let stage_delete (t : t) ~block_id = in let removed = Blocks.filter (fun id _ -> descendant [] id) t.blocks in let removed_branches = Blocks.filter (fun id _ -> Blocks.mem id removed) t.branches in + let removed_branches = + if fence_reads + then + Blocks.map + (fun (branch : branch) -> { branch with pending = None }) + removed_branches + else removed_branches + in let branches = Blocks.filter_map (fun id (branch : branch) -> diff --git a/app/journal_detail.mli b/app/journal_detail.mli index a6d7dc6..8e01cca 100644 --- a/app/journal_detail.mli +++ b/app/journal_detail.mli @@ -24,6 +24,7 @@ type row = type event = | Observe_visible_range of int64 * int64 | Load_next + | Load_initial_next | Load_more of string | Loaded of int64 * Journal_graph_projection.detail | Load_failed of int64 * bool * string @@ -54,6 +55,12 @@ val row_key : row -> string failed reads remain explicit Load more actions. *) val next_automatic_load : t -> string option +(** Initial body discovery uses the same depth, complete-page reservation and + attempt budgets as visible demand. It reads only unknown first pages of + materialized branches; continuation cursors remain explicit. Publication + must preserve these counters rather than starting another automatic round. *) +val initial_staging : t -> [ `Waiting | `Ready | `Load of string ] + val step : t -> event -> t * Journal_graph_request.t list val mode : t -> mode val session_id : t -> ID.Text_input.session_id @@ -89,8 +96,19 @@ val admit_child val apply_block : t -> Journal_model.t -> t val reconcile_children : t -> Journal_graph_projection.detail -> t + +(** Charge newly materialized shared updates to the still-hidden initial owner. + Existing nodes are not charged twice; consumed counters never decrease. *) +val account_initial_update : previous:t -> t -> t + +(** Fence cancelled initial completions without refunding budgets or reusing + their generations. Restored branches can admit fresh bounded reads. *) +val cancel_initial_reads : t -> t val apply_child_created : t -> child:Journal_model.t -> parent:Journal_model.t -> t -val stage_delete : t -> block_id:string -> (t * staged_delete) option + +(** With [fence_reads], initial deletion fences only removed branch tickets, including in + Undo state; unrelated pending reads continue without a duplicate request. *) +val stage_delete : ?fence_reads:bool -> t -> block_id:string -> (t * staged_delete) option val undo_delete : t -> staged_delete -> t (** Only composer ownership is retained; reopening loads a fresh outline projection. *) diff --git a/app/journal_header.ml b/app/journal_header.ml index 90e4754..f4524b9 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -56,6 +56,15 @@ let date_header ~title = () ;; +let startup ~loading ~initial ~active ?value ~message action = + Ui.Native_widget.widget chrome + ~key:(Ui.Key.string "journal-startup-surface") + ~props:(`Assoc (["mode",`String "startup";"loading",`Bool loading; + "initial",`Bool initial;"active",`Bool active;"message",`String message] + @ Option.to_list (Option.map (fun value -> "value",`Float value) value))) + ~on_event:(fun _ -> ()) ~children:[V.column [action]] () |> V.Body.static +;; + let capture_control ~enabled ~on_capture = V.buttons ~actions: diff --git a/app/journal_header.mli b/app/journal_header.mli index 4ac5ef2..37914d1 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -78,3 +78,7 @@ val detail -> copy_feedback:string option -> Journal_view.View.Body.t -> Journal_view.View.Body.t + +(** Stable native startup surface; action remains reachable during waiting. *) +val startup : loading:bool -> initial:bool -> active:bool -> ?value:float + -> message:string -> Journal_view.View.t -> Journal_view.View.Body.t diff --git a/app/journal_media.ml b/app/journal_media.ml index b3cb433..b82d379 100644 --- a/app/journal_media.ml +++ b/app/journal_media.ml @@ -212,7 +212,7 @@ let status = function | Waiting_remote -> Placeholder "Waiting for upload" | Waiting_network -> Placeholder "Available when online" | Waiting_unlock -> Placeholder "Unlock graph to view file" - | Ready _ -> Placeholder "Opening file" + | Ready _ -> Placeholder "Loading" | Failed { failure; _ } -> Failed (match failure with @@ -287,7 +287,7 @@ let step t = function ; available = Some (scope, handle) ; lease = None ; serial = ticket.id - ; status = Placeholder "Opening file" + ; status = Placeholder "Loading" } , release_file t @ [ Acquire ticket ] )) | _, _ -> @@ -329,7 +329,7 @@ let step t = function pending = Some ticket ; lease = None ; serial = ticket.id - ; status = Placeholder "Opening file" + ; status = Placeholder "Loading" } , release_file t @ [ Acquire ticket ] ) | None -> diff --git a/app/journal_routes.ml b/app/journal_routes.ml index bbb4884..160a77f 100644 --- a/app/journal_routes.ml +++ b/app/journal_routes.ml @@ -22,6 +22,8 @@ type view = ; request_generation : int64 ; session_number : int64 ; draft_owner : string option + ; loading_indicator : bool + ; staged_detail : Journal_detail.t option } | Detail_view of { block_id : string @@ -32,10 +34,12 @@ type view = { block_id : string ; request_generation : int64 ; message : string + ; draft_owner : string option } | Missing_detail_view of { block_id : string ; request_generation : int64 + ; draft_owner : string option } type detached = @@ -102,6 +106,35 @@ let view_route = function let route t = view_route (current_view t) +let loading_indicator t = + match current_view t with + | Detail_loading_view owner -> owner.loading_indicator + | _ -> false +;; + +let loading_requests t = + Owners.fold + (fun entry_id view requests -> + match view with + | Detail_loading_view owner when not owner.loading_indicator -> + (entry_id, owner.request_generation) :: requests + | _ -> requests) + t.owners + [] +;; + +let observe_loading_delay t ~entry_id ~request_generation ~elapsed = + match Owners.find_opt entry_id t.owners with + | Some (Detail_loading_view owner) + when owner.request_generation = request_generation + && elapsed >= 0.1 && not owner.loading_indicator -> + { t with + owners = Owners.add entry_id + (Detail_loading_view { owner with loading_indicator = true }) t.owners + } + | _ -> t +;; + let view_block_id = function | Timeline_view -> None | Detail_loading_view { block_id; _ } @@ -121,6 +154,15 @@ let view_generation = function let detail_block_id t = view_block_id (current_view t) let detail_request_generation t = view_generation (current_view t) +let initial_load t = + match current_view t with + | Detail_loading_view { staged_detail = Some detail; _ } -> + (match Journal_detail.initial_staging detail with + | `Load id -> Some (Journal_detail.session_id detail, id) + | `Waiting | `Ready -> None) + | _ -> None +;; + let detail t = match current_view t with | Detail_view { detail; _ } -> Some detail @@ -345,7 +387,9 @@ let open_detail t ~block_id ~request_generation = ; owners = Owners.add owner_id - (Detail_loading_view { block_id; request_generation; session_number; draft_owner }) + (Detail_loading_view { block_id; request_generation; session_number; draft_owner + ; loading_indicator = false; staged_detail = None + }) t.owners } ;; @@ -365,15 +409,63 @@ let open_favorite { block_id; after = None; limit = 128; request_generation }) ) ;; +let settle_initial t ~entry_id = + match Owners.find_opt entry_id t.owners with + | Some (Detail_loading_view ({ staged_detail = Some detail; _ } as loading)) + when Journal_detail.initial_staging detail = `Ready -> + let draft = + Option.bind loading.draft_owner (fun id -> Drafts.find_opt id t.drafts) + in + let detail = + match draft with + | None -> detail + | Some draft -> Journal_detail.restore_composer detail draft.composer + in + let t = track_detail_session t detail in + { t with + drafts = + (match loading.draft_owner with + | None -> t.drafts + | Some id -> Drafts.remove id t.drafts) + ; owners = + Owners.add entry_id + (Detail_view + { block_id = loading.block_id + ; request_generation = loading.request_generation + ; detail + }) + t.owners + } + | _ -> t +;; + let update_detail_at t ~entry_id detail = match Owners.find_opt entry_id t.owners with | Some (Detail_view owner) when owner.detail == detail -> t | Some (Detail_view owner) -> let t = track_detail_session t detail in { t with owners = Owners.add entry_id (Detail_view { owner with detail }) t.owners } + | Some (Detail_loading_view ({ staged_detail = Some old; _ } as owner)) -> + if old == detail + then t + else + settle_initial + { t with + owners = Owners.add entry_id + (Detail_loading_view { owner with staged_detail = Some detail }) t.owners + } + ~entry_id | _ -> t ;; +let load_initial t = + match active_entry_id t, current_view t with + | Some entry_id, Detail_loading_view { staged_detail = Some detail; _ } -> + let next, requests = Journal_detail.step detail Load_initial_next in + update_detail_at t ~entry_id next, requests + | _ -> t, [] +;; + let update_detail t detail = match active_entry_id t with | None -> t @@ -385,6 +477,9 @@ let map_details t ~f = (fun entry_id view t -> match view with | Detail_view { detail; _ } -> update_detail_at t ~entry_id (f detail) + | Detail_loading_view { staged_detail = Some detail; _ } -> + let next = Journal_detail.account_initial_update ~previous:detail (f detail) in + update_detail_at t ~entry_id next | _ -> t) t.owners t @@ -394,33 +489,24 @@ let apply_detail_response t ~request_generation projection = Owners.fold (fun entry_id view t -> match view with - | Detail_loading_view loading + | Detail_loading_view ({ staged_detail = None; _ } as loading) when loading.request_generation = request_generation && loading.block_id = Journal_model.id projection.Journal_graph_projection.root -> let detail = Journal_detail.create ~session_number:loading.session_number projection in - let draft = - Option.bind loading.draft_owner (fun id -> Drafts.find_opt id t.drafts) - in - let detail = - match draft with - | None -> detail - | Some draft -> Journal_detail.restore_composer detail draft.composer + settle_initial + { t with + owners = Owners.add entry_id + (Detail_loading_view { loading with staged_detail = Some detail }) t.owners + } + ~entry_id + | Detail_loading_view { staged_detail = Some detail; _ } -> + let next, _ = + Journal_detail.step detail (Loaded (request_generation, projection)) in - let t = track_detail_session t detail in - { t with - drafts = - (match loading.draft_owner with - | None -> t.drafts - | Some id -> Drafts.remove id t.drafts) - ; owners = - Owners.add - entry_id - (Detail_view { block_id = loading.block_id; request_generation; detail }) - t.owners - } + update_detail_at t ~entry_id next | Detail_view { detail; _ } -> let next, _ = Journal_detail.step detail (Loaded (request_generation, projection)) @@ -442,17 +528,38 @@ let apply_detail_failure Owners.fold (fun entry_id view t -> match view with - | Detail_loading_view loading + | Detail_loading_view ({ staged_detail = None; _ } as loading) when loading.request_generation = request_generation && (block_id = None || block_id = Some loading.block_id) -> let view = if missing - then Missing_detail_view { block_id = loading.block_id; request_generation } + then Missing_detail_view { block_id = loading.block_id; request_generation + ; draft_owner = loading.draft_owner } else Failed_detail_view - { block_id = loading.block_id; request_generation; message } + { block_id = loading.block_id; request_generation; message + ; draft_owner = loading.draft_owner + } in { t with owners = Owners.add entry_id view t.owners } + | Detail_loading_view ({ staged_detail = Some detail; _ } as loading) -> + let next, _ = + Journal_detail.step detail + (Load_failed (request_generation, stale_cursor, message)) + in + if next == detail + then t + else + { t with + owners = Owners.add entry_id + (Failed_detail_view + { block_id = loading.block_id + ; request_generation = loading.request_generation + ; message + ; draft_owner = loading.draft_owner + }) + t.owners + } | Detail_view { detail; _ } -> let next, _ = Journal_detail.step @@ -551,6 +658,8 @@ let retry_detail_at t ~entry_id ~request_generation = else ( match view with | Detail_loading_view loading -> loading.draft_owner + | Failed_detail_view failed -> failed.draft_owner + | Missing_detail_view missing -> missing.draft_owner | _ -> None) in ( { t with @@ -558,7 +667,9 @@ let retry_detail_at t ~entry_id ~request_generation = Owners.add entry_id (Detail_loading_view - { block_id; request_generation; session_number; draft_owner }) + { block_id; request_generation; session_number; draft_owner + ; loading_indicator = false; staged_detail = None + }) t.owners ; next_session = Int64.succ session_number ; focus = None @@ -595,7 +706,7 @@ let runtime_replaced t = t ;; -type staged_delete = (string * int64 * view * Journal_detail.staged_delete) list +type staged_delete = (string * int64 * view * string * Journal_detail.staged_delete) list let stage_delete t ~block_id = (* A missing root no longer carries its composer. Preserve it before changing @@ -625,11 +736,44 @@ let stage_delete t ~block_id = Missing_detail_view { block_id = owner.block_id ; request_generation = owner.request_generation + ; draft_owner = + (if Drafts.mem entry_id t.drafts then Some entry_id else None) } else Detail_view { owner with detail = hidden } in ( { t with owners = Owners.add entry_id next t.owners } - , (entry_id, owner.request_generation, view, undo) :: staged )) + , (entry_id, owner.request_generation, view, block_id, undo) :: staged )) + | Detail_loading_view ({ staged_detail = Some detail; _ } as owner) -> + let root_removed = Journal_model.id (Journal_detail.root detail) = block_id in + (match Journal_detail.stage_delete ~fence_reads:true detail ~block_id with + | None -> t, staged + | Some (hidden, undo) -> + let before = + if root_removed + then + Detail_loading_view + { owner with + staged_detail = Some (Journal_detail.cancel_initial_reads detail) + } + else view + in + let next = + if root_removed + then + { t with + owners = + Owners.add + entry_id + (Missing_detail_view + { block_id = owner.block_id + ; request_generation = owner.request_generation + ; draft_owner = owner.draft_owner + }) + t.owners + } + else update_detail_at t ~entry_id hidden + in + next, (entry_id, owner.request_generation, before, block_id, undo) :: staged) | _ -> t, staged) t.owners (t, []) @@ -639,19 +783,30 @@ let stage_delete t ~block_id = let undo_delete t staged = List.fold_left - (fun t (entry_id, generation, before, undo) -> + (fun t (entry_id, generation, before, deleted_id, undo) -> match Owners.find_opt entry_id t.owners with - | Some (Detail_view { detail; _ }) as current + | ( Some (Detail_view { detail; _ }) + | Some (Detail_loading_view { staged_detail = Some detail; _ }) ) as current when Option.fold ~none:false ~some:(fun view -> view_generation view = generation) current -> update_detail_at t ~entry_id (Journal_detail.undo_delete detail undo) - | Some (Missing_detail_view owner) when owner.request_generation = generation -> - { t with - owners = Owners.add entry_id before t.owners - ; drafts = Drafts.remove entry_id t.drafts - } + | Some (Missing_detail_view owner) + when owner.request_generation = generation + && + match before with + | Detail_loading_view _ -> owner.block_id = deleted_id + | _ -> true -> + settle_initial + { t with + owners = Owners.add entry_id before t.owners + ; drafts = + (match before with + | Detail_loading_view _ -> t.drafts + | _ -> Drafts.remove entry_id t.drafts) + } + ~entry_id | _ -> t) t staged diff --git a/app/journal_routes.mli b/app/journal_routes.mli index d785baf..f87f7a6 100644 --- a/app/journal_routes.mli +++ b/app/journal_routes.mli @@ -22,6 +22,13 @@ val open_favorite val destination : t -> destination val select_destination : t -> destination -> t val route : t -> route + +(** Initial body progress becomes visible only while the same request remains + pending after its grace period. Content completion has no minimum delay. *) +val loading_indicator : t -> bool +val loading_requests : t -> (string * int64) list +val observe_loading_delay + : t -> entry_id:string -> request_generation:int64 -> elapsed:float -> t val open_detail : t -> block_id:string -> request_generation:int64 -> t val detail_block_id : t -> string option val detail_request_generation : t -> int64 @@ -71,6 +78,12 @@ val apply_detail_failure -> message:string -> t +(** A pending route keeps all initial non-asset content unpublished while + bounded first-page discovery runs. Query/admit only the current owner; + covered completions retain their own route identity. *) +val initial_load : t -> (Journal_ids.Text_input.session_id * string) option +val load_initial : t -> t * Journal_graph_request.t list + val detail : t -> Journal_detail.t option val update_detail : t -> Journal_detail.t -> t val apply_child_created : t -> child:Journal_model.t -> parent:Journal_model.t -> t diff --git a/app/journal_startup.ml b/app/journal_startup.ml index 809ab1a..ceaf3bd 100644 --- a/app/journal_startup.ml +++ b/app/journal_startup.ml @@ -92,7 +92,12 @@ let derive ~snapshot ~(graph : Logseq_db_worker.graph_state) = else if ready then { phase = Ready; error = None } else if not facts.authenticated - then { phase = Signed_out; error = None } + then + (match facts.failure with + | Some stage -> { phase = Failed; error = Some (startup_error stage + (Option.value snapshot.last_error ~default:"Startup failed.")) } + | None when facts.restoring_local -> { phase = Restoring_local; error = None } + | None -> { phase = Signed_out; error = None }) else if graph_is_current && graph.phase = Graph_failed then { phase = Failed @@ -168,3 +173,120 @@ let decode bytes = with | Yojson.Json_error _ -> error "startup configuration must be valid UTF-8 JSON")) ;; + +module Presentation = struct + type ticket = int64 + type observation = + { manager : Graph_service.snapshot option + ; graph : Logseq_db_worker.graph_state + ; graph_ready : bool + ; feed_loaded : bool + ; password_pending : bool + ; graph_error : bool + } + type account_result = Local | No_local | User | No_user + type screen = Waiting | Timeline | Interaction of startup_state | Account_failure + type account = Discovering | Local_available | User_available | User_required + type t = + { screen : screen + ; serial : int64 + ; account_serial : int64 + ; indicator : bool + ; foreground : bool + ; account : account + ; local_available : bool + ; account_failed : bool + ; observed : observation option + } + type event = + | Observe of observation + | Account of ticket * account_result + | Account_error of ticket + | Retry + | Signed_out + | Foreground of bool + | Elapsed of ticket * float + + let create () = + { screen = Waiting; serial = 0L; account_serial = 0L; indicator = false + ; foreground = true; account = Discovering; local_available = false + ; account_failed = false; observed = None } + let foreground t = t.foreground + let screen t = t.screen + let loading_indicator t = t.foreground && t.screen = Waiting && t.indicator + let loading_request t = + if t.foreground && t.screen = Waiting && not t.indicator then Some t.serial else None + let account_ticket t = t.account_serial + let initial_attempt t = t.serial = 0L + let equal_presentation left right = + left.screen = right.screen + && (left.screen <> Waiting || + (loading_indicator left = loading_indicator right + && initial_attempt left = initial_attempt right + && left.foreground = right.foreground)) + + let required phase = Interaction {phase;error=None} + let classify t = + if t.account = User_required then required Signed_out + else match t.observed with + | Some o when o.password_pending -> required Awaiting_e2ee_password + | Some o -> + (match o.manager with + | Some manager when o.graph_ready && o.feed_loaded && not o.graph_error + && manager.local_deletion = None && manager.startup.authenticated + && o.graph.phase = Logseq_db_worker.Graph_open && graph_matches manager o.graph -> Timeline + | Some manager -> + let startup = derive ~snapshot:manager ~graph:o.graph in + (match startup.phase with + | Awaiting_selection | Awaiting_e2ee_password | Failed -> Interaction startup + | _ when o.graph_error -> required Failed + | _ when t.account_failed && not t.local_available -> Account_failure + | _ -> Waiting) + | None -> if t.account_failed && not t.local_available then Account_failure else Waiting) + | None -> if t.account_failed && not t.local_available then Account_failure else Waiting + + let project t = + let next = classify t in + if next = t.screen then t + else if next = Waiting then + {t with screen=next;serial=Int64.succ t.serial;indicator=false} + else {t with screen=next;indicator=false} + + let step t event = + let next = match event with + | Observe o -> + let previous = Option.bind t.observed (fun o -> o.manager) in + let changed_account = match previous,o.manager with + | Some previous,Some next -> previous.startup.account_generation <> 0 + && previous.startup.account_generation <> next.startup.account_generation + | _ -> false in + let account = match o.manager with + | Some manager when changed_account && not manager.startup.authenticated + && not manager.startup.restoring_local -> User_required + | Some manager when manager.startup.authenticated && t.account <> User_required -> User_available + | _ -> t.account in + project {t with observed=Some o;account; + account_serial=(if changed_account then Int64.succ t.account_serial else t.account_serial)} + | Account (key,result) when key=t.account_serial -> + let account,local_available=match result with + | Local -> Local_available,true + | No_local -> (if t.account=Local_available then Discovering else t.account),false + | User -> User_available,t.local_available + | No_user -> User_required,false in + project {t with account;local_available;account_failed=false} + | Account_error key when key=t.account_serial -> project {t with account_failed=true} + | Retry -> + {t with screen=Waiting;serial=Int64.succ t.serial;account_serial=Int64.succ t.account_serial; + indicator=false;account=Discovering;local_available=false;account_failed=false;observed=None} + | Signed_out -> + {t with screen=required Signed_out;indicator=false;account=User_required; + account_serial=Int64.succ t.account_serial;local_available=false;account_failed=false} + | Foreground active when active<>t.foreground -> + {t with foreground=active;indicator=false; + serial=(if active && t.screen=Waiting then Int64.succ t.serial else t.serial)} + | Elapsed (key,elapsed) when key=t.serial && t.foreground && t.screen=Waiting + && elapsed>=0.1 && not t.indicator -> {t with indicator=true} + | Account _ | Account_error _ | Foreground _ | Elapsed _ -> t + in + if next=t then t else next +end diff --git a/app/journal_startup.mli b/app/journal_startup.mli index b9f7d7c..2906b5c 100644 --- a/app/journal_startup.mli +++ b/app/journal_startup.mli @@ -55,3 +55,36 @@ type t = Logseq_db_worker.Config.t val encode : t -> (bytes, Error.t) result val decode : bytes -> (t, Error.t) result + +(** Own the initial root presentation and its delayed progress lifetime. *) +module Presentation : sig + type ticket = private int64 + type observation = + { manager : Logseq_db_worker_lui.Logseq_db_worker_lui_service.snapshot option + ; graph : Logseq_db_worker.graph_state + ; graph_ready : bool + ; feed_loaded : bool + ; password_pending : bool + ; graph_error : bool + } + type account_result = Local | No_local | User | No_user + type screen = Waiting | Timeline | Interaction of startup_state | Account_failure + type t + type event = + | Observe of observation + | Account of ticket * account_result + | Account_error of ticket + | Retry + | Signed_out + | Foreground of bool + | Elapsed of ticket * float + val create : unit -> t + val foreground : t -> bool + val screen : t -> screen + val loading_indicator : t -> bool + val loading_request : t -> ticket option + val account_ticket : t -> ticket + val initial_attempt : t -> bool + val equal_presentation : t -> t -> bool + val step : t -> event -> t +end diff --git a/apple-tests/startup-layout/JournalStartupLayoutFixture.swift b/apple-tests/startup-layout/JournalStartupLayoutFixture.swift new file mode 100644 index 0000000..66cf793 --- /dev/null +++ b/apple-tests/startup-layout/JournalStartupLayoutFixture.swift @@ -0,0 +1,83 @@ +import LUIAppleBackend +import Observation +import SwiftUI + +@MainActor @Observable final class LayoutFixtureState { + let backend: LUIAppleBackend + var embedded = false + var actionShown = false + var recovered = false + private var generation = 1 + + init() { + backend = try! LUIAppleBackend(extensionRegistry: JournalExtensions.registry()) + let data = try! Data(contentsOf: Bundle.main.url(forResource: "startup-patch", withExtension: "json")!) + var patch = try! JSONSerialization.jsonObject(with: data) as! [String: Any] + var ops = patch["ops"] as! [[String: Any]] + for i in ops.indices where ops[i]["op"] as? String == "set-extension-prop" + && ops[i]["id"] as? Int == 25 { + ops[i]["value"] = "{\"mode\":\"startup\",\"loading\":true,\"initial\":false,\"active\":true,\"message\":\"\"}" + } + patch["ops"] = ops + let encoded = try! JSONSerialization.data(withJSONObject: patch) + try! backend.apply(json: String(decoding: encoded, as: UTF8.self)) + backend.onEvent = { [weak self] event in + if case .press(node: 3000) = event { self?.recovered = true } + } + } + + func toggleAction() { + let ops: [[String: Any]] + if actionShown { + ops = [["op": "remove-child", "parent": 26, "child": 3000], + ["op": "detach-subtree", "id": 3000]] + } else { + ops = [["op": "create-node", "id": 3000, "kind": "button"], + ["op": "set-prop", "id": 3000, "property": "text", "value": "Choose another graph"], + ["op": "set-prop", "id": 3000, "property": "accessibility-identifier", "value": "qa-choose-graph"], + ["op": "insert-child", "parent": 26, "child": 3000, "index": 0]] + } + generation += 1 + let data = try! JSONSerialization.data(withJSONObject: ["generation": generation, "ops": ops]) + try! backend.apply(json: String(decoding: data, as: UTF8.self)) + actionShown.toggle() + } +} + +@MainActor struct LayoutFixtureRoot: View { + @State private var state = LayoutFixtureState() + private let width = CGFloat(Int(ProcessInfo.processInfo.environment["STARTUP_LAYOUT_WIDTH"] ?? "390")!) + private let font: DynamicTypeSize = ProcessInfo.processInfo.environment["STARTUP_LAYOUT_FONT"] == "accessibility3" ? .accessibility3 : .large + + var body: some View { + ZStack { + GeometryReader { container in + Group { + if state.embedded { + LUISwiftUIRoot(backend: state.backend, rootID: 1) + .environment(\.journalContainerTopInset, container.safeAreaInsets.top) + .environment(\.journalStartupContainerFrame, container.frame(in: .global)) + } else { + JournalStartupLoadingSurface(loading: true, initial: false, active: true) + } + } + }.frame(width: width) + } + .font(.body) + .environment(\.dynamicTypeSize, font) + .overlay(alignment: .topLeading) { + VStack(alignment: .leading) { + HStack { + Button("Runtime") { state.embedded = true }.accessibilityIdentifier("qa-runtime") + Button("Action") { state.toggleAction() }.accessibilityIdentifier("qa-action") + }.buttonStyle(.bordered).controlSize(.small) + if state.embedded { Text("Runtime active").accessibilityIdentifier("qa-runtime-state") } + if state.recovered { Text("Recovery invoked").accessibilityIdentifier("qa-recovery-invoked") } + }.padding(4) + } + } +} + +@main struct StartupLayoutFixture: App { + var body: some Scene { WindowGroup { LayoutFixtureRoot() } } +} diff --git a/apple-tests/startup-layout/JournalStartupLayoutTests.swift b/apple-tests/startup-layout/JournalStartupLayoutTests.swift new file mode 100644 index 0000000..e9e7c28 --- /dev/null +++ b/apple-tests/startup-layout/JournalStartupLayoutTests.swift @@ -0,0 +1,76 @@ +import XCTest +import UIKit + +/// Exercises the production loading surface and extension factory through a +/// disposable native fixture. The defect is SwiftUI layout, not OCaml state: +/// the fixture uses accepted public patches and never starts a graph/runtime. +final class JournalStartupLayoutTests: XCTestCase { + let bundle = "org.logseq.journal.startup-layout-20261012" + + @MainActor private func record(_ phase: String, _ app: XCUIApplication) { + let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + image.name = phase + image.lifetime = .keepAlways + add(image) + let tree = XCTAttachment(string: app.debugDescription) + tree.name = phase + " AX" + tree.lifetime = .keepAlways + add(tree) + } + + @MainActor private func loadingFrame(_ app: XCUIApplication) -> CGRect { + let label = app.staticTexts["Loading journal"].firstMatch + XCTAssertTrue(label.waitForExistence(timeout: 5)) + return label.frame + } + + @MainActor private func assertSameFrame(_ actual: CGRect, _ expected: CGRect) { + XCTAssertEqual(actual.midX, expected.midX, accuracy: 1) + XCTAssertEqual(actual.midY, expected.midY, accuracy: 1) + XCTAssertEqual(actual.width, expected.width, accuracy: 1) + XCTAssertEqual(actual.height, expected.height, accuracy: 1) + } + + @MainActor private func probe(width: Int, font: String) { + continueAfterFailure = true + let app = XCUIApplication(bundleIdentifier: bundle) + app.launchEnvironment = ["STARTUP_LAYOUT_WIDTH": String(width), + "STARTUP_LAYOUT_FONT": font] + app.launch() + defer { app.terminate() } + let original = loadingFrame(app) + XCTAssertGreaterThan(original.height, 0) + XCTAssertLessThanOrEqual(original.width, CGFloat(width - 48) + 1) + record("Native preparation \(width) \(font)", app) + + app.buttons["qa-runtime"].tap() + XCTAssertTrue(app.staticTexts["qa-runtime-state"].waitForExistence(timeout: 5)) + assertSameFrame(loadingFrame(app), original) + record("LUI takeover \(width) \(font)", app) + + app.buttons["qa-action"].tap() + let recovery = app.buttons["Choose another graph"].firstMatch + XCTAssertTrue(recovery.waitForExistence(timeout: 5)) + XCTAssertTrue(recovery.isEnabled) + XCTAssertTrue(recovery.isHittable) + assertSameFrame(loadingFrame(app), original) + record("Recovery action visible \(width) \(font)", app) + recovery.tap() + XCTAssertTrue(app.staticTexts["qa-recovery-invoked"].waitForExistence(timeout: 5)) + + app.buttons["qa-action"].tap() + XCTAssertFalse(recovery.exists) + assertSameFrame(loadingFrame(app), original) + record("Recovery action removed \(width) \(font)", app) + } + + @MainActor func testHandoffAt390() { probe(width: 390, font: "large") } + @MainActor func testHandoffAt320() { probe(width: 320, font: "large") } + @MainActor func testNarrowHandoffAtAccessibility3() { + probe(width: 320, font: "accessibility3") + } + + @MainActor func testHandoffAtAccessibility3() { + probe(width: 390, font: "accessibility3") + } +} diff --git a/apple-tests/startup-layout/README.md b/apple-tests/startup-layout/README.md new file mode 100644 index 0000000..4d9d0d7 --- /dev/null +++ b/apple-tests/startup-layout/README.md @@ -0,0 +1,25 @@ +# Startup canvas regression + +This is a native layout boundary test. The public OCaml startup owner supplies +valid states and patches, but cannot execute SwiftUI safe-area proposals or +recovery-button geometry. Do not duplicate the frame regression in the reducer. + +Build `JournalStartupLayoutFixture.swift` as the disposable host entry point with +the production `swift/` sources (excluding `App.swift`), the declared LUI Apple +SDK, and the existing native linking object. Bundle `startup-patch.json` as a +resource. It is the accepted first public patch from the synthetic startup host +at Journal `6d4fc15763bd2f98f29384b45d8eb2f7d71e1a13`, with no personal graph. +The fixture never starts JournalRuntime or a worker. It applies public patches +through the production extension registry and verifies the actual recovery event. + +Use host bundle ID `org.logseq.journal.startup-layout-20261012`. Add +`JournalStartupLayoutTests.swift` to an XCTest UI target and run the four methods +on an isolated iPhone 13 / iOS 26.1 Simulator. Keep generated hosts, runner +projects, build caches, result bundles, screenshots and logs outside the repository. + +The tests compare the actual text frame through native preparation, LUI navigation +handoff, action insertion and removal at 390/320 points with normal and +accessibility3 text. Every phase retains its raw screenshot and accessibility tree. +Recovery must remain enabled, hittable, and deliver the public press event. +The fixture uses the same root GeometryReader environment as JournalRuntimeHost; +it does not duplicate the production surface or extension layout implementation. diff --git a/apple-tests/startup-layout/startup-patch.json b/apple-tests/startup-layout/startup-patch.json new file mode 100644 index 0000000..fcc5b6c --- /dev/null +++ b/apple-tests/startup-layout/startup-patch.json @@ -0,0 +1 @@ +{"generation":1,"ops":[{"op":"create-node","id":1,"kind":"column"},{"op":"create-node","id":2,"kind":"stack"},{"op":"create-extension","id":3,"identifier":"navigation-stack","fingerprint":"lui-extension-v1|16:navigation-stack|profiles:ios/swiftui,macos/swiftui|standard-children:0|children:15:navigation-page|properties:4:path:string:required:none,8:revision:int:required:none|events:12:path-changed[6:length:int:required,8:revision:int:required],7:settled[6:length:int:required,8:revision:int:required]"},{"op":"insert-child","parent":2,"child":3,"index":0},{"op":"create-extension","id":4,"identifier":"navigation-page","fingerprint":"lui-extension-v1|15:navigation-page|profiles:ios/swiftui,macos/swiftui|standard-children:1|children:|properties:8:entry-id:string:required:none|events:"},{"op":"set-extension-prop","id":4,"property":"entry-id","value":"root"},{"op":"create-node","id":5,"kind":"box"},{"op":"set-prop","id":5,"property":"grow","value":1.0},{"op":"insert-child","parent":4,"child":5,"index":0},{"op":"create-node","id":6,"kind":"column"},{"op":"set-prop","id":6,"property":"grow","value":1.0},{"op":"insert-child","parent":5,"child":6,"index":0},{"op":"create-node","id":7,"kind":"stack"},{"op":"insert-child","parent":6,"child":7,"index":0},{"op":"create-node","id":8,"kind":"column"},{"op":"set-prop","id":8,"property":"gap","value":16},{"op":"set-prop","id":8,"property":"cross","value":"center"},{"op":"create-node","id":9,"kind":"edge-inset"},{"op":"set-prop","id":9,"property":"gap","value":0},{"op":"set-prop","id":9,"property":"grow","value":1.0},{"op":"set-prop","id":9,"property":"background","value":"bar"},{"op":"set-prop","id":9,"property":"edge","value":"bottom"},{"op":"set-prop","id":9,"property":"visible","value":false},{"op":"insert-child","parent":8,"child":9,"index":0},{"op":"create-node","id":10,"kind":"stack"},{"op":"set-prop","id":10,"property":"grow","value":1.0},{"op":"insert-child","parent":9,"child":10,"index":0},{"op":"create-node","id":11,"kind":"stack"},{"op":"insert-child","parent":10,"child":11,"index":0},{"op":"create-node","id":12,"kind":"column"},{"op":"set-prop","id":12,"property":"gap","value":16},{"op":"set-prop","id":12,"property":"cross","value":"center"},{"op":"create-node","id":13,"kind":"column"},{"op":"set-prop","id":13,"property":"grow","value":1.0},{"op":"insert-child","parent":12,"child":13,"index":0},{"op":"create-node","id":14,"kind":"row"},{"op":"set-prop","id":14,"property":"gap","value":8},{"op":"set-prop","id":14,"property":"cross","value":"center"},{"op":"set-prop","id":14,"property":"padding-horizontal","value":10},{"op":"set-prop","id":14,"property":"padding-vertical","value":4},{"op":"insert-child","parent":13,"child":14,"index":0},{"op":"create-node","id":15,"kind":"spacer"},{"op":"set-prop","id":15,"property":"grow","value":1.0},{"op":"insert-child","parent":14,"child":15,"index":0},{"op":"create-node","id":16,"kind":"toolbar"},{"op":"set-prop","id":16,"property":"accessibility-label","value":"navigation"},{"op":"set-prop","id":16,"property":"gap","value":16},{"op":"set-prop","id":16,"property":"placement","value":"principal"},{"op":"insert-child","parent":14,"child":16,"index":1},{"op":"create-node","id":17,"kind":"text"},{"op":"set-prop","id":17,"property":"text","value":""},{"op":"insert-child","parent":16,"child":17,"index":0},{"op":"set-prop","id":17,"property":"style-class","value":"semibold"},{"op":"create-node","id":18,"kind":"spacer"},{"op":"set-prop","id":18,"property":"grow","value":1.0},{"op":"insert-child","parent":14,"child":18,"index":2},{"op":"create-node","id":19,"kind":"toolbar"},{"op":"set-prop","id":19,"property":"accessibility-label","value":"navigation"},{"op":"set-prop","id":19,"property":"gap","value":16},{"op":"set-prop","id":19,"property":"placement","value":"primary-action"},{"op":"insert-child","parent":14,"child":19,"index":3},{"op":"create-node","id":20,"kind":"button-group"},{"op":"set-prop","id":20,"property":"gap","value":0},{"op":"set-prop","id":20,"property":"background","value":"glass"},{"op":"set-prop","id":20,"property":"corner-radius","value":999},{"op":"set-prop","id":20,"property":"height","value":44},{"op":"insert-child","parent":19,"child":20,"index":0},{"op":"create-node","id":21,"kind":"box"},{"op":"set-prop","id":21,"property":"width","value":44},{"op":"set-prop","id":21,"property":"height","value":44},{"op":"insert-child","parent":20,"child":21,"index":0},{"op":"create-node","id":22,"kind":"menu-trigger"},{"op":"set-prop","id":22,"property":"foreground","value":"foreground"},{"op":"set-prop","id":22,"property":"style-class","value":"capsule"},{"op":"set-prop","id":22,"property":"icon","value":"app:ellipsis"},{"op":"set-prop","id":22,"property":"accessibility-label","value":"Account menu"},{"op":"insert-child","parent":21,"child":22,"index":0},{"op":"create-node","id":23,"kind":"dropdown-menu"},{"op":"insert-child","parent":22,"child":23,"index":0},{"op":"create-node","id":24,"kind":"menu-item"},{"op":"set-prop","id":24,"property":"text","value":"Diagnostics"},{"op":"set-prop","id":24,"property":"icon","value":"app:stethoscope"},{"op":"set-prop","id":24,"property":"enabled","value":true},{"op":"set-prop","id":24,"property":"press-enabled","value":true},{"op":"insert-child","parent":23,"child":24,"index":0},{"op":"set-prop","id":20,"property":"accessibility-identifier","value":"journal-startup-menu"},{"op":"create-extension","id":25,"identifier":"journal-chrome","fingerprint":"lui-extension-v1|14:journal-chrome|profiles:ios/swiftui,macos/swiftui|standard-children:1|children:20:journal-asset-import,22:journal-asset-settings,14:journal-chrome,21:journal-image-preview,12:journal-list|properties:7:payload:string:required:none|events:"},{"op":"set-extension-prop","id":25,"property":"payload","value":"{\"mode\":\"startup\",\"loading\":false,\"initial\":true,\"active\":true,\"message\":\"\"}"},{"op":"insert-child","parent":13,"child":25,"index":1},{"op":"create-node","id":26,"kind":"column"},{"op":"set-prop","id":26,"property":"gap","value":16},{"op":"set-prop","id":26,"property":"cross","value":"center"},{"op":"insert-child","parent":25,"child":26,"index":0},{"op":"set-prop","id":13,"property":"accessibility-identifier","value":"sync-manager-route"},{"op":"insert-child","parent":11,"child":12,"index":0},{"op":"create-node","id":27,"kind":"view-that-fits"},{"op":"set-prop","id":27,"property":"orientation","value":"horizontal"},{"op":"insert-child","parent":9,"child":27,"index":1},{"op":"create-node","id":28,"kind":"column"},{"op":"set-prop","id":28,"property":"gap","value":16},{"op":"set-prop","id":28,"property":"cross","value":"center"},{"op":"insert-child","parent":27,"child":28,"index":0},{"op":"create-node","id":29,"kind":"row"},{"op":"set-prop","id":29,"property":"gap","value":16},{"op":"set-prop","id":29,"property":"cross","value":"center"},{"op":"insert-child","parent":28,"child":29,"index":0},{"op":"create-node","id":30,"kind":"row"},{"op":"insert-child","parent":29,"child":30,"index":0},{"op":"create-node","id":31,"kind":"icon"},{"op":"set-prop","id":31,"property":"name","value":"app:exclamationmark-circle"},{"op":"insert-child","parent":30,"child":31,"index":0},{"op":"create-node","id":32,"kind":"text"},{"op":"set-prop","id":32,"property":"text","value":""},{"op":"insert-child","parent":30,"child":32,"index":1},{"op":"create-node","id":33,"kind":"spacer"},{"op":"insert-child","parent":29,"child":33,"index":1},{"op":"set-prop","id":29,"property":"padding","value":16},{"op":"create-node","id":34,"kind":"column"},{"op":"set-prop","id":34,"property":"gap","value":16},{"op":"set-prop","id":34,"property":"cross","value":"center"},{"op":"insert-child","parent":27,"child":34,"index":1},{"op":"create-node","id":35,"kind":"column"},{"op":"set-prop","id":35,"property":"gap","value":8},{"op":"set-prop","id":35,"property":"cross","value":"start"},{"op":"insert-child","parent":34,"child":35,"index":0},{"op":"create-node","id":36,"kind":"row"},{"op":"insert-child","parent":35,"child":36,"index":0},{"op":"create-node","id":37,"kind":"icon"},{"op":"set-prop","id":37,"property":"name","value":"app:exclamationmark-circle"},{"op":"insert-child","parent":36,"child":37,"index":0},{"op":"create-node","id":38,"kind":"text"},{"op":"set-prop","id":38,"property":"text","value":""},{"op":"insert-child","parent":36,"child":38,"index":1},{"op":"set-prop","id":35,"property":"padding","value":16},{"op":"insert-child","parent":7,"child":8,"index":0},{"op":"insert-child","parent":3,"child":4,"index":0},{"op":"set-extension-prop","id":3,"property":"path","value":""},{"op":"set-extension-prop","id":3,"property":"revision","value":1},{"op":"create-node","id":39,"kind":"stack"},{"op":"insert-child","parent":2,"child":39,"index":1},{"op":"create-node","id":40,"kind":"column"},{"op":"set-prop","id":40,"property":"gap","value":16},{"op":"set-prop","id":40,"property":"cross","value":"center"},{"op":"insert-child","parent":39,"child":40,"index":0},{"op":"create-node","id":41,"kind":"stack"},{"op":"insert-child","parent":2,"child":41,"index":2},{"op":"create-node","id":42,"kind":"column"},{"op":"set-prop","id":42,"property":"gap","value":16},{"op":"set-prop","id":42,"property":"cross","value":"center"},{"op":"insert-child","parent":41,"child":42,"index":0},{"op":"create-node","id":43,"kind":"stack"},{"op":"insert-child","parent":2,"child":43,"index":3},{"op":"create-node","id":44,"kind":"column"},{"op":"set-prop","id":44,"property":"gap","value":16},{"op":"set-prop","id":44,"property":"cross","value":"center"},{"op":"create-node","id":45,"kind":"column"},{"op":"set-prop","id":45,"property":"grow","value":1.0},{"op":"insert-child","parent":44,"child":45,"index":0},{"op":"insert-child","parent":43,"child":44,"index":0},{"op":"create-node","id":46,"kind":"stack"},{"op":"insert-child","parent":2,"child":46,"index":4},{"op":"create-node","id":47,"kind":"column"},{"op":"set-prop","id":47,"property":"gap","value":16},{"op":"set-prop","id":47,"property":"cross","value":"center"},{"op":"insert-child","parent":46,"child":47,"index":0},{"op":"create-node","id":48,"kind":"stack"},{"op":"insert-child","parent":2,"child":48,"index":5},{"op":"create-node","id":49,"kind":"column"},{"op":"set-prop","id":49,"property":"gap","value":16},{"op":"set-prop","id":49,"property":"cross","value":"center"},{"op":"create-extension","id":50,"identifier":"journal-asset-settings","fingerprint":"lui-extension-v1|22:journal-asset-settings|profiles:ios/swiftui,macos/swiftui|standard-children:1|children:20:journal-asset-import,22:journal-asset-settings,14:journal-chrome,21:journal-image-preview,12:journal-list|properties:7:payload:string:required:none|events:5:event[2:id:int:required,7:payload:string:required]"},{"op":"set-extension-prop","id":50,"property":"payload","value":"{\"presented\":false,\"recent\":\"Waiting for a graph\",\"favorites\":\"Waiting for a graph\",\"uploads\":[]}"},{"op":"insert-child","parent":49,"child":50,"index":0},{"op":"insert-child","parent":48,"child":49,"index":0},{"op":"set-prop","id":2,"property":"theme-mode","value":"system"},{"op":"insert-child","parent":1,"child":2,"index":0}]} \ No newline at end of file diff --git a/logseq_journal.opam b/logseq_journal.opam index 814e201..5d88e91 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#4fdb9136026af7863a8d025133871e34864eace1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 4e4bd44..79bb9ee 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044" + "git+https://github.com/logseq/lui.git#4fdb9136026af7863a8d025133871e34864eace1" ] [ "melange-edn-core.0.5.0" diff --git a/swift/App.swift b/swift/App.swift index 64ab593..38be764 100644 --- a/swift/App.swift +++ b/swift/App.swift @@ -14,100 +14,23 @@ import AppKit var body: some Scene { #if os(macOS) Window("Logseq Journal", id: "journal") { - JournalHost(platform: platform) + JournalHost(platform: platform, prepare: { + JournalRuntimeSetup(payload: try JournalNativeServices.startupPayload(), + extensions: try JournalExtensions.registry()) + }, authentication: JournalCognitoNative.shared) .frame(minWidth: 320, minHeight: 360) .onAppear { delegate.platform = platform } } .defaultSize(width: 840, height: 720) #else - WindowGroup("Logseq Journal") { JournalHost(platform: platform) } + WindowGroup("Logseq Journal") { JournalHost(platform: platform, prepare: { + JournalRuntimeSetup(payload: try JournalNativeServices.startupPayload(), + extensions: try JournalExtensions.registry()) + }, authentication: JournalCognitoNative.shared) } #endif } } -private struct JournalRuntimeSetup { - let payload: Data - let extensions: LUIAppleExtensionRegistry - @MainActor init() throws { - payload = try JournalNativeServices.startupPayload() - extensions = try JournalExtensions.registry() - } -} - -private struct JournalHost: View { - let platform: JournalApplicationPlatform - @Environment(\.scenePhase) private var scenePhase - @State private var setup: Result? - @State private var retry = 0 - @State private var signInPresented = false - - var body: some View { - Group { - switch setup { - case .none: ProgressView("Opening journal") - case .failure: - ContentUnavailableView { - Label("Unable to open local application storage", systemImage: "exclamationmark.folder") - } actions: { Button("Retry") { setup = nil; retry += 1 } } - case .success(let setup): - JournalRuntimeHost( - platform: platform, payload: setup.payload, extensions: setup.extensions) - .font(.body) - .safeAreaInset(edge: .top, spacing: 0) { - if platform.authenticationRequired { - HStack { - Text("Sign in to connect your account") - Spacer() - Button("Sign in") { signInPresented = true }.buttonStyle(.bordered) - }.padding(12) - } - } - } - } - .task(id: retry) { if setup == nil { setup = Result { try JournalRuntimeSetup() } } } - .onChange(of: scenePhase, initial: true) { _, phase in - if phase == .background { lifecycle(backgrounded: true) } - else if phase == .active { lifecycle(backgrounded: false) } - } - .onChange(of: platform.authenticationRequired) { _, required in - if required && platform.localAccountAvailable == false { signInPresented = true } - if !required { signInPresented = false } - } - .onChange(of: platform.localAccountAvailable) { _, available in - if available == false && platform.authenticationRequired { signInPresented = true } - } - .sheet(isPresented: $signInPresented) { - NavigationStack { - JournalAuthenticationView(api: JournalCognitoNative.shared) { - platform.refreshAuthentication() - signInPresented = false - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - JournalSheetDismissButton { signInPresented = false } - .accessibilityIdentifier("authentication-close") - } - } - } - .presentationSizing(.form) - .presentationDetents([.large]) - .frame(idealWidth: 480) - #if os(macOS) - .frame(minWidth: 320, minHeight: 360) - #endif - } - #if os(macOS) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didHideNotification)) { _ in lifecycle(backgrounded: true) } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in lifecycle(backgrounded: false) } - #endif - } - - private func lifecycle(backgrounded: Bool) { - do { try platform.setBackgrounded(backgrounded) } - catch { Logger(subsystem: "com.logseq.journal", category: "lifecycle").error("Unable to enqueue lifecycle event") } - } -} - #if os(macOS) @MainActor final class JournalApplicationDelegate: NSObject, NSApplicationDelegate { weak var platform: JournalApplicationPlatform? diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 468b7eb..da13aba 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -24,7 +24,7 @@ struct JournalSheetDismissButton: SwiftUI.View { /// Native chrome layout only. List sections own all date scrolling and pinning. @MainActor enum JournalChrome { private enum Mode: String, Decodable { - case page, header, detail, unlock + case page, header, detail, unlock, startup case toolbarControl = "toolbar-control" case detailText = "detail-text" case detailRow = "detail-row" @@ -40,6 +40,11 @@ struct JournalSheetDismissButton: SwiftUI.View { let text: String? let id: String? let level: Int? + let loading: Bool? + let initial: Bool? + let active: Bool? + let value: Double? + let message: String? } private struct DetailText: SwiftUI.View { @@ -182,6 +187,7 @@ struct JournalSheetDismissButton: SwiftUI.View { struct View: SwiftUI.View { let context: LUIAppleExtensionViewContext + @Environment(\.journalStartupContainerFrame) private var startupContainerFrame private var properties: Properties? { JournalExtensions.decode(Properties.self, context: context) @@ -200,6 +206,21 @@ struct JournalSheetDismissButton: SwiftUI.View { FloatingChrome(content: child(0), cluster: child(1), progress: child(2), properties: properties) } + case .startup: + if let properties, context.childIDs.count == 1 { + GeometryReader { bounds in + JournalStartupLoadingSurface(loading: properties.loading == true, + initial: properties.initial == true, active: properties.active != false, + value: properties.value, message: properties.message ?? "", action: child(0)) + // Ancestor columns have already consumed the navigation inset. + // Keep the same safe-area canvas as native preparation instead. + .frame(width: bounds.size.width, + height: startupContainerFrame?.height ?? bounds.size.height) + .offset(y: startupContainerFrame.map { + $0.minY - bounds.frame(in: .global).minY + } ?? 0) + } + } case .header: if let properties, context.childIDs.isEmpty, let title = properties.title { SectionDate(title: title) @@ -324,3 +345,13 @@ extension EnvironmentValues { set { self[JournalContainerTopInsetKey.self] = newValue } } } + +private struct JournalStartupContainerFrameKey: EnvironmentKey { + static let defaultValue: CGRect? = nil +} +extension EnvironmentValues { + var journalStartupContainerFrame: CGRect? { + get { self[JournalStartupContainerFrameKey.self] } + set { self[JournalStartupContainerFrameKey.self] = newValue } + } +} diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 88554ea..0061ea7 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -1,6 +1,10 @@ import LUIAppleBackend import OSLog import SwiftUI +import Observation +#if os(macOS) +import AppKit +#endif /// Boots one `JournalRuntime` for the given startup payload and renders the /// lui root with the environment/notice plumbing attached. Shared by the app @@ -11,14 +15,25 @@ struct JournalRuntimeHost: View { let payload: Data let extensions: LUIAppleExtensionRegistry @State private var runtime: JournalRuntime? + @State private var fallbackClock = JournalStartupClock() + @Environment(\.journalStartupClock) private var inheritedClock + @State private var failure: Error? + @State private var retry = 0 + private var clock: JournalStartupClock { inheritedClock ?? fallbackClock } private var content: some SwiftUI.View { Group { if let runtime, let rootID = runtime.rootID { LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + } else if failure != nil { + ContentUnavailableView { + Label("Unable to open journal", systemImage: "exclamationmark.folder") + } actions: { + Button("Retry") { failure = nil; clock.restart(); retry += 1 } + .accessibilityIdentifier("journal-runtime-retry") + } } else { - ProgressView("Opening journal") - .frame(maxWidth: .infinity, maxHeight: .infinity) + JournalStartupLoadingSurface(loading: false, initial: true, active: true) } } } @@ -29,15 +44,18 @@ struct JournalRuntimeHost: View { // Capture the container inset before the descendant NavigationStack // adds its navigation bar; floating pages retain their original layout. GeometryReader { container in - content.environment(\.journalContainerTopInset, container.safeAreaInsets.top) + content + .environment(\.journalContainerTopInset, container.safeAreaInsets.top) + .environment(\.journalStartupContainerFrame, container.frame(in: .global)) } #else content #endif } + .environment(\.journalStartupClock, clock) .modifier(JournalNoticePresenter(controller: platform.notices)) .background(JournalEnvironmentObserver { platform.pushEnvironment($0) }) - .task { + .task(id: retry) { if runtime == nil { do { let next = try JournalRuntime( @@ -47,6 +65,7 @@ struct JournalRuntimeHost: View { next.start() runtime = next } catch { + failure = error Logger(subsystem: "com.logseq.journal", category: "runtime") .error("Unable to start journal runtime: \(error)") } @@ -55,3 +74,155 @@ struct JournalRuntimeHost: View { .onDisappear { runtime?.stop() } } } + +@Observable @MainActor final class JournalStartupClock { + private(set) var expired = false + @ObservationIgnored private var started: TimeInterval? + @ObservationIgnored private var task: Task? + @ObservationIgnored private var revision = 0 + var elapsed: Bool { + expired || started.map { ProcessInfo.processInfo.systemUptime - $0 >= 0.1 } == true + } + func begin() { + guard started == nil else { return } + started = ProcessInfo.processInfo.systemUptime + let version = revision + task = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(100)) } + catch { return } + guard let self, !Task.isCancelled, version == revision else { return } + expired = true + } + } + func restart() { + revision += 1; task?.cancel(); task = nil; started = nil; expired = false + } +} + +private struct JournalStartupClockKey: EnvironmentKey { + static let defaultValue: JournalStartupClock? = nil +} +extension EnvironmentValues { + var journalStartupClock: JournalStartupClock? { + get { self[JournalStartupClockKey.self] } + set { self[JournalStartupClockKey.self] = newValue } + } +} + +struct JournalStartupLoadingSurface: View { + let loading: Bool + let initial: Bool + let active: Bool + var value: Double? = nil + var message = "" + var action = AnyView(EmptyView()) + @Environment(\.journalStartupClock) private var clock + private var visible: Bool { active && (loading || (initial && clock?.elapsed == true)) } + var body: some View { + VStack(spacing: 12) { + Group { + if let value { ProgressView(value: value).frame(width: 160) } + else { ProgressView() } + }.frame(height: 24).accessibilityIdentifier("journal-startup-loading") + Text("Loading journal") + Text(message).font(.footnote).foregroundStyle(.secondary).frame(height: 20) + } + .opacity(visible ? 1 : 0) + .accessibilityHidden(!visible) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // Recovery actions do not change the centered waiting canvas. + .overlay(alignment: .bottom) { action } + .padding(24) + .background(.background) + .accessibilityIdentifier("journal-startup-waiting") + .onAppear { clock?.begin() } + } +} + +struct JournalRuntimeSetup { + let payload: Data + let extensions: LUIAppleExtensionRegistry +} + +/// Production account entry points with injectable native capabilities for QA. +struct JournalHost: View { + let platform: JournalApplicationPlatform + let prepare: @MainActor () async throws -> JournalRuntimeSetup + let authentication: any JournalAuthenticationAPI + @Environment(\.scenePhase) private var scenePhase + @State private var setup: Result? + @State private var retry = 0 + @State private var signInPresented = false + @State private var clock = JournalStartupClock() + + var body: some View { + Group { + switch setup { + case .none: + JournalStartupLoadingSurface(loading: false, initial: true, active: scenePhase != .background) + case .failure: + ContentUnavailableView { + Label("Unable to open local application storage", systemImage: "exclamationmark.folder") + } actions: { + Button("Retry") { setup = nil; clock.restart(); retry += 1 } + .accessibilityIdentifier("journal-storage-retry") + } + case .success(let setup): + JournalRuntimeHost(platform: platform, payload: setup.payload, extensions: setup.extensions) + .font(.body) + .safeAreaInset(edge: .top, spacing: 0) { + if platform.authenticationRequired { + HStack { + Text("Sign in to connect your account") + Spacer() + Button("Sign in") { signInPresented = true }.buttonStyle(.bordered) + .accessibilityIdentifier("journal-sign-in") + }.padding(12) + } + } + } + } + .environment(\.journalStartupClock, clock) + .task(id: retry) { + guard setup == nil else { return } + do { setup = .success(try await prepare()) } + catch { if !Task.isCancelled { setup = .failure(error) } } + } + .onChange(of: scenePhase, initial: true) { _, phase in + if phase == .background { lifecycle(backgrounded: true) } + else if phase == .active { lifecycle(backgrounded: false) } + } + .onChange(of: platform.authenticationRequired) { _, required in + if required && platform.localAccountAvailable == false { signInPresented = true } + if !required { signInPresented = false } + } + .onChange(of: platform.localAccountAvailable) { _, available in + if available == false && platform.authenticationRequired { signInPresented = true } + } + .sheet(isPresented: $signInPresented) { + NavigationStack { + JournalAuthenticationView(api: authentication) { + platform.refreshAuthentication(); signInPresented = false + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + JournalSheetDismissButton { signInPresented = false } + .accessibilityIdentifier("authentication-close") + } + } + } + .presentationSizing(.form).presentationDetents([.large]).frame(idealWidth: 480) + #if os(macOS) + .frame(minWidth: 320, minHeight: 360) + #endif + } + #if os(macOS) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didHideNotification)) { _ in lifecycle(backgrounded: true) } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in lifecycle(backgrounded: false) } + #endif + } + private func lifecycle(backgrounded: Bool) { + do { try platform.setBackgrounded(backgrounded) } + catch { Logger(subsystem: "com.logseq.journal", category: "lifecycle").error("Unable to enqueue lifecycle event") } + } +} diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 6bd73ac..2edc382 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -622,7 +622,10 @@ let run_favorites_native_visibility (Atomic.exchange media_notices []); let sync_phase = Atomic.exchange publish_phase None in let error = Atomic.exchange publish_error None in + (* A current graph must carry the same manager generation. The + startup owner deliberately rejects mixed old/new graph snapshots. *) if sync_phase <> None || error <> None + || Atomic.get graph_generation <> manager.snapshot.startup.graph_generation then W.Request_context.emit context @@ -633,6 +636,8 @@ let run_favorites_native_visibility { manager.snapshot with sync_phase = Option.value sync_phase ~default:Service.Current ; last_error = Option.value error ~default:None + ; startup = { manager.snapshot.startup with + graph_generation = Atomic.get graph_generation } } }); Ok @@ -970,10 +975,12 @@ let run_favorites_native_visibility })) ; next_cursor = None } - | V2_get_children { parent; limit; _ } -> + | V2_get_children { parent; limit; cursor; _ } -> if (check_detail_discovery || check_detail_wait) && G.Uuid.equal parent (uuid 2) then Atomic.set discovery_reads (limit :: Atomic.get discovery_reads); - if check_detail_wait && G.Uuid.equal parent (uuid 2) then + (* Initial first pages complete before publication. The wait + scenario delays only a later explicit continuation read. *) + if check_detail_wait && G.Uuid.equal parent (uuid 2) && cursor <> None then while not (Atomic.get detail_wait_release) do Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001 done; @@ -984,8 +991,19 @@ let run_favorites_native_visibility ; items = (if G.Uuid.equal parent favorites_page.uuid then List.init 65 favorite_membership + else if check_detail_wait && cursor = None + && (G.Uuid.equal parent (uuid 2) || G.Uuid.equal parent (uuid 3)) + then + let value = timeline_block (if G.Uuid.equal parent (uuid 2) then 63 else 64) in + [ { P.value = { value with block = { value.block with parent } } + ; revision = "child-first-page" + } ] else []) - ; next_cursor = None + ; next_cursor = + (if check_detail_wait && cursor = None + && (G.Uuid.equal parent (uuid 2) || G.Uuid.equal parent (uuid 3)) + then Some (G.Cursor.of_string "later-child-page" |> Result.get_ok) + else None) } | V2_list_favorites { limit; cursor } -> let offset = @@ -1294,8 +1312,11 @@ let run_favorites_native_visibility (Yojson.Safe.to_string (`Assoc ["id", `Int 1; "payload", `String (Yojson.Safe.to_string (`Assoc ["type", `String "visible_range"; "first", `Int first; "last", `Int last]))])) |> consume in - range 2 3; - wait "first branch is pending" (fun () -> Atomic.get discovery_reads = [128]); + let first_more = Option.get (find "accessibility-identifier" + ("detail-more:" ^ G.Uuid.to_string (uuid 2))) in + dispatch (Lui_protocol.Press first_more); + wait "explicit first branch continuation is pending" (fun () -> + Atomic.get discovery_reads = [128;128]); range 4 5; let more = Option.get (find "accessibility-identifier" more_id) in Alcotest.(check bool) "other branch stays disabled while first is pending" true @@ -1331,17 +1352,14 @@ let run_favorites_native_visibility Unix.sleepf 0.001 done; Alcotest.(check (list int)) - "no discovery before native range" - [] + "bounded initial discovery completes before body publication" + [128] (Atomic.get discovery_reads); - let more = - find "accessibility-identifier" ("detail-more:" ^ G.Uuid.to_string (uuid 2)) - |> Option.get - in Alcotest.(check bool) - "unobserved More remains actionable" + "authoritative empty child has no More at publication" true - (List.assoc_opt "enabled" (Hashtbl.find props more) = Some (`Bool true)); + (Option.is_none (find "accessibility-identifier" + ("detail-more:" ^ G.Uuid.to_string (uuid 2)))); let list = Hashtbl.fold (fun id values found -> @@ -1362,7 +1380,7 @@ let run_favorites_native_visibility |> consume in range (); - wait "first native range activates unknown visible child" (fun () -> + wait "first native range preserves completed initial discovery" (fun () -> Atomic.get discovery_reads = [ 128 ]); wait "authoritative empty child retires More" (fun () -> Option.is_none (find "text" "Load more")); @@ -1375,7 +1393,7 @@ let run_favorites_native_visibility [ 128 ] (Atomic.get discovery_reads); Printf.printf - "FOLLOWUP_APPLICATION first_range reads=1 limit=128 repeated_range=10 \ + "FOLLOWUP_APPLICATION initial_staging reads=1 limit=128 repeated_range=10 \ duplicate_reads=0\n\ %!"; let navigator = Option.get (find "_extension" "navigation-stack") in @@ -3007,7 +3025,7 @@ let run_favorites_native_visibility publish (Ready "fixture-ready"); wait "Ready enters real Acquire before completion" (fun () -> Atomic.get acquire_entered >= 1 - && List.length (all "text" "Opening file") = shown_count); + && List.length (all "text" "Loading") = shown_count); settle (); record "ready"; check_media_counts "Ready" shown_count; @@ -4656,7 +4674,7 @@ let () = ~check_covered_update:true ~timeline_rows:50 ()) - ; Alcotest.test_case "Detail first native range activates discovery" `Quick (fun () -> + ; Alcotest.test_case "Detail initial staging and native range ownership" `Quick (fun () -> run_favorites_native_visibility ~check_detail_discovery:true ()) ; Alcotest.test_case "Detail disabled branch explains serialized wait" `Quick (fun () -> run_favorites_native_visibility ~check_detail_wait:true ()) diff --git a/test/journal_media_test.ml b/test/journal_media_test.ml index 8a54171..8d7368d 100644 --- a/test/journal_media_test.ml +++ b/test/journal_media_test.ml @@ -112,6 +112,87 @@ let () = incr failed; Printf.printf "FAIL %s: %s\n%!" name (Printexc.to_string exn) in + run "typed local readiness shows Loading until acquisition" (fun () -> + let waiting, _ = P.step P.empty (show (asset 'a')) in + let pending, effects = ready waiting "local" in + check + (P.presentation pending = Placeholder "Loading") + "Ready opens locally as Loading"; + check (List.length effects = 1) "local readiness only acquires a file"; + let ticket = acquire effects in + let duplicate, effects = ready pending "local" in + check + (effects = [] && P.presentation duplicate = Placeholder "Loading") + "duplicate Ready preserves acquisition"; + let visible, effects = + P.step duplicate (Acquired (ticket, Some ("lease", "/cache/local"))) + in + check + (effects = [] && P.presentation visible = File "/cache/local") + "local completion publishes retained file"); + run "typed download transitions into local Loading" (fun () -> + let waiting, _ = P.step P.empty (show (asset 'a')) in + let downloading, effects = + P.step + waiting + (Availability { scope; consumer = "visible"; availability = Downloading }) + in + check + (effects = [] && P.presentation downloading = Placeholder "Downloading") + "only typed Downloading says Downloading"; + let pending, effects = ready downloading "downloaded-local" in + check + (P.presentation pending = Placeholder "Loading") + "remote completion starts local Loading"; + let visible, effects = + P.step pending (Acquired (acquire effects, Some ("lease", "/cache/downloaded"))) + in + check + (effects = [] && P.presentation visible = File "/cache/downloaded") + "remote then local completion publishes file"); + run "queued and unavailable states never masquerade as downloads" (fun () -> + let waiting, _ = P.step P.empty (show (asset 'a')) in + List.iter + (fun (availability, label) -> + let state, effects = + P.step waiting (Availability { scope; consumer = "visible"; availability }) + in + check + (effects = [] && P.presentation state = Placeholder label) + "typed wait retains its specific label") + [ S.Asset.Queued, "Waiting for file" + ; Waiting_remote, "Waiting for upload" + ; Waiting_network, "Available when online" + ; Waiting_unlock, "Unlock graph to view file" + ]); + run "failed local open retries only acquisition and fences late completion" (fun () -> + let waiting, _ = P.step P.empty (show (asset 'a')) in + let pending, effects = ready waiting "local" in + let old = acquire effects in + let failed, effects = P.step pending (Acquired (old, None)) in + check + (effects = [] && P.presentation failed = Failed "Unable to open file") + "local failure has explicit Retry"; + let pending, effects = P.step failed Retry_requested in + check + (P.presentation pending = Placeholder "Loading" && List.length effects = 1) + "local Retry only reacquires"; + let current = acquire effects in + let stable, effects = + P.step pending (Acquired (old, Some ("orphan", "/cache/old"))) + in + check + (P.presentation stable = Placeholder "Loading" && List.length (releases effects) = 1) + "late completion releases orphan without replacing Loading"; + let visible, _ = + P.step stable (Acquired (current, Some ("held", "/cache/current"))) + in + let stable, effects = + P.step visible (Acquired (current, Some ("held", "/cache/current"))) + in + check + (effects = [] && P.presentation stable = File "/cache/current") + "duplicate local completion retains displayed lease"); run "loading cannot Retry" (fun () -> let loading, _ = P.step P.empty (show (asset 'a')) in let _, effects = P.step loading Retry_requested in diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index 31bc75e..772f6b0 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -2098,10 +2098,15 @@ let test_typed_covered_failure_missing_and_branch () = R.apply_detail_response retry ~request_generation:830L - { Journal_graph_projection.root = a - ; children = { blocks = [ child ]; continuation = None } - } - in + (detail ~root:a ()) + in + (* This regression concerns a later branch read on an already published + owner. Reconcile the newly observed child through the public outline API. *) + let ready = R.update_detail_at ready ~entry_id:a_id + (Journal_detail.reconcile_children (entry_detail ready a_id) + { Journal_graph_projection.root = a + ; children = { blocks = [ child ]; continuation = None } + }) in let owner = entry_detail ready a_id in let owner, requests = Journal_detail.step owner (Load_more (Journal_model.id child)) @@ -3183,7 +3188,680 @@ let test_followup_review_regressions () = (String.concat ", " (List.rev !failures)) ;; + +(* Initial Detail publication owns the complete bounded page, while the delayed + indicator belongs to its route/request identity rather than asset work. *) +let test_detail_initial_loading_delay_and_ownership () = + let module R = Journal_routes in + let cases = [ + "fast completion", (fun () -> + let t = R.open_detail (R.create ()) ~block_id:(Journal_model.id (block ())) ~request_generation:9000L in + require (not (R.loading_indicator t)) "initial Detail shows loading before 100ms"; + let t = R.observe_loading_delay t ~entry_id:(entry_id t) ~request_generation:9000L ~elapsed:0.099 in + require (not (R.loading_indicator t)) "99ms exposes the indicator"; + let root = block () in + let child = block ~id:"70000000-0000-4000-a000-000000000099" ~parent_id:(Some (Journal_model.id root)) () in + let staged = R.apply_detail_response t ~request_generation:9000L { root; children={blocks=[child];continuation=None} } in + let pending, requests = R.load_initial staged in + let generation = match requests with + | [Journal_graph_request.Load_detail {request_generation;_}] -> request_generation + | _ -> fail "fast initial child discovery was not admitted" in + let ready = R.apply_detail_response pending ~request_generation:generation (detail ~root:child ()) in + require (R.route ready = Detail && not (R.loading_indicator ready)) "fast body is artificially delayed"; + require (List.length (List.filter (function Journal_detail.Block _ -> true | More _ -> false) (Journal_detail.rows (Option.get (R.detail ready)))) = 2) "root published without its bounded children page"; + require (R.observe_loading_delay ready ~entry_id:(entry_id ready) ~request_generation:9000L ~elapsed:1. == ready) "late timer changes ready body"); + "slow delay boundary", (fun () -> + let t = R.open_detail (R.create ()) ~block_id:(Journal_model.id (block ())) ~request_generation:9001L in + let id = entry_id t in + require (not (R.loading_indicator t)) "slow route starts with a spinner"; + require (R.observe_loading_delay t ~entry_id:id ~request_generation:9001L ~elapsed:(-1.) == t) "negative elapsed time changes owner"; + require (R.observe_loading_delay t ~entry_id:id ~request_generation:9001L ~elapsed:0.099 == t) "early callback changes owner"; + let slow = R.observe_loading_delay t ~entry_id:id ~request_generation:9001L ~elapsed:0.1 in + require (R.loading_indicator slow) "100ms pending route has no indicator"; + require (R.detail slow = None) "pending route exposes a partial root"; + require (R.observe_loading_delay slow ~entry_id:id ~request_generation:9001L ~elapsed:0.2 == slow) "duplicate delay republishes owner"); + "failure and retry", (fun () -> + let t = R.open_detail (R.create ()) ~block_id:(Journal_model.id (block ())) ~request_generation:9002L in + let id = entry_id t in + let failed = R.apply_detail_failure t ~request_generation:9002L ~missing:false ~message:"read failed" in + require (not (R.loading_indicator failed)) "failure retains spinner"; + require (R.observe_loading_delay failed ~entry_id:id ~request_generation:9002L ~elapsed:1. == failed) "late timer overwrites failure"; + let retry, request = R.retry_detail failed ~request_generation:9003L in + require (Option.is_some request && not (R.loading_indicator retry)) "retry skips its fresh grace period"; + require (R.observe_loading_delay retry ~entry_id:id ~request_generation:9002L ~elapsed:1. == retry) "old timer activates retry"; + require (R.apply_detail_response retry ~request_generation:9002L (detail ()) == retry) "old completion activates retry"; + let missing = R.apply_missing_detail retry ~request_generation:9003L in + require (R.route missing = Missing_detail && not (R.loading_indicator missing)) "missing route retains progress"); + "back and new route", (fun () -> + let t = R.open_detail (R.create ()) ~block_id:(Journal_model.id (block ())) ~request_generation:9004L in + let old = entry_id t in + let back = R.back t in + require (R.observe_loading_delay back ~entry_id:old ~request_generation:9004L ~elapsed:1. == back) "cancelled timer recreates route"; + require (R.apply_detail_response back ~request_generation:9004L (detail ()) == back) "cancelled completion recreates route"; + let fresh = R.open_detail back ~block_id:(Journal_model.id (block ())) ~request_generation:9005L in + require (entry_id fresh <> old && not (R.loading_indicator fresh)) "reentry inherits progress"; + require (R.observe_loading_delay fresh ~entry_id:old ~request_generation:9004L ~elapsed:1. == fresh) "retired timer affects reentry"; + let reset = R.graph_unavailable fresh in + require (R.observe_loading_delay reset ~entry_id:(entry_id fresh) ~request_generation:9005L ~elapsed:1. == reset) "graph reset accepts timer"); + "rapid nested routes", (fun () -> + let a = block () and b = block ~id:"70000000-0000-4000-a000-000000000002" () in + let t = R.open_detail (R.create ()) ~block_id:(Journal_model.id a) ~request_generation:9006L in + let a_id = entry_id t in + let t = R.open_detail t ~block_id:(Journal_model.id b) ~request_generation:9007L in + let b_id = entry_id t in + let t = R.observe_loading_delay t ~entry_id:a_id ~request_generation:9006L ~elapsed:0.2 in + require (not (R.loading_indicator t) && R.loading_indicator (entry t a_id)) "covered timer leaks into top route"; + require (R.observe_loading_delay t ~entry_id:b_id ~request_generation:9006L ~elapsed:0.2 == t) "timer accepts wrong generation"; + let t = R.apply_detail_response t ~request_generation:9006L (detail ~root:a ()) in + require (R.route t = Detail_loading && not (R.loading_indicator t)) "covered completion reveals top body"; + let t = R.apply_detail_response t ~request_generation:9007L (detail ~root:b ()) in + require (R.route t = Detail) "current completion does not reveal body"); + ] in + let failures = ref [] in + List.iter (fun (name, test) -> try test () with exn -> + Printf.eprintf "DETAIL_LOADING_RED %s: %s\n%!" name (Printexc.to_string exn); + failures := name :: !failures) cases; + require (!failures = []) "Detail loading cases failed: %s" (String.concat ", " !failures) +;; + + +(* The route owns unpublished initial text. Only public queries, admitted + requests and matching completions are used to verify publication. *) +let test_detail_initial_nested_body_staging () = + let module R = Journal_routes in + let module D = Journal_detail in + let id n = Printf.sprintf "70000000-0000-4000-a000-%012d" (6000+n) in + let node ?parent ?(order="a") ?(source="Initial text") n = + block ~id:(id n) ~parent_id:parent ~order ~source ~child_count:0 () + in + let projection ?continuation root blocks : Journal_graph_projection.detail = + {root;children={blocks;continuation}} + in + let start ?(generation=9500L) projection = + R.open_detail (R.create ()) ~block_id:(Journal_model.id projection.Journal_graph_projection.root) ~request_generation:generation + |> fun t -> R.apply_detail_response t ~request_generation:generation projection + in + let text_ids t = + D.rows (Option.get (R.detail t)) + |> List.filter_map (function D.Block {block;_} -> Some (Journal_model.id block) | More _ -> None) + in + let admit t = + let _, parent = Option.get (R.initial_load t) in + let pending, commands = R.load_initial t in + match commands with + | [Journal_graph_request.Load_detail {block_id;after=None;limit=128;request_generation}] -> + require_string parent block_id "initial read parent"; + require (R.route pending = Detail_loading && R.detail pending = None) "admitted read exposes partial text"; + require (R.initial_load pending = None && R.load_initial pending = (pending, [])) "initial read is duplicated while pending"; + pending, parent, request_generation + | _ -> fail "initial staging must admit one bounded first page" + in + let finish t projections = + let rec drain t calls = + match R.initial_load t with + | None -> require (R.route t = Detail) "initial text never publishes"; t, List.rev calls + | Some _ -> + require (R.detail t = None) "initial partial text is visible"; + let pending, parent, generation = admit t in + let value = List.find (fun p -> Journal_model.id p.Journal_graph_projection.root = parent) projections in + let next = R.apply_detail_response pending ~request_generation:generation value in + require (R.apply_detail_response next ~request_generation:generation value == next) "duplicate completion changes initial owner"; + drain next (parent::calls) + in + drain t [] + in + let root=node 0 and child=node ~parent:(id 0) 1 and tail=node ~parent:(id 0) ~order:"z" 2 in + let grandchild=node ~parent:(id 1) 3 and extra=node ~parent:(id 1) ~order:"b" 4 in + let small=projection root [child;tail] in + let small_pages=[projection child [grandchild;extra];projection grandchild [];projection extra [];projection tail []] in + let cases=[ + "three levels publish all text once", (fun () -> + let t=start small in + require (R.route t = Detail_loading && R.detail t = None) "first direct page publishes ahead of deeper text"; + let ready,calls=finish t small_pages in + require (text_ids ready = List.map id [0;1;3;4;2]) "three-level first body is incomplete or unordered"; + require (List.length calls=4) "small tree has unexpected discovery reads"; + let visible=observe_outline (Option.get (R.detail ready)) in + require (D.next_automatic_load visible=None) "small body inserts more text after publication"); + "ten levels and following sibling publish together", (fun () -> + let chain=List.init 11 (fun depth -> node ?parent:(if depth=0 then None else Some (id (depth-1))) depth) in + let tail=node ~parent:(id 0) ~order:"z" 20 in + let pages=List.init 8 (fun i -> projection (List.nth chain (i+1)) [List.nth chain (i+2)]) @ [projection tail []] in + let t=start (projection (List.hd chain) [List.nth chain 1;tail]) in + require (R.detail t=None) "ten-level direct page is published early"; + let ready,calls=finish t pages in + require (text_ids ready = List.init 10 id @ [id 20]) "ten-level first body is incomplete"; + require (List.length calls=9 && not (List.mem (id 9) calls)) "staging exceeds ten levels or repeats a branch"; + require (D.next_automatic_load (observe_outline (Option.get (R.detail ready)))=None) "ten-level body continues inserting after publication"); + "wide tree stops at material budget and retains continuation", (fun () -> + let branches=List.init 128 (fun i -> node ~parent:(id 0) ~order:(Printf.sprintf "%04d" i) (100+i)) in + let children=List.init 128 (fun i -> node ~parent:(id 100) ~order:(Printf.sprintf "%04d" i) (300+i)) in + let t=start (projection root branches) in + require (R.detail t=None) "wide direct page publishes before bounded staging"; + let ready,calls=finish t [projection (List.hd branches) children] in + require (calls=[id 100] && List.length (text_ids ready)=257) "wide staging exceeds conservative 384-block reservation"; + let outline=observe_outline (Option.get (R.detail ready)) in + require (D.next_automatic_load outline=None) "publication resets material budget"; + let _,commands=D.step outline (Load_more (id 101)) in + require (List.length commands=1) "budget stop loses explicit continuation"); + "128 attempts bound unknown zero-count discovery", (fun () -> + let branches=List.init 128 (fun i -> node ~parent:(id 0) ~order:(Printf.sprintf "%04d" i) (100+i)) in + let leaf=node ~parent:(id 100) 500 in + let pages=projection (List.hd branches) [leaf] :: projection leaf [] :: List.map (fun b -> projection b []) (List.tl branches) in + let t=start (projection root branches) in + require (R.detail t=None) "zero count is assumed to be a leaf before its own read"; + let ready,calls=finish t pages in + require (List.length calls=128 && List.length (List.sort_uniq String.compare calls)=128) "unknown branches exceed attempt budget or repeat"; + require (List.length (text_ids ready)=130) "bounded attempt result loses discovered text"; + let outline=observe_outline (Option.get (R.detail ready)) in + require (D.next_automatic_load outline=None) "attempt cap resets at publication"; + require (List.exists (function D.More {enabled=true;_} -> true | _ -> false) (D.rows outline)) "attempt cap loses explicit continue action"); + "initial descendant failure and explicit fresh Retry", (fun () -> + let t=start small in + require (R.detail t=None) "partial body visible before failure"; + let pending,parent,generation=admit t in + let failed=R.apply_detail_failure ~block_id:parent pending ~request_generation:generation ~missing:true ~message:"descendant unavailable" in + require (R.route failed=Failed_detail "descendant unavailable" && R.detail failed=None && not (R.loading_indicator failed)) "descendant failure exposes partial body or wrong missing-root state"; + let retry,request=R.retry_detail failed ~request_generation:9600L in + require (Option.is_some request && not (R.loading_indicator retry)) "explicit Retry has no fresh initial grace period"; + require (R.apply_detail_response retry ~request_generation:generation (projection child [grandchild]) == retry) "late staging completion contaminates Retry"; + let ready,_=finish (R.apply_detail_response retry ~request_generation:9600L small) small_pages in + require (List.length (text_ids ready)=5) "Retry never publishes complete small tree"; + let prior = start (projection root []) in + let edited = D.update_child_source (Option.get (R.detail prior)) "Retained initial draft" in + let detached = R.back (R.update_detail prior edited) in + let reopened = R.open_detail detached ~block_id:(id 0) ~request_generation:9650L + |> fun t -> R.apply_detail_response t ~request_generation:9650L small in + let pending,parent,generation = admit reopened in + let failed = R.apply_detail_failure ~block_id:parent pending + ~request_generation:generation ~missing:false ~message:"initial branch failed" in + let retry,_ = R.retry_detail failed ~request_generation:9651L in + let restored,_ = finish (R.apply_detail_response retry ~request_generation:9651L small) small_pages in + require_string "Retained initial draft" + (Option.fold ~none:"" ~some:Journal_capture.source + (D.child_capture (Option.get (R.detail restored)))) + "initial staged failure retains the exact detached composer"); + "Back graph replacement and covered route fence staged reads", (fun () -> + let t=start (projection root [child]) in + require (R.detail t=None) "staging owner prematurely visible"; + let pending,_,generation=admit t in + let response=projection child [] in + let back=R.back pending in + require (R.apply_detail_response back ~request_generation:generation response==back) "Back accepts retired staging completion"; + let reset=R.graph_unavailable pending in + require (R.apply_detail_response reset ~request_generation:generation response==reset) "graph replacement accepts old staging completion"; + let b=node 90 in + let nested=R.open_detail pending ~block_id:(id 90) ~request_generation:9700L + |> fun t -> R.apply_detail_response t ~request_generation:9700L (projection b []) in + let ready=R.apply_detail_response nested ~request_generation:generation response in + require (text_ids ready=[id 90]) "covered completion replaces current target"; + require (text_ids (R.back ready)=[id 0;id 1]) "covered completed owner loses staged text"; + let replaced=R.runtime_replaced pending in + require (R.apply_detail_response replaced ~request_generation:generation response==replaced) "runtime replacement accepts old staged read"); + "asset-bearing text never waits for asset availability", (fun () -> + let asset=node ~parent:(id 0) ~source:"![local attachment](../assets/image.png)" 70 in + let t=start (projection root [asset]) in + require (R.detail t=None) "asset-bearing direct text publishes before initial staging"; + let ready,calls=finish t [projection asset []] in + require (calls=[id 70] && text_ids ready=[id 0;id 70]) "asset state gates non-asset body completion"); + "first-page continuation remains an explicit action", (fun () -> + let cursor : Journal_graph_projection.block_cursor = {after_sibling_order="a";after_block_id=id 1;protocol_cursor=None} in + let t=start (projection ~continuation:cursor root [child]) in + require (R.detail t=None) "continuation direct page publishes early"; + let ready,calls=finish t [projection child []] in + require (calls=[id 1]) "initial staging automatically follows a continuation"; + let outline=Option.get (R.detail ready) in + require (D.continuation outline ~parent_id:(id 0)=Some cursor) "staging discards pagination cursor"; + let _,commands=D.step outline (Load_more (id 0)) in + require (match commands with [Journal_graph_request.Load_detail {after=Some value;limit=128;_}] -> value=cursor | _ -> false) "explicit continuation cannot load next page"); + "100ms belongs to the whole initial body without a minimum delay", (fun () -> + let t=start (projection root [child]) in + require (R.route t=Detail_loading && not (R.loading_indicator t)) "root response ends the grace period with partial body"; + let identity=entry_id t in + let early=R.observe_loading_delay t ~entry_id:identity ~request_generation:9500L ~elapsed:0.099 in + require (early==t) "root response restarts or changes grace period"; + let slow=R.observe_loading_delay t ~entry_id:identity ~request_generation:9500L ~elapsed:0.1 in + require (R.loading_indicator slow) "staging at 100ms lacks initial indicator"; + let ready,_=finish slow [projection child []] in + require (not (R.loading_indicator ready)) "completed body retains indicator"; + let fast,_=finish t [projection child []] in + require (R.route fast=Detail && not (R.loading_indicator fast)) "completed small tree has a minimum artificial delay") + ] in + let failures=ref [] in + List.iter (fun (name,test) -> try test () with error -> + Printf.eprintf "INITIAL_STAGING_RED %s: %s\n%!" name (Printexc.to_string error); + failures:=name::!failures) cases; + require (!failures=[]) "Initial staging regressions: %s" (String.concat ", " (List.rev !failures)) +;; + +let test_initial_staging_refresh_and_delete () = + let module R = Journal_routes in + let module D = Journal_detail in + let id n = Printf.sprintf "98000000-0000-4000-8000-%012d" n in + let node ?parent n = + block ~id:(id n) ~parent_id:parent ~order:(Printf.sprintf "%08d" n) ~child_count:0 () + in + let page root blocks : Journal_graph_projection.detail = + { root; children = { blocks; continuation = None } } + in + let root = node 0 + and child = node ~parent:(id 0) 1 in + let start ?(routes = R.create ()) ?(generation = 500L) children = + R.open_detail routes ~block_id:(id 0) ~request_generation:generation + |> fun t -> + R.apply_detail_response t ~request_generation:generation (page root children) + in + let admit t = + match R.load_initial t with + | ( pending + , [ Journal_graph_request.Load_detail + { block_id; request_generation; limit = 128; after = None } + ] ) -> pending, block_id, request_generation + | _ -> fail "expected public initial admission" + in + let refresh t root blocks = + R.map_details t ~f:(fun d -> D.reconcile_children d (page root blocks)) + in + let ids t = + D.rows (Option.get (R.detail t)) + |> List.filter_map (function + | D.Block { block; _ } -> Some (Journal_model.id block) + | More _ -> None) + in + let children count parent first = List.init count (fun i -> node ~parent (first + i)) in + let finish t = + let rec drain t = + match R.initial_load t with + | None -> + require (R.route t = Detail) "staged body never published"; + t + | Some (_, parent) -> + let pending, _, generation = admit t in + let parent_node = node ~parent:(id 0) (int_of_string (String.sub parent 24 12)) in + drain + (R.apply_detail_response + pending + ~request_generation:generation + (page parent_node [])) + in + drain t + in + let cases = + [ ( "refresh counts root and blocks before another complete page" + , fun () -> + let branches = children 128 (id 0) 100 in + let descendants = children 128 (id 100) 300 in + let pending, parent, generation = admit (start branches) in + require_string (id 100) parent "first branch"; + let ready = refresh pending (List.hd branches) descendants in + require + (R.route ready = Detail && List.length (ids ready) = 257) + "refresh bypasses root-inclusive 384-block reservation"; + require + (R.initial_load ready = None && snd (R.load_initial ready) = []) + "refresh allows 385th initial block"; + require + (R.apply_detail_response + ready + ~request_generation:generation + (page (List.hd branches) descendants) + == ready) + "late refreshed read is accepted"; + require + (D.next_automatic_load (observe_outline (Option.get (R.detail ready))) = None) + "publication resets refresh budget"; + require + (List.length (snd (D.step (Option.get (R.detail ready)) (Load_more (id 300)))) + = 1) + "budget stop loses explicit continuation" ) + ; ( "unrelated refresh cannot consume a pending page reservation" + , fun () -> + let branches = children 128 (id 0) 100 in + let pending, _, generation = admit (start branches) in + let refreshed = + refresh pending (List.nth branches 1) (children 128 (id 101) 600) + in + let late = + R.apply_detail_response + refreshed + ~request_generation:generation + (page (List.hd branches) (children 128 (id 100) 300)) + in + require + (late == refreshed && R.route late = Detail && List.length (ids late) = 257) + "unrelated refresh leaves an admitted page able to publish %d blocks" + (List.length (ids late)); + require + (List.length (snd (D.step (Option.get (R.detail late)) (Load_more (id 100)))) + = 1) + "cancelled page loses explicit continuation" ) + ; ( "multiple refreshes accumulate without duplicate charge or attempt reset" + , fun () -> + let first = children 32 (id 0) 100 in + let current = ref (start first) + and expected = ref (List.hd first) in + for depth = 1 to 7 do + let pending, parent, generation = admit !current in + require_string (Journal_model.id !expected) parent "cumulative first branch"; + let values = children 32 parent (1000 * depth) in + let next = refresh pending !expected values in + let duplicate = refresh next !expected values in + require + (R.apply_detail_response + duplicate + ~request_generation:generation + (page !expected values) + == duplicate) + "refresh late completion changed owner"; + current := duplicate; + expected := List.hd values; + if depth < 7 + then + require + (R.route !current = Detail_loading) + "duplicate refresh is charged as another materialization" + done; + require + (R.route !current = Detail + && List.length (ids !current) = 257 + && R.initial_load !current = None) + "multi-refresh initial budget permits 385/1153 blocks" ) + ; ( "empty refreshes preserve consumed automatic attempts" + , fun () -> + let first = children 128 (id 0) 100 in + let current = ref (start first) in + for i = 0 to 127 do + let pending, parent, _ = admit !current in + require_string (id (100 + i)) parent "empty refresh attempt"; + current := refresh pending (List.nth first i) [] + done; + require + (R.route !current = Detail + && R.initial_load !current = None + && List.length (ids !current) = 129) + "refresh resets attempts or charges unchanged root" ) + ; ( "attempt-cap root Undo publishes immediately without refunding budgets" + , fun () -> + let run routes generation expected_draft = + let branches = children 128 (id 0) 100 in + let leaf = node ~parent:(id 100) 500 in + let pending, _, _ = admit (start ~routes ~generation branches) in + let current = ref (refresh pending (List.hd branches) [ leaf ]) in + let pending, _, _ = admit !current in + current := refresh pending leaf []; + for number = 101 to 225 do + let pending, parent, _ = admit !current in + require_string (id number) parent "spent initial attempt"; + current := refresh pending (List.nth branches (number - 100)) [] + done; + let pending, parent, old = admit !current in + require_string (id 226) parent "128th pending initial read"; + let missing, undo = R.stage_delete pending ~block_id:(id 0) in + let restored = R.undo_delete missing (Option.get undo) in + require + (R.route restored = Detail && Option.is_some (R.detail restored)) + "attempt-exhausted Undo leaves ready data in Loading"; + require + (List.length (ids restored) = 130 + && R.initial_load restored = None + && R.load_initial restored = (restored, [])) + "Undo resets budgets or loses bounded rows"; + require + (R.apply_detail_response + restored + ~request_generation:old + (page (List.nth branches 126) []) + == restored) + "exhausted Undo accepts cancelled read"; + let timed = + R.observe_loading_delay + restored + ~entry_id:(entry_id restored) + ~request_generation:generation + ~elapsed:0.1 + in + require + (timed == restored && not (R.loading_indicator timed)) + "Undo hangs after 100ms"; + let outline = observe_outline (Option.get (R.detail restored)) in + require (D.next_automatic_load outline = None) "Undo refunds spent attempts"; + require + (List.length (snd (D.step outline (Load_more (id 227)))) = 1) + "attempt-cap Undo loses explicit continuation"; + require + (Option.map Journal_capture.source (D.child_capture outline) + = expected_draft) + "attempt-cap settlement loses the exact retained draft" + in + let failures = ref [] in + let check name test = + try test () with + | error -> + Printf.eprintf "ATTEMPT_CAP_RED %s: %s\n%!" name (Printexc.to_string error); + failures := name :: !failures + in + check "without draft" (fun () -> run (R.create ()) 500L None); + check "retained draft" (fun () -> + let prior = start [] in + let edited = + D.update_child_source (Option.get (R.detail prior)) "Ready-at-cap draft" + in + let detached = R.back (R.update_detail prior edited) in + run detached 800L (Some "Ready-at-cap draft")); + require + (!failures = []) + "Attempt-cap Undo variants: %s" + (String.concat ", " !failures) ) + ; ( "root deletion fences pending completion and Undo uses fresh generation" + , fun () -> + let pending, _, old = admit (start [ child ]) in + let missing, undo = R.stage_delete pending ~block_id:(id 0) in + require + (R.route missing = Missing_detail + && Option.is_some undo + && not (R.loading_indicator missing)) + "staged root deletion is ignored"; + require + (R.apply_detail_response missing ~request_generation:old (page child []) + == missing) + "deleted root is republished by late completion"; + let restored = R.undo_delete missing (Option.get undo) in + let pending, _, fresh = admit restored in + require (fresh <> old) "Undo reuses cancelled initial generation"; + require + (R.apply_detail_response pending ~request_generation:old (page child []) + == pending) + "Undo accepts cancelled completion"; + let ready = + R.apply_detail_response pending ~request_generation:fresh (page child []) + in + require (ids ready = [ id 0; id 1 ]) "Undo cannot finish fresh initial body" ) + ; ( "sibling deletion preserves an unaffected pending initial read" + , fun () -> + let tail = node ~parent:(id 0) 2 in + let pending, _, generation = admit (start [ child; tail ]) in + let hidden, undo = R.stage_delete pending ~block_id:(id 2) in + require + (Option.is_some undo && R.initial_load hidden = None && R.detail hidden = None) + "sibling deletion cancels and repeats an unrelated read"; + let ready = + R.apply_detail_response hidden ~request_generation:generation (page child []) + in + require + (ids ready = [ id 0; id 1 ]) + "unaffected completion cannot publish the surviving body" ) + ; ( "descendant deletion removes subtree and Undo fences its old read" + , fun () -> + let tail = node ~parent:(id 0) 2 + and grandchild = node ~parent:(id 1) 3 in + let pending, _, generation = admit (start [ child; tail ]) in + let staged = + R.apply_detail_response + pending + ~request_generation:generation + (page child [ grandchild ]) + in + let pending, parent, old = admit staged in + require_string (id 3) parent "grandchild read"; + let hidden, undo = R.stage_delete pending ~block_id:(id 1) in + require + (Option.is_some undo && Option.map snd (R.initial_load hidden) = Some (id 2)) + "deletion leaves subtree read pending"; + require + (R.apply_detail_response hidden ~request_generation:old (page grandchild []) + == hidden) + "removed subtree is revived"; + let ready = finish hidden in + require (ids ready = [ id 0; id 2 ]) "deleted subtree remains in published text"; + let restored = R.undo_delete hidden (Option.get undo) in + let pending, _, fresh = admit restored in + require + (fresh <> old + && R.apply_detail_response + pending + ~request_generation:old + (page grandchild []) + == pending) + "descendant Undo accepts old read"; + let ready = + finish + (R.apply_detail_response + pending + ~request_generation:fresh + (page grandchild [])) + in + require + (ids ready = [ id 0; id 1; id 3; id 2 ]) + "Undo loses subtree or siblings" ) + ; ( "descendant Undo cannot restore a subsequently deleted staged root" + , fun () -> + let tail = node ~parent:(id 0) 2 in + let pending, _, old = admit (start [ child; tail ]) in + let hidden, child_undo = R.stage_delete pending ~block_id:(id 1) in + let missing, root_undo = R.stage_delete hidden ~block_id:(id 0) in + require + (R.route missing = Missing_detail + && Option.is_some child_undo + && Option.is_some root_undo) + "delete sequence lacks owned Undo targets"; + require + (R.undo_delete missing (Option.get child_undo) == missing) + "descendant Undo revives a deleted root through same entry generation"; + let restored = + R.undo_delete missing (Option.get root_undo) + |> fun t -> R.undo_delete t (Option.get child_undo) + in + let pending, _, fresh = admit restored in + require + (fresh <> old + && R.apply_detail_response pending ~request_generation:old (page child []) + == pending) + "ordered root/subtree Undo accepts cancelled read"; + let ready = + finish + (R.apply_detail_response pending ~request_generation:fresh (page child [])) + in + require + (ids ready = [ id 0; id 1; id 2 ]) + "ordered Undo cannot restore the surviving body" ) + ; ( "delete Undo respects covered entry Back graph and retry identities" + , fun () -> + let pending, _, old = admit (start [ child ]) in + let target = node 90 in + let covered = + R.open_detail pending ~block_id:(id 90) ~request_generation:600L + |> fun t -> + R.apply_detail_response t ~request_generation:600L (page target []) + in + let hidden, undo = R.stage_delete covered ~block_id:(id 0) in + require + (ids hidden = [ id 90 ] && R.route (R.back hidden) = Missing_detail) + "covered deletion replaces active entry or leaves deleted root"; + require + (R.apply_detail_response hidden ~request_generation:old (page child []) + == hidden) + "covered deleted read is accepted"; + let absent = R.pop_to_root hidden in + require + (R.undo_delete absent (Option.get undo) == absent) + "Undo revives popped owner"; + let absent = R.graph_unavailable hidden in + require + (R.undo_delete absent (Option.get undo) == absent) + "Undo revives owner from prior graph"; + let missing = R.back hidden in + let retry, _ = R.retry_detail missing ~request_generation:700L in + require + (R.undo_delete retry (Option.get undo) == retry) + "old Undo replaces fresh initial retry"; + require + (R.apply_detail_response retry ~request_generation:old (page child []) + == retry) + "deleted read contaminates retry" ) + ; ( "missing staged root preserves the detached draft through Retry and Undo" + , fun () -> + let prior = start [] in + let edited = + D.update_child_source + (Option.get (R.detail prior)) + "Retained across staged deletion" + in + let detached = R.back (R.update_detail prior edited) in + let pending, _, old = + admit (start ~routes:detached ~generation:800L [ child ]) + in + let missing, undo = R.stage_delete pending ~block_id:(id 0) in + require + (R.route missing = Missing_detail && Option.is_some undo) + "draft owner root deletion is ignored"; + let retry, _ = R.retry_detail missing ~request_generation:801L in + let ready = + finish + (R.apply_detail_response + retry + ~request_generation:801L + (page root [ child ])) + in + require_string + "Retained across staged deletion" + (Journal_capture.source + (Option.get (D.child_capture (Option.get (R.detail ready))))) + "missing Retry loses original draft"; + let restored = finish (R.undo_delete missing (Option.get undo)) in + require_string + "Retained across staged deletion" + (Journal_capture.source + (Option.get (D.child_capture (Option.get (R.detail restored))))) + "root Undo loses original draft"; + require + (R.apply_detail_response restored ~request_generation:old (page child []) + == restored) + "restored draft accepts stale initial completion"; + let committed, _ = R.commit_delete missing ~block_id:(id 0) in + let reopened = start ~routes:(R.pop_to_root committed) ~generation:802L [] in + require + (D.child_capture (Option.get (R.detail reopened)) = None) + "terminal deletion retains retired draft" ) + ] + in + let failures = ref [] in + List.iter + (fun (name, test) -> + try test () with + | error -> + Printf.eprintf "STAGING_REVIEW_RED %s: %s\n%!" name (Printexc.to_string error); + failures := name :: !failures) + cases; + require + (!failures = []) + "Staging review regressions: %s" + (String.concat ", " (List.rev !failures)) +;; + let tests = + [ "Initial staging refresh and delete review regressions", test_initial_staging_refresh_and_delete ] @ + [ "Detail bounded initial nested body staging", test_detail_initial_nested_body_staging ] @ + [ "Detail initial loading delay and ownership", test_detail_initial_loading_delay_and_ownership ] @ [ "Unicode blank Capture admission", test_unicode_blank_capture_admission ] @ [ "PR63 public review regressions", test_followup_review_regressions diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 3be45cb..84f25cd 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -1449,7 +1449,7 @@ let check_stable_image_slots ~entry ~images ~width ~height = ignore (Lui_app.flush app); check ()) [ views (Placeholder "Waiting for file") 1 - ; views (Placeholder "Opening file") (List.length images) + ; views (Placeholder "Loading") (List.length images) ; views (File "/tmp/synthetic-first.png") 1 ; views (File "/tmp/synthetic-first.png") (List.length images) ; views Hidden (List.length images) @@ -1682,7 +1682,7 @@ let test_reactive_child_gallery_routes_and_preserves_siblings () = (node PathValue "/tmp/synthetic-second.jpg" = sibling) "availability replaced the sibling image") [ Journal_media.Hidden - ; Placeholder "Opening file" + ; Placeholder "Loading" ; File "/tmp/reactive-child.png" ]; assert_event (asset_id first) first.token; @@ -1804,13 +1804,13 @@ let test_reactive_nonimage_structure_and_presentation () = "structure removal retained old chrome"; update (Some - (media_state [ { rebound with presentation = Placeholder "Opening file" } ])); + (media_state [ { rebound with presentation = Placeholder "Loading" } ])); require - (current_text (ops ()) "Opening file") + (current_text (ops ()) "Loading") "replacement token did not get a fresh item subscription"; update None; require - ((not (current_text (ops ()) "Opening file")) + ((not (current_text (ops ()) "Loading")) && not (current_text (ops ()) "Retry")) "empty structure kept an attachment item"; require @@ -2387,22 +2387,21 @@ let test_detail_three_tiers_keep_deep_order_and_plain_rows () = |> fun t -> Journal_routes.open_detail t ~block_id ~request_generation:1L |> fun t -> Journal_routes.apply_detail_response t ~request_generation:1L (projection root [ child; sibling ]) in - let load detail parent children = - let detail, requests = Journal_detail.step detail (Load_more (Journal_model.id parent)) in - let generation = match requests with - | [ Journal_graph_request.Load_detail { limit = 128; request_generation; _ } ] -> request_generation - | _ -> fail "Expanding one branch must issue exactly one bounded page read" - in - fst (Journal_detail.step detail (Loaded (generation, projection parent children))) - in - let detail = Option.get (Journal_routes.detail routes) - |> fun t -> load t child [ grandchild ] - |> fun t -> load t grandchild [ deep ] - |> fun t -> load t deep [ deeper ] - |> fun t -> load t deeper [] - |> fun t -> load t sibling [] - in - let routes = Journal_routes.update_detail routes detail in + let pages = + [ projection child [grandchild]; projection grandchild [deep] + ; projection deep [deeper]; projection deeper []; projection sibling [] ] in + let rec complete routes = + match Journal_routes.initial_load routes with + | None -> routes + | Some (_, parent) -> + let routes, requests = Journal_routes.load_initial routes in + let generation = match requests with + | [Journal_graph_request.Load_detail {limit=128;request_generation;_}] -> request_generation + | _ -> fail "Initial composition must use a bounded owned read" in + let page = List.find (fun page -> Journal_model.id page.Journal_graph_projection.root=parent) pages in + complete (Journal_routes.apply_detail_response routes ~request_generation:generation page) + in + let routes = complete routes in let body = Application.For_testing.detail_page ~routes ~write_enabled:true ignore in with_mounted (V.Body.Private.to_widget body) (fun _ ops -> let payloads = List.filter_map (function @@ -2434,6 +2433,12 @@ let test_detail_flat_branch_has_no_collapse_action () = let routes = Journal_routes.create () |> fun t -> Journal_routes.open_detail t ~block_id ~request_generation:1L |> fun t -> Journal_routes.apply_detail_response t ~request_generation:1L projection in + let routes, requests = Journal_routes.load_initial routes in + let generation = match requests with + | [Journal_graph_request.Load_detail {request_generation;_}] -> request_generation + | _ -> fail "Flat child composition needs one initial read" in + let routes = Journal_routes.apply_detail_response routes ~request_generation:generation + {root=child;children={blocks=[];continuation=None}} in let actions = ref [] in let body = Application.For_testing.detail_page ~routes ~write_enabled:true (fun a -> actions := a :: !actions) in with_mounted (V.Body.Private.to_widget body) (fun app ops -> @@ -2520,7 +2525,41 @@ let test_bottom_controls_reuse_lui_inset_and_keep_capture () = require !pressed "native inset swallowed the shared Capture event") ;; + +let test_detail_initial_blank_preserves_chrome () = + let routes = Journal_routes.open_detail (Journal_routes.create ()) ~block_id ~request_generation:700L in + let body = Application.For_testing.detail_page ~routes ~write_enabled:true ignore in + with_mounted (V.Body.Private.to_widget body) (fun _ ops -> + require (not (current_text (ops ()) "Loading block")) "initial grace period renders loading"; + (* Native Chrome requires two positional children even for a blank body; + the pure route owner cannot reproduce this mounting contract. *) + let header = List.find_map (function + | Lui_protocol.SetExtensionProp (id, "payload", StringValue value) + when Yojson.Basic.Util.member "mode" (Yojson.Basic.from_string value) + = `String "detail" -> Some id + | _ -> None) (ops ()) |> Option.get in + let slots = List.filter_map (function + | Lui_protocol.InsertChild (parent, child, _) when parent = header -> Some child + | _ -> None) (ops ()) in + require (List.length slots = 2) "blank body removes native Detail Chrome"; + require (List.length (mounted_nodes (ops ()) AccessibilityLabel (StringValue "Block options")) = 1) "initial grace period drops the existing menu") +;; + +(* Known graph asset identity must reserve its slot before media descriptors or + files arrive. This composition is not reproducible in the route reducer. *) +let test_detail_known_image_reserves_initial_slot () = + let root = Journal_model.with_asset_file_type (block ~source:"Image attachment" ()) ~asset_file_type:(Some "jpg") in + let routes = Journal_routes.open_detail (Journal_routes.create ()) ~block_id ~request_generation:701L in + let routes = Journal_routes.apply_detail_response routes ~request_generation:701L {root;children={blocks=[];continuation=None}} in + let body = Application.For_testing.detail_page ~routes ~write_enabled:true ignore in + with_mounted (V.Body.Private.to_widget body) (fun _ ops -> + require (List.length (mounted_nodes (ops ()) AccessibilityIdentifier (StringValue ("journal-image-slot:" ^ block_id))) = 1) "Detail known image has no stable slot before descriptor arrival"; + require (current_text (ops ()) "Waiting for file") "known slot does not explain pending media") +;; + let tests = + [ "Detail initial blank preserves chrome", test_detail_initial_blank_preserves_chrome + ; "Detail known image initial slot", test_detail_known_image_reserves_initial_slot ] @ [ "Feedback native inset visibility and events", test_feedback_reuses_lui_inset_and_keeps_control_events ; "Bottom native inset retains Capture", test_bottom_controls_reuse_lui_inset_and_keep_capture ] @ diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index bd21599..aa8b64f 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1125,7 +1125,7 @@ let () = (* Journal uses the upstream native host ABI decoder; keep the OCaml installation and Apple package on the same validated revision. *) let compatible_lui_revision = - "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044" + "git+https://github.com/logseq/lui.git#4fdb9136026af7863a8d025133871e34864eace1" in List.iter (fun relative -> require_occurrences root relative compatible_lui_revision 1) @@ -1279,7 +1279,7 @@ let () = ; "V.composer" ; "if favorites_selected" ; "Detail_list.view" - ; "V.progress" + ; "Journal_header.startup" ]; require_text root @@ -1586,7 +1586,7 @@ let () = require_text root "swift/App.swift" - [ "JournalRuntimeHost(" + [ "JournalHost(" ; "applicationShouldTerminate" ; ".terminateLater" ; "reply(toApplicationShouldTerminate:" @@ -1596,6 +1596,14 @@ let () = root "swift/JournalApplicationPlatform.swift" [ "JournalPlatformEvents"; "beginShutdown"; "terminationReady" ]; + (* The app scene delegates native startup/account presentation to the host, + which still owns the RuntimeHost and native progress component. *) + require_text root "swift/JournalRuntimeHost.swift" + [ "struct JournalHost: View" + ; "JournalRuntimeHost(platform:" + ; "JournalStartupLoadingSurface(" + ; "ProgressView()" + ]; forbid_text root "swift/App.swift" [ "Darwin.exit" ]; require_text root diff --git a/test/startup_test.ml b/test/startup_test.ml index 4a2a665..c3b297e 100644 --- a/test/startup_test.ml +++ b/test/startup_test.ml @@ -234,7 +234,124 @@ let test_deletion_failure_has_no_recovery () = "deletion failure offered recovery" ;; +let test_stable_startup_presentation () = + let open Journal_startup in + let module P = Journal_startup.Presentation in + let observe ?manager ?(graph=graph_state ()) ?(graph_ready=false) + ?(feed_loaded=false) ?(password_pending=false) ?(graph_error=false) t = + P.step t (Observe {manager;graph;graph_ready;feed_loaded;password_pending;graph_error}) in + let timer t = Option.get (P.loading_request t) in + let account t result = P.step t (Account (P.account_ticket t,result)) in + let cases=[ + "anonymous local restore is not signed out",(fun () -> + let phase=Journal_startup.derive ~snapshot:(startup_snapshot ~authenticated:false ~restoring_local:true ()) + ~graph:(graph_state ~phase:Graph_closed ()) in + require (phase.phase=Restoring_local) "local restore flashes signed-out UI"; + let phase=Journal_startup.derive ~snapshot:(startup_snapshot ~authenticated:false ~failure:During_local_restore ()) + ~graph:(graph_state ~phase:Graph_closed ()) in + require (phase.phase=Failed) "anonymous restore failure hides recovery"); + "initial and unresolved account stay blank",(fun () -> + let t=P.create () in + require (P.screen t=Waiting && not(P.loading_indicator t) && P.loading_request t<>None) + "initial startup mounts transient Timeline or immediate spinner"; + let t=observe ~manager:(startup_snapshot ~authenticated:false ()) ~graph:(graph_state ~phase:Graph_closed ()) t in + require (P.screen t=Waiting) "account discovery flashes sign-in"; + let t=account t Local in + let t=observe ~manager:(startup_snapshot ~authenticated:false ~restoring_local:true ()) ~graph:(graph_state ~phase:Graph_closed ()) t in + require (P.screen t=Waiting) "local binding restore flashes intermediate page"); + "one 100ms grace spans catalog restore and first feed",(fun () -> + let t=observe ~manager:(startup_snapshot ~catalog_loading:true ()) ~graph:(graph_state ~phase:Graph_closed ()) (P.create ()) in + let key=timer t in + let early=P.step t (Elapsed(key,0.099)) in + require (not(P.loading_indicator early)) "99ms shows loading"; + let t=observe ~manager:(startup_snapshot ~restoring_local:true ()) early in + require (timer t=key) "busy phase restarts grace"; + let slow=P.step t (Elapsed(key,0.1)) in + require (P.screen slow=Waiting && P.loading_indicator slow) "100ms waiting has no loading"; + require (P.step slow (Elapsed(key,0.2))==slow) "duplicate elapsed changes owner"; + let ready=observe ~manager:(startup_snapshot ()) ~graph_ready:true ~feed_loaded:true slow in + require (P.screen ready=Timeline && not(P.loading_indicator ready)) "ready body retains loading"; + require (P.step ready (Elapsed(key,2.))==ready) "late timer changes ready Timeline"); + "Graph_open waits for first feed and an empty feed completes",(fun () -> + let t=observe ~manager:(startup_snapshot ()) ~graph_ready:true (P.create ()) in + require (P.screen t=Waiting) "Graph_open mounts feed's transient loading page"; + let ready=observe ~manager:(startup_snapshot ~timeline_presentation_pending:true ()) + ~graph_ready:true ~feed_loaded:true t in + require (P.screen ready=Timeline) "presentation handshake or empty feed deadlocks Timeline"; + let refreshing=observe ~manager:(startup_snapshot ~sync_phase:Pulling ~catalog_loading:true ()) + ~graph_ready:true ~feed_loaded:true ready in + require (P.screen refreshing=Timeline) "cached background refresh hides existing content"); + "required input and errors remain immediately available",(fun () -> + let t=account (P.create ()) No_user in + require (match P.screen t with Interaction {phase=Signed_out;_}->true|_->false) "signed-out result is hidden"; + List.iter(fun phase -> + let snapshot=match phase with + | Awaiting_selection -> startup_snapshot ~awaiting_selection:true () + | Awaiting_e2ee_password -> startup_snapshot ~awaiting_e2ee_password:true () + | _ -> startup_snapshot ~failure:During_bootstrap () in + let t=observe ~manager:snapshot ~graph:(graph_state ~phase:Graph_closed ()) (P.create ()) in + require (match P.screen t with Interaction s->s.phase=phase|_->false) "required input/failure is masked"; + require (not(P.loading_indicator t)) "required input is covered by startup loading") + [Awaiting_selection;Awaiting_e2ee_password;Failed]); + "unlock submission retains its form and does not remount busy page",(fun () -> + let t=observe ~manager:(startup_snapshot ~awaiting_e2ee_password:true ()) + ~graph:(graph_state ~phase:Graph_closed ()) (P.create ()) in + let pending=observe ~manager:(startup_snapshot ~bootstrapping:true ()) + ~graph:(graph_state ~phase:Graph_closed ()) ~password_pending:true t in + require (match P.screen pending with Interaction {phase=Awaiting_e2ee_password;_}->true|_->false) + "password submission flashes a different waiting page"); + "account lookup failure Retry and stale replies",(fun () -> + let initial=P.create () in let old=P.account_ticket initial in + let failed=P.step initial (Account_error old) in + require (P.screen failed=Account_failure) "lookup failure has no explicit recovery"; + let retry=P.step failed Retry in + require (P.screen retry=Waiting && not(P.loading_indicator retry) && P.account_ticket retry<>old) + "Retry does not start fresh discovery/grace"; + require (P.step retry (Account(old,No_user))==retry) "old account result replaces retry"; + require (P.step retry (Account_error old)==retry) "old lookup failure replaces retry"); + "local offline recovery is independent of online auth and assets",(fun () -> + let t=account (P.create ()) Local in + let t=P.step t (Account_error(P.account_ticket t)) in + let ready=observe ~manager:(startup_snapshot ~sync_phase:Offline ()) + ~graph_ready:true ~feed_loaded:true t in + require (P.screen ready=Timeline) "valid cached Timeline waits for online auth/assets"); + "graph replacement rejects old ready data and old timer",(fun () -> + let ready=observe ~manager:(startup_snapshot ()) ~graph_ready:true ~feed_loaded:true (P.create ()) in + let other={ (startup_snapshot ~graph_generation:8 ()) with selected_graph=Some graph_id } in + let waiting=observe ~manager:other ~graph:(graph_state ~generation:7 ()) + ~graph_ready:true ~feed_loaded:true ready in + require (P.screen waiting=Waiting) "stale graph falsely publishes ready data"; + let key=timer waiting in + let picker=observe ~manager:(startup_snapshot ~awaiting_selection:true ~graph_generation:8 ()) + ~graph:(graph_state ~phase:Graph_closed ~generation:8 ()) waiting in + let fresh=observe ~manager:(startup_snapshot ~graph_generation:9 ~restoring_local:true ()) + ~graph:(graph_state ~phase:Graph_opening ~generation:9 ()) picker in + require (timer fresh<>key && P.step fresh (Elapsed(key,1.))==fresh) + "old graph journey timer enables new loading"); + "background interruption fences elapsed and resumes a fresh grace",(fun () -> + let t=observe ~manager:(startup_snapshot ~restoring_local:true ()) (P.create ()) in + let old=timer t in let inactive=P.step t (Foreground false) in + require (P.loading_request inactive=None && P.step inactive (Elapsed(old,1.))==inactive) + "background keeps an active loading timer"; + let resumed=P.step inactive (Foreground true) in + require (timer resumed<>old && not(P.loading_indicator resumed)) "resume reuses old grace"; + require (P.step resumed (Elapsed(old,1.))==resumed) "late background timer changes resume"); + "negative/NaN elapsed and explicit signout are safe",(fun () -> + let t=observe ~manager:(startup_snapshot ~restoring_local:true ()) (P.create ()) in + let key=timer t in + require (P.step t (Elapsed(key,-1.))==t && P.step t (Elapsed(key,nan))==t) "invalid elapsed changes owner"; + let signed=P.step t P.Signed_out in + require (match P.screen signed with Interaction {phase=Signed_out;_}->true|_->false) "signout entry is hidden"; + require (P.step signed (Elapsed(key,2.))==signed) "signout leaves stale timer active") + ] in + let failures=ref [] in + List.iter(fun(name,test)->try test () with error-> + Printf.eprintf "STARTUP_RED %s: %s\n%!" name (Printexc.to_string error);failures:=name::!failures) cases; + require (!failures=[]) "Startup regressions: %s" (String.concat ", " (List.rev !failures)) +;; + let () = + test_stable_startup_presentation (); test_deletion_failure_has_no_recovery (); test_exact_codec_and_round_trip (); test_bounded_rejection ();