diff --git a/app/application.ml b/app/application.ml index c64c413a..bc4be9d6 100644 --- a/app/application.ml +++ b/app/application.ml @@ -147,19 +147,7 @@ module Admission_refresh = struct let observation state = state.observation end -type delete_phase = - | Undoable - | Committing - -type pending_delete = - { mutation_id : string - ; block_id : string - ; expected_revision : string - ; staged : Journal_timeline_state.staged_delete option - ; detail_staged : Journal_routes.staged_delete option - ; deadline : Core.Time_ns.t - ; phase : delete_phase - } +type pending_delete = Journal_graph_projection.delete_subtree type pending_status = { mutation_id : string @@ -169,7 +157,6 @@ type pending_status = } type timeline_notice = - | Delete_undo | Delete_failed of string | Status_failed of string | Copy_failed of string @@ -180,7 +167,7 @@ let operation_failure ?(can_retry_imports = false) = function Some ( "Delete failed" , message - , "The block has been restored. Review it before trying Delete again." ) + , "The block is kept. Review it before trying Delete again." ) | Some (Status_failed message) -> Some ( "Status not changed" @@ -198,7 +185,7 @@ let operation_failure ?(can_retry_imports = false) = function else "Your journal entry and other attachments are kept. Select the file again to \ retry." ) - | None | Some Delete_undo -> None + | None -> None ;; type feed_projection_context = @@ -855,46 +842,15 @@ let capture_failure_message = function | Local_capture_failure message -> message ;; -let restore_deleted state (pending : pending_delete) = - let timeline = - Option.fold - ~none:state.timeline - ~some:(Journal_timeline_state.undo_delete state.timeline) - pending.staged - in - let routes = - Option.fold - ~none:state.routes - ~some:(Journal_routes.undo_delete state.routes) - pending.detail_staged - in - favorites_event { state with timeline; routes } (Reveal_target pending.block_id) -;; - -let hide_deleted state (pending : pending_delete) = - let timeline, staged = - match - Journal_timeline_state.stage_delete state.timeline ~block_id:pending.block_id - with - | None -> state.timeline, pending.staged - | Some (timeline, staged) -> timeline, Some staged - in - let routes, detail_staged = - let routes, staged = - Journal_routes.stage_delete state.routes ~block_id:pending.block_id - in - ( routes - , match staged with - | Some _ -> staged - | None -> pending.detail_staged ) - in +let commit_deleted state block_id = + let routes, released = Journal_routes.commit_delete state.routes ~block_id in + List.iter Journal_asset_import.discard_staged_file released; favorites_event { state with - timeline - ; routes - ; pending_delete = Some { pending with staged; detail_staged } + routes + ; timeline = Journal_timeline_state.remove_block state.timeline ~block_id } - (Hide_target pending.block_id) + (Hide_target block_id) ;; let terminal_graph_state state graph_error = @@ -1034,7 +990,7 @@ let back_state state = { state with routes; timeline } ;; -let apply_worker_response_unstaged state (response : Journal_graph_runtime.response) = +let apply_worker_response state (response : Journal_graph_runtime.response) = match response.payload with | Projection_changes _ | Projection_resync -> state | Reference_sources_changed updates -> @@ -1289,11 +1245,7 @@ let apply_worker_response_unstaged state (response : Journal_graph_runtime.respo ~some:(Journal_timeline_state.replace_timeline_entry state.timeline) timeline_entry_update } - | Block_removed { block_id } -> - { state with - timeline = Journal_timeline_state.remove_block state.timeline ~block_id - ; routes = fst (Journal_routes.stage_delete state.routes ~block_id) - } + | Block_removed { block_id } -> commit_deleted state block_id | Page_tree_reconciled { page; value } -> { state with timeline = @@ -1370,11 +1322,9 @@ let apply_worker_response_unstaged state (response : Journal_graph_runtime.respo | Subtree_deleted { block_id; parent; timeline_entry_update; _ } -> (match state.pending_delete with | Some pending when String.equal pending.block_id block_id -> - let routes, released = Journal_routes.commit_delete state.routes ~block_id in - List.iter Journal_asset_import.discard_staged_file released; + let state = commit_deleted state block_id in { state with - routes - ; timeline = + timeline = Option.fold ~none: (Option.fold @@ -1390,8 +1340,7 @@ let apply_worker_response_unstaged state (response : Journal_graph_runtime.respo | Delete_conflict latest -> (match state.pending_delete with | None -> state - | Some pending -> - let state = restore_deleted state pending in + | Some _ -> let state = { state with routes = @@ -1448,7 +1397,7 @@ let apply_worker_response_unstaged state (response : Journal_graph_runtime.respo (match state.pending_delete with | Some pending when pending.mutation_id = mutation_id && pending.block_id = block_id -> - { (restore_deleted state pending) with + { state with pending_delete = None ; timeline_notice = Some (Delete_failed message) } @@ -1463,16 +1412,6 @@ let apply_worker_response_unstaged state (response : Journal_graph_runtime.respo | Projection_failure message -> Non_worker_sync_failure message) ;; -let apply_worker_response state (response : Journal_graph_runtime.response) = - match state.pending_delete, response.payload with - | None, _ | Some _, Subtree_deleted _ -> apply_worker_response_unstaged state response - | Some pending, _ -> - let state = apply_worker_response_unstaged (restore_deleted state pending) response in - (match state.pending_delete with - | None -> state - | Some pending -> hide_deleted state pending) -;; - module Root_navigation = struct type t = state @@ -1721,7 +1660,6 @@ type command = | Sign_out | Submit_e2ee_password | Request_local_cache_reset - | Delete_undo | Detail_retry | Asset_settings of string | Import_asset of string @@ -5372,7 +5310,6 @@ let start (Effect.of_thunk (fun () -> sync_media !state_ref)) ~f:(fun () -> flush_media set_state)) in - let delete_timer_generation = ref 0 in let schedule_after span thunk = ignore (Thread.create @@ -5405,37 +5342,6 @@ let start ~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 - let remaining = Core.Time_ns.(Span.to_sec (diff deadline (now ()))) in - schedule_after remaining (fun () -> - if !delete_timer_generation = generation - then - Effect.run - (set_state_and_effect (fun state -> - match state.pending_delete with - | Some ({ phase = Undoable; _ } as pending) - when String.equal pending.mutation_id mutation_id -> - let request : Journal_graph_projection.delete_subtree = - { mutation_id = pending.mutation_id - ; block_id = pending.block_id - ; expected_revision = pending.expected_revision - } - in - ( { state with - pending_delete = Some { pending with phase = Committing } - ; timeline_notice = None - } - , send (Journal_graph_request.Delete_subtree request) ) - | None | Some _ -> state, Effect.ignore))) - in - let delete_timer_key state = - match state.pending_delete with - | Some { mutation_id; deadline; phase = Undoable; _ } -> Some (mutation_id, deadline) - | Some _ | None -> None - in - let prev_delete_timer_key = ref (delete_timer_key initial_state) in let sync_error_timer_generation = ref 0 in let arm_sync_error_timer sequence = incr sync_error_timer_generation; @@ -5457,7 +5363,6 @@ let start in let handle_dispatch payload = let snapshot = !state_ref in - let current_time : Core.Time_ns.t Effect.t = fun () -> Core.Time_ns.now () in let update f = set_state f in let with_request next request = Effect.many [ update (fun _ -> next); send request ] @@ -6050,7 +5955,7 @@ let start | Some (Delete_failed _ | Status_failed _ | Copy_failed _ | Import_failed _) -> discard_import_batches state.failed_capture_imports; { state with timeline_notice = None; failed_capture_imports = [] } - | None | Some Delete_undo -> state) + | None -> state) | Close_error_info -> update (fun state -> { state with modal = No_modal }) | Switch_graph -> cancel_copy (); @@ -6132,15 +6037,6 @@ let start ~f:(fun () -> send_manager (Graph_service.Submit_e2ee_password password))) | Request_local_cache_reset -> update (fun state -> { state with graph_picker_open = true; modal = No_modal }) - | Delete_undo -> - update (fun state -> - match state.pending_delete with - | Some ({ phase = Undoable; _ } as pending) -> - { (restore_deleted state pending) with - pending_delete = None - ; timeline_notice = None - } - | None | Some { phase = Committing; _ } -> state) | Timeline_retry value -> (match int_of_string_opt value with | None -> Effect.ignore @@ -6318,22 +6214,15 @@ let start if saving then Effect.ignore else ( - let duration = - if snapshot.environment.accessible_navigation then 10. else 5. + let request : Journal_graph_projection.delete_subtree = + { mutation_id = fresh_identity () + ; block_id + ; expected_revision = Journal_model.revision block + } in - Effect.bind current_time ~f:(fun now -> - let pending = - { mutation_id = fresh_identity () - ; block_id - ; expected_revision = Journal_model.revision block - ; staged = None - ; detail_staged = None - ; deadline = Core.Time_ns.add now (Core.Time_ns.Span.of_sec duration) - ; phase = Undoable - } - in - update (fun state -> - hide_deleted { state with timeline_notice = Some Delete_undo } pending))) + with_direct_request + { snapshot with pending_delete = Some request; timeline_notice = None } + (Journal_graph_request.Delete_subtree request)) | _ -> Effect.ignore) | Copy_block block_id -> let exists = @@ -6386,42 +6275,6 @@ let start in if timeline == state.timeline then state else { state with timeline })))) in - let notice_token_sequence = ref 0L in - let notice_cancellation : int64 option ref = ref None in - let cancel_notice token = - emit_platform_request (Journal_platform.notice_cancel_request ~token) - in - let notice_callback ((undo_available, _capture_error), accessible_navigation) = - Option.iter cancel_notice !notice_cancellation; - notice_cancellation := None; - (* Composer failures are laid out above its controls. Only Undo uses - the platform notice, so an error never covers draft actions. *) - match undo_available with - | false -> () - | true -> - let token = Int64.succ !notice_token_sequence in - notice_token_sequence := token; - notice_cancellation := Some token; - let message, action_label, duration_ms = - ( "Block and descendants removed" - , Some "Undo" - , if accessible_navigation then 10_000 else 5_000 ) - in - emit_platform_request - (Journal_platform.show_notice_request ~token ~message ~action_label ~duration_ms) - ~k:(fun result -> - match result with - | Error _ -> () - | Ok payload -> - (match Journal_platform.decode_notice_response ~token payload with - | Ok Notice_action -> dispatch.send Delete_undo - | Ok (Notice_dismiss | Notice_swipe | Notice_timeout) | Error _ -> ())) - in - let notice_key state = - ( (state.graph_ready && state.timeline_notice = Some Delete_undo, state.capture_error) - , state.environment.accessible_navigation ) - in - let prev_notice_key = ref (notice_key initial_state) in (* Every [Edge.on_change] of the bonsai version becomes a post-update key comparison: the key is recomputed from the new model and the callback fires when it differs from the previous post-update value. *) @@ -6502,30 +6355,6 @@ let start (List.filter_map Fun.id rows)) roots; Effect.run (flush_media set_state))); - (let key = notice_key model in - if - not - ((fun (left_notice, left_accessible) (right_notice, right_accessible) -> - left_notice = right_notice && Bool.equal left_accessible right_accessible) - !prev_notice_key - key) - then ( - prev_notice_key := key; - notice_callback key)); - (let key = delete_timer_key model in - if - not - (Option.equal - (fun (left_id, left_deadline) (right_id, right_deadline) -> - String.equal left_id right_id - && Core.Time_ns.equal left_deadline right_deadline) - !prev_delete_timer_key - key) - then ( - prev_delete_timer_key := key; - match key with - | None -> incr delete_timer_generation - | Some (mutation_id, deadline) -> arm_delete_timer mutation_id deadline)); (let key = model.capture_imports in if not (Option.equal ( == ) !prev_capture_imports_key key) then ( diff --git a/app/journal_detail.ml b/app/journal_detail.ml index 63f96ff0..db3dcff2 100644 --- a/app/journal_detail.ml +++ b/app/journal_detail.ml @@ -56,14 +56,6 @@ type t = ; reveal_outcome : Ui.View.Native_list.outcome option } -type staged_delete = - { root_id : string - ; blocks : Journal_model.t Blocks.t - ; branches : branch Blocks.t - ; parent_id : string option - ; was_appended : bool - } - type event = | Observe_visible_range of int64 * int64 | Load_next @@ -747,7 +739,7 @@ let apply_child_created (t : t) ~child ~parent = else t ;; -let stage_delete ?(fence_reads = false) (t : t) ~block_id = +let remove_block (t : t) ~block_id = match Blocks.find_opt block_id t.blocks with | None -> None | Some block -> @@ -766,15 +758,6 @@ let stage_delete ?(fence_reads = false) (t : t) ~block_id = | None -> false) 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) -> @@ -804,63 +787,7 @@ let stage_delete ?(fence_reads = false) (t : t) ~block_id = blocks | None -> blocks in - Some - ( invalidate_rows { t with blocks; branches } - , { root_id = block_id - ; blocks = removed - ; branches = removed_branches - ; parent_id = Journal_model.parent_id block - ; was_appended = - Option.fold - ~none:false - ~some:(fun parent -> List.mem block_id (branch t parent).appended) - (Journal_model.parent_id block) - } ) -;; - -let undo_delete (t : t) (staged : staged_delete) = - let blocks = Blocks.union (fun _ current _ -> Some current) t.blocks staged.blocks in - let branches = - Blocks.union (fun _ current _ -> Some current) t.branches staged.branches - in - let branches, blocks = - match staged.parent_id with - | None -> branches, blocks - | Some id -> - let current = Option.value (Blocks.find_opt id branches) ~default:empty_branch in - if List.mem staged.root_id current.children - then branches, blocks - else ( - let children = - staged.root_id :: current.children - |> List.sort (fun a b -> - match Blocks.find_opt a blocks, Blocks.find_opt b blocks with - | Some a, Some b -> - String.compare - (Journal_model.sibling_order a) - (Journal_model.sibling_order b) - | _ -> String.compare a b) - in - ( Blocks.add - id - { current with - children - ; appended = - (if staged.was_appended - then staged.root_id :: current.appended - else current.appended) - } - branches - , Blocks.update - id - (Option.map (fun parent -> - Journal_model.with_child_count - parent - ~child_count:(Journal_model.child_count parent + 1) - |> Result.get_ok)) - blocks )) - in - invalidate_rows { t with blocks; branches } + Some (invalidate_rows { t with blocks; branches }) ;; let reveal_outcome t = t.reveal_outcome diff --git a/app/journal_detail.mli b/app/journal_detail.mli index 8e01cca3..e1cfc110 100644 --- a/app/journal_detail.mli +++ b/app/journal_detail.mli @@ -6,7 +6,6 @@ type mode = | Failed of string type t -type staged_delete type row = | Block of @@ -101,15 +100,11 @@ val reconcile_children : t -> Journal_graph_projection.detail -> t 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 -(** 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 +(** Remove a committed subtree from the materialized outline. Unrelated pending + reads retain their ownership; removed branches cannot accept completions. *) +val remove_block : t -> block_id:string -> t option (** Only composer ownership is retained; reopening loads a fresh outline projection. *) type retained_composer diff --git a/app/journal_platform.ml b/app/journal_platform.ml index d4319dd9..9efb12e1 100644 --- a/app/journal_platform.ml +++ b/app/journal_platform.ml @@ -107,11 +107,6 @@ module Json = struct type sign_out = { signedOut : bool } [@@deriving yojson] type ready = { ready : bool } [@@deriving yojson] type presented = { presented : bool } [@@deriving yojson] - type notice = - { token : int64; message : string; actionLabel : string option [@default None]; durationMs : int } - [@@deriving yojson] - type notice_result = { token : int64; result : string } [@@deriving yojson] - type cancel = { token : int64 } [@@deriving yojson] type text = { text : string } [@@deriving yojson] type copied = { copied : bool } [@@deriving yojson] end @@ -205,8 +200,6 @@ let decode_termination_ready_response bytes = if wire.ready then Ok () else Error "termination-ready response fields are invalid" ;; -type notice_result = Notice_action | Notice_dismiss | Notice_swipe | Notice_timeout - let decode_environment_event bytes = Result.bind (decode_envelope [24] bytes) (fun payload -> match Yojson.Basic.from_string (Bytes.to_string payload) |> Journal_environment.decode_json with @@ -217,29 +210,6 @@ let decode_environment_event bytes = let is_environment_event bytes = Result.is_ok (decode_envelope [24] bytes) -let show_notice_request ~token ~message ~action_label ~duration_ms = - json_request 25 Json.notice_to_yojson - { token; message; actionLabel = action_label; durationMs = duration_ms } - |> Result.get_ok -;; - -let decode_notice_response ~token bytes = - let ( let* ) = Result.bind in - let* payload = decode_envelope [26] bytes in - let* wire = decode_json "notice response" Json.notice_result_of_yojson payload in - if wire.token <> token then Error "notice response token is unsolicited" - else match wire.result with - | "action" -> Ok Notice_action - | "dismiss" -> Ok Notice_dismiss - | "swipe" -> Ok Notice_swipe - | "timeout" -> Ok Notice_timeout - | _ -> Error "notice response result is unsupported" -;; - -let notice_cancel_request ~token = - json_request 27 Json.cancel_to_yojson { token } |> Result.get_ok -;; - let copy_text_request ~text = if not (Journal_validation.is_valid_utf_8 text) then Error "Clipboard text is invalid." diff --git a/app/journal_platform.mli b/app/journal_platform.mli index 3b81054b..fefc552e 100644 --- a/app/journal_platform.mli +++ b/app/journal_platform.mli @@ -33,29 +33,10 @@ val termination_ready_request : bytes val decode_termination_ready_response : bytes -> (unit, string) result -type notice_result = - | Notice_action - | Notice_dismiss - | Notice_swipe - | Notice_timeout - (** Host -> OCaml environment snapshot push (tag 24). *) val decode_environment_event : bytes -> (Journal_environment.snapshot, string) result val is_environment_event : bytes -> bool -(** OCaml -> host notice request (tag 25); response arrives on tag 26. *) -val show_notice_request - : token:int64 - -> message:string - -> action_label:string option - -> duration_ms:int - -> bytes - -val decode_notice_response : token:int64 -> bytes -> (notice_result, string) result - -(** OCaml -> host request cancelling a pending notice (tag 27). *) -val notice_cancel_request : token:int64 -> bytes - val copy_text_request : text:string -> (bytes, string) result val decode_copy_text_response : bytes -> (unit, string) result diff --git a/app/journal_routes.ml b/app/journal_routes.ml index 31616251..c37bcda7 100644 --- a/app/journal_routes.ml +++ b/app/journal_routes.ml @@ -288,20 +288,6 @@ let map_child_capture_at t ~entry_id ~request_generation ~f = | _ -> t) ;; -let commit_delete t ~block_id = - let retired, kept = - Drafts.partition (fun _ draft -> draft.block_id = block_id) t.drafts - in - let files = - Drafts.fold - (fun _ draft files -> - List.rev_append (Journal_detail.retained_attachments draft.composer) files) - retired - [] - in - if Drafts.is_empty retired then t, [] else { t with drafts = kept }, files -;; - let child_attachment_imports t ~child ~parent = let found = Drafts.fold @@ -705,110 +691,67 @@ let runtime_replaced t = t ;; -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 - phase, in path order, so pop/Undo cannot lose an independent pending draft. *) +let commit_delete t ~block_id = let t = - List.fold_left - (fun t (entry : detail_route Lui_navigation.entry) -> - match Owners.find_opt entry.id t.owners with - | Some (Detail_view owner) - when Journal_model.id (Journal_detail.root owner.detail) = block_id -> - retain_owner t entry.id + Owners.fold + (fun entry_id view t -> + match view with + | Detail_view owner when owner.block_id = block_id -> retain_owner t entry_id | _ -> t) + t.owners t - (Path.entries t.path) in - let t, staged = + let t = Owners.fold - (fun entry_id view (t, staged) -> + (fun entry_id view t -> match view with - | Detail_view owner -> - (match Journal_detail.stage_delete owner.detail ~block_id with - | None -> t, staged - | Some (hidden, undo) -> - let next = - if Journal_model.id (Journal_detail.root owner.detail) = block_id - then - Missing_detail_view + | Detail_view owner when owner.block_id = block_id -> + { t with + owners = + Owners.add + entry_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, 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, []) - in - t, if staged = [] then None else Some staged -;; - -let undo_delete t staged = - List.fold_left - (fun t (entry_id, generation, before, deleted_id, undo) -> - match Owners.find_opt entry_id t.owners with - | ( 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 - && - match before with - | Detail_loading_view _ -> owner.block_id = deleted_id - | _ -> true -> - settle_initial + ; draft_owner = None + }) + t.owners + } + | Detail_loading_view owner when owner.block_id = block_id -> { 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) + owners = + Owners.add + entry_id + (Missing_detail_view + { block_id = owner.block_id + ; request_generation = owner.request_generation + ; draft_owner = None + }) + t.owners } - ~entry_id - | _ -> t) - t - staged + | Detail_view owner -> + (match Journal_detail.remove_block owner.detail ~block_id with + | None -> t + | Some detail -> update_detail_at t ~entry_id detail) + | Detail_loading_view { staged_detail = Some detail; _ } -> + (match Journal_detail.remove_block detail ~block_id with + | None -> t + | Some detail -> update_detail_at t ~entry_id detail) + | _ -> t) + t.owners + t + in + let retired, kept = + Drafts.partition (fun _ draft -> draft.block_id = block_id) t.drafts + in + let files = + Drafts.fold + (fun _ draft files -> + List.rev_append (Journal_detail.retained_attachments draft.composer) files) + retired + [] + in + if Drafts.is_empty retired then t, [] else { t with drafts = kept }, files ;; module Favorites = struct @@ -819,7 +762,6 @@ module Favorites = struct | Select of bool | Invalidate | Hide_target of string - | Reveal_target of string | Retry | Visible of { first_index : int @@ -926,15 +868,6 @@ module Favorites = struct { t with hidden_targets = id :: t.hidden_targets; revision = t.revision + 1 } in start_if_needed next) - | Reveal_target id -> - if not (List.mem id t.hidden_targets) - then t, [] - else - ( { t with - hidden_targets = List.filter (( <> ) id) t.hidden_targets - ; revision = t.revision + 1 - } - , [] ) | Retry -> if t.pending = None then refresh t else t, [] | Visible { first_index = _; last_exclusive } -> let visible = visible_items t in diff --git a/app/journal_routes.mli b/app/journal_routes.mli index f87f7a67..733ceebd 100644 --- a/app/journal_routes.mli +++ b/app/journal_routes.mli @@ -56,11 +56,6 @@ val retry_detail_at val map_details : t -> f:(Journal_detail.t -> Journal_detail.t) -> t -type staged_delete - -val stage_delete : t -> block_id:string -> t * staged_delete option -val undo_delete : t -> staged_delete -> t - val apply_detail_response : t -> request_generation:int64 @@ -105,8 +100,8 @@ val map_child_capture_at -> f:(Journal_capture.t -> Journal_capture.t) -> t -(** Retire committed root drafts and return their files to the application for cleanup. - Staging a delete and Undo do not retire them. *) +(** Remove a committed subtree from all owned routes, retire its root drafts, and + return their files to the application for cleanup. *) val commit_delete : t -> block_id:string -> t * Journal_asset_import.staged list val child_attachment_imports @@ -128,7 +123,6 @@ module Favorites : sig | Select of bool | Invalidate | Hide_target of string - | Reveal_target of string | Retry | Visible of { first_index : int diff --git a/app/journal_row.ml b/app/journal_row.ml index e064ee96..dc1feedd 100644 --- a/app/journal_row.ml +++ b/app/journal_row.ml @@ -6,9 +6,12 @@ let body ?(child = false) title = if String.trim (Journal_model.Title.plain title) = "" then V.column [] else Journal_header.bounded_text ~child title -let metadata block = +let metadata_view ~timeline block = let status = Journal_model.task_state block in let titles = Journal_model.tag_titles block in + let visible_titles, remaining = + if timeline then List.take 3 titles, max 0 (List.length titles - 3) else titles, 0 + in let state = if status = No_status then [] @@ -32,7 +35,12 @@ let metadata block = then [] else [ L.text - ~value:(String.concat " " (List.map (fun title -> "#" ^ title) titles)) + ~value: + (String.concat + " " + (List.map (fun title -> "#" ^ title) visible_titles + @ if remaining = 0 then [] else [ "+" ^ string_of_int remaining ])) + ?text_alignment:(if timeline then Some `end_ else None) ~grow:1. ~style_class:"caption" ~foreground:"secondary" @@ -44,6 +52,8 @@ let metadata block = | children -> [ V.of_lui (L.row ~gap:8 ~cross:`start children) ] ;; +let metadata block = metadata_view ~timeline:false block + let view ?(render_source = Fun.id) ?render_title @@ -83,7 +93,12 @@ let view then [] else render (Journal_model.source block) in - let content = V.column ~spacing:8. ~alignment:Leading (body source :: metadata block) in + let content = + V.column + ~spacing:8. + ~alignment:Leading + (body source :: metadata_view ~timeline:true block) + in let labels = [ render_media ~title:(Journal_model.source block) ~root:id ~image_children content ] in diff --git a/app/journal_timeline_state.ml b/app/journal_timeline_state.ml index c387816e..ba93a2e2 100644 --- a/app/journal_timeline_state.ml +++ b/app/journal_timeline_state.ml @@ -51,13 +51,6 @@ type t = ; day_failures : (int * string) list } -(* The entry's stable (day, sibling_order, id) position is its restore anchor. - Only the day context hidden by this deletion is retained, not the old timeline. *) -type staged_delete = - { entry : Journal_graph_projection.timeline_entry - ; day_after_delete : day_knowledge option - } - let maximum_hidden_days = 512 let maximum_recovery_entries = 512 let initial_visible_count = 40 @@ -1130,7 +1123,22 @@ let prepend_timeline_entry (state : t) (entry : Journal_graph_projection.timelin } ;; -let stage_delete (state : t) ~block_id = +let remove_block (state : t) ~block_id = + let state = + { state with + days = + Days.map + (fun knowledge -> + match knowledge.hidden_entry with + | Some entry when String.equal (Journal_model.id entry.block) block_id -> + { knowledge with + hidden_entry = None + ; block_count = max 0 (knowledge.block_count - 1) + } + | _ -> knowledge) + state.days + } + in let position = find_slot_index (fun slot -> @@ -1162,91 +1170,8 @@ let stage_delete (state : t) ~block_id = } |> preserve_anchor state in - Some - ( prune_days deleted - , { entry - ; day_after_delete = Days.find_opt (Journal_model.journal_day block) deleted.days - } ) - | None | Some (_, (Day_heading _ | Day_continuation _ | Feed_continuation _)) -> None -;; - -let remove_block (state : t) ~block_id = - let state = - { state with - days = - Days.map - (fun knowledge -> - match knowledge.hidden_entry with - | Some entry when String.equal (Journal_model.id entry.block) block_id -> - { knowledge with - hidden_entry = None - ; block_count = max 0 (knowledge.block_count - 1) - } - | _ -> knowledge) - state.days - } - in - match stage_delete state ~block_id with - | Some (state, _) -> state - | None -> state -;; - -let undo_delete (state : t) staged = - let block = staged.entry.Journal_graph_projection.block in - let block_id = Journal_model.id block in - let day = Journal_model.journal_day block in - let current_day = Days.find_opt day state.days in - let target_present = - Option.fold - ~none:false - ~some:(fun knowledge -> - Option.fold - ~none:false - ~some:(fun (entry : Journal_graph_projection.timeline_entry) -> - String.equal (Journal_model.id entry.block) block_id) - knowledge.hidden_entry) - current_day - || Rrbvec.exists - (function - | Top_level entry -> String.equal (Journal_model.id entry.block) block_id - | _ -> false) - state.slots - in - if target_present - then state - else ( - let knowledge = - match current_day with - | Some knowledge -> { knowledge with block_count = knowledge.block_count + 1 } - | None -> - let previous = - Option.value - ~default: - { page = - { id = Journal_model.page_id block; day; title = string_of_int day } - ; block_count = 0 - ; complete = false - ; hidden = true - ; hidden_entry = None - } - staged.day_after_delete - in - (* Absent current days regain only this deletion's footprint. Historical - siblings/continuations removed by a later refresh are not resurrected. *) - let hidden_entry = if previous.hidden then previous.hidden_entry else None in - { previous with - block_count = (1 + if Option.is_some hidden_entry then 1 else 0) - ; hidden = true - ; hidden_entry - } - in - let restored = - { state with days = Days.add day knowledge state.days } |> normalize_days - in - (* Reuse the current ordering, including intervening Capture/page entries. - normalize_days reveals a surviving hidden sibling using its latest value. *) - let slots = insert_entry restored.slots staged.entry in - { restored with slots; pending = None } |> finish_change state) + prune_days deleted + | None | Some (_, (Day_heading _ | Day_continuation _ | Feed_continuation _)) -> state ;; let observe_visible_range (state : t) ~first_index ~last_exclusive = diff --git a/app/journal_timeline_state.mli b/app/journal_timeline_state.mli index 15244028..49df1827 100644 --- a/app/journal_timeline_state.mli +++ b/app/journal_timeline_state.mli @@ -15,7 +15,6 @@ type slot = | Feed_continuation of { before_day : int } type t -type staged_delete val empty : today:int -> t val reset : t -> today:int -> t @@ -41,8 +40,6 @@ val replace_timeline_entry_page -> t val prepend_timeline_entry : t -> Journal_graph_projection.timeline_entry -> t -val stage_delete : t -> block_id:string -> (t * staged_delete) option -val undo_delete : t -> staged_delete -> t (** Equality of the rendered slots, date/error presentation and scroll command. Demand windows and recovery bookkeeping do not affect this comparison. *) diff --git a/apple-tests/platform-wire/JournalPlatformWireTests.swift b/apple-tests/platform-wire/JournalPlatformWireTests.swift index d95b7fa3..481221c5 100644 --- a/apple-tests/platform-wire/JournalPlatformWireTests.swift +++ b/apple-tests/platform-wire/JournalPlatformWireTests.swift @@ -25,8 +25,6 @@ import Foundation "local-account": .localAccount, "timeline-presented": .timelinePresented, "copy-text": .copyText("Root\n - 中文😀"), - "notice": .showNotice(token: String(Int64.max), message: "Fixture", actionLabel: nil, durationMs: 5000), - "cancel-notice": .cancelNotice(token: String(Int64.max)), ] var baseRequest = Data() for (name, expectedRequest) in expected { @@ -66,7 +64,6 @@ import Foundation "sign-out": .signedOut, "termination-ready": .terminationReady, "local-account": .localAccount(userID: "fixture-user", origin: "https://api.logseq.io"), "no-local-account": .noLocalAccount, "timeline-presented": .timelinePresented, - "notice": .notice(token: String(Int64.max), result: "action"), "copy-text": .textCopied, ] var encoded: [String: String] = [:] diff --git a/apple-tests/platform-wire/journal_platform_wire_test.ml b/apple-tests/platform-wire/journal_platform_wire_test.ml index 9e05d10b..f11500c3 100644 --- a/apple-tests/platform-wire/journal_platform_wire_test.ml +++ b/apple-tests/platform-wire/journal_platform_wire_test.ml @@ -21,8 +21,6 @@ let emit path = ; "local-account", P.local_account_binding_request ; "timeline-presented", P.timeline_presented_request ; "copy-text", P.copy_text_request ~text:"Root\n - 中文😀" |> Result.get_ok - ; "notice", P.show_notice_request ~token:Int64.max_int ~message:"Fixture" ~action_label:None ~duration_ms:5000 - ; "cancel-notice", P.notice_cancel_request ~token:Int64.max_int ] in Yojson.Safe.to_file @@ -81,7 +79,6 @@ let verify path = "foreground-resumed" (P.decode_network_lifecycle (packet "foreground-resumed")) (Ok (P.Foreground_resumed { generation = Int64.max_int })); - require "notice" (P.decode_notice_response ~token:Int64.max_int (packet "notice")) (Ok P.Notice_action); require "environment" (P.decode_environment_event (packet "environment")) (Ok Journal_environment.{ brightness = Dark; platform = "ios"; accessible_navigation = true; high_contrast = false }); (match Journal_startup.decode (packet "startup") with diff --git a/apple-tests/warm-start/JournalIPhoneAcceptance.swift b/apple-tests/warm-start/JournalIPhoneAcceptance.swift index 8904688f..e88bf0c7 100644 --- a/apple-tests/warm-start/JournalIPhoneAcceptance.swift +++ b/apple-tests/warm-start/JournalIPhoneAcceptance.swift @@ -105,42 +105,6 @@ final class JournalIPhoneAcceptance: XCTestCase { record("All children after partial-page Append", app) } - @MainActor func testChildDeleteUndoAndPersistence() { - continueAfterFailure = false - let app = application(relaunch: true) - let parent = row("Graph 1 — Encrypted offline Journal", in: app) - XCTAssertTrue(parent.waitForExistence(timeout: 25)) - parent.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Graph 1 — Encrypted offline Journal")).firstMatch.tap() - let first = app.staticTexts["Native child 01 — nested content"] - XCTAssertTrue(first.waitForExistence(timeout: 10)) - first.press(forDuration: 1) - XCTAssertTrue(app.buttons["Delete block and descendants"].waitForExistence(timeout: 3)) - app.buttons["Delete block and descendants"].tap() - XCTAssertTrue(app.buttons["Undo"].waitForExistence(timeout: 3)) - app.buttons["Undo"].tap() - XCTAssertTrue(first.waitForExistence(timeout: 5)) - record("Child deletion canceled by timed Undo", app) - let second = app.staticTexts["Native child 02 — nested content"] - XCTAssertTrue(second.exists) - second.press(forDuration: 1) - XCTAssertTrue(app.buttons["Delete block and descendants"].waitForExistence(timeout: 3)) - app.buttons["Delete block and descendants"].tap() - XCTAssertTrue(app.buttons["Undo"].waitForExistence(timeout: 3)) - let expired = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: app.buttons["Undo"]) - wait(for: [expired], timeout: 12) - XCTAssertTrue(first.exists) - XCTAssertFalse(second.exists) - record("Only second child deleted", app) - app.terminate() - app.launch() - XCTAssertTrue(parent.waitForExistence(timeout: 25)) - parent.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Graph 1 — Encrypted offline Journal")).firstMatch.tap() - XCTAssertTrue(first.waitForExistence(timeout: 10)) - XCTAssertFalse(second.exists) - XCTAssertTrue(app.staticTexts["Native child 03 — nested content"].exists) - record("Child deletion survives restart", app) - } - @MainActor func testInspectNativeOutline() { continueAfterFailure = false let app = application(relaunch: true) diff --git a/docs/agent-guide/implemented/feature/2026-10-16-timeline-tags-direct-delete.md b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tags-direct-delete.md new file mode 100644 index 00000000..dde4f757 --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tags-direct-delete.md @@ -0,0 +1,54 @@ +# Timeline Tags Direct Delete + +## Problem + +Timeline tags currently follow status at the leading edge and render every title. Deleting a block waits for an Undo deadline and retains restoration state across timeline and detail owners. + +## Proposal + +Implement the user's requested right-aligned tags, showing the first three in existing order and the remaining count as +N, using native OCaml LUI components without additional graph reads. Keep status readable and allow native text wrapping. Remove the Undo feature under the explicitly approved three protected interface diffs; send Delete immediately, retain visible content while pending, and remove it only on successful completion. A failure keeps content and displays an error. Existing mutation and synchronization contracts remain authoritative. + +## Decision + +Adopt the native OCaml Timeline tag layout and immediate deletion flow described above. The exact protected API removals are approved; implementation and synthetic acceptance are complete. + +## Alternatives considered + +### Keep Undo API as unused compatibility wrappers + +Rejected because the user explicitly requires removing the complete feature and its obsolete tests, with no compatibility wrappers. + +## Acceptance criteria + +- Tags: 0, 1, 3, 4 and more; correct remaining count and stable title order. +- Native trailing alignment and wrapping work with long names, narrow screens and accessibility text sizes, alongside status and assets. +- No increased graph-query scope or hidden-title rendering. +- After interface approval, no Undo UI, events, deadlines, restoration state or compatibility API remains. +- Synthetic fixtures verify successful delete, failure feedback and duplicate suppression without touching personal graphs. +- Use the narrowest existing public owner tests and coordinate a real Simulator screenshot; reuse prepared dependencies and incremental build inputs. + +## Risks + +- The user approved the exact three-interface proposal on 2026-10-16 at 08:55:13 UTC (Sentinel_a55c41c2006881918699ecad3290877e). Application, Timeline, Detail and Routes no longer stage or restore deletions. The user additionally approved removal of the notice API and Favorites.Reveal_target on 2026-10-16 at 09:11 UTC (Sentinel_e1cc9f8dab808191abd16e0f60c651dd). Both interfaces and their sole-purpose implementations and obsolete tests are removed. +- Rendering has no pure reducer owner; existing public mounted-view tests are its narrowest boundary. Delete admission is private to Application and needs its existing public mounted Worker fixture unless an existing public pure boundary can reproduce it. +- Host-restamped Simulator objects do not establish full iOS cross-compilation or ABI acceptance. + +## Current validation + +- Tags mounted semantics: RED on the old alignment, GREEN after the OCaml-only Timeline change; 50 semantics cases pass, including unchanged complete Detail tags. +- Delete mounted boundary: old optimistic staging fails the retained-content assertion; success and failure now pass immediate submission, revision preconditions, duplicate suppression, failure Details/retry and absence of notice requests. +- Full local dune runtest after PR83 integration passes 876 Alcotest cases plus 87 custom PASS checks and source/package boundary checks with loopback permission for synthetic peers. The restricted run was stopped after its peer bind was denied; its logs are preserved separately. +- The old macOS toplevel runner cannot load dllapp_stubs because caml_startup is absent. The registered pure and Worker runtime sources pass as native executables linked against the current public compiled interfaces and existing Dune link inputs. +- The branch is rebased onto main bb2b9fbd8d0e090e90b5ab7f1b0e990ce0e09169 after PR83, PR84 and PR85 integration. PR85 changes only CI/build documentation and tests; all native-source hashes remain identical to the completed 876-case run. Existing Favorites page/outline, Copy, Capture and list semantics remain; the public page owner also verifies child deletion preserves the page and its composer. Final source hashes are checked; the current-main Simulator app has a successful unified-builder manifest and all five native XCTest scenarios pass (67.439 seconds, zero failures). + +- Final native Simulator evidence covers narrow tags 0/1/3/4/9, long names, status/image layout, successful direct deletion, visible failure Details/retry, one request under repeated pending actions, and largest accessibility text wrapping. All fixtures are in memory on a new device; no personal graph was deleted. The initial UI positioning failure was corrected only in the external driver and the final full run passes. +- The final actual screenshot is Library libfile_01b78e91e8ec81918a5f1d672cca0943; additional final screenshots and the test result are in the external task evidence. The probe remains cross_abi_validated=false and does not claim a production iOS cross-compile. +- This decision validates. Full spec-dev-tool check --all still reports three unchanged pre-existing main documents missing required sections. + +## Consequences + +Timeline now displays at most three existing tag titles and an exact remainder, without widening reads. Delete submits immediately with existing revision/sync semantics; while pending, content remains visible and duplicate deletion is disabled. Successful completion removes it, while failure keeps authoritative content and explains recovery. Undo backup state, events, timer/notice wire and Swift presenter are removed rather than wrapped. Both protected interface edits followed their explicit exact-diff approvals. + +## Questions + +None. Both exact protected interface proposals were explicitly approved and applied within that scope. diff --git a/swift/JournalApplicationPlatform.swift b/swift/JournalApplicationPlatform.swift index dce23feb..f509d57a 100644 --- a/swift/JournalApplicationPlatform.swift +++ b/swift/JournalApplicationPlatform.swift @@ -63,7 +63,6 @@ import AppKit @ObservationIgnored private var shutdown: JournalApplicationShutdown? @ObservationIgnored private var lastEnvironment: JournalEnvironmentSample? @ObservationIgnored private(set) var connected = false - @ObservationIgnored let notices = JournalNotices() /// Test observation hooks (apple-tests): every decoded LJP2 request and /// each connect/disconnect transition, in delivery order on the main actor. @ObservationIgnored var requestObserver: ((JournalPlatformWire.Request) -> Void)? @@ -78,7 +77,6 @@ import AppKit self.runtime = runtime connected = true pending.connect() - notices.setActive(true) connectionObserver?(true) observeAuthentication() // Re-deliver the latest environment sample after a reconnect. @@ -98,8 +96,6 @@ import AppKit shutdown?.cancel() shutdown = nil pending.disconnect() - notices.setActive(false) - notices.cancelAll() connectionObserver?(false) connected = false runtime = nil @@ -136,7 +132,7 @@ import AppKit pending.clearAuthentication() } if pending.terminating, request != .terminationReady { return nil } - // Notice requests resolve through the presenter, not platform services. + // Clipboard access belongs to the main-actor application host. switch request { case .copyText(let text): guard connected, connection == identity else { return nil } @@ -149,16 +145,6 @@ import AppKit return nil #endif return try? JournalPlatformWire.encodeResponse(.textCopied) - case .cancelNotice(let token): - notices.cancel(token: token) - return nil - case .showNotice(let token, let message, let actionLabel, let durationMs): - let close = await notices.show( - token: token, message: message, actionLabel: actionLabel, - durationMs: durationMs) - guard connected else { return nil } - return try? JournalPlatformWire.encodeResponse( - .notice(token: token, result: close.result.rawValue)) default: break } diff --git a/swift/JournalNotices.swift b/swift/JournalNotices.swift deleted file mode 100644 index debe5dfa..00000000 --- a/swift/JournalNotices.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import Observation -import SwiftUI - -/// One OCaml notice request (LJP2 tag 25) being presented to the user. The -/// request resolves when the notice closes, producing the tag-26 response. -struct JournalNoticePresentation: Equatable, Identifiable { - enum Close { - case action, dismiss, swipe, timeout - var result: JournalPlatformWire.NoticeResult { - switch self { - case .action: .action - case .dismiss: .dismiss - case .swipe: .swipe - case .timeout: .timeout - } - } - } - let id: UUID - let token: String - let message: String - let actionLabel: String? - let durationMilliseconds: Int -} - -/// FIFO notice queue; at most one notice presents at a time. Ported from the -/// bonsai `NativeNotices` controller with the response wired to the LJP2 -/// platform-response channel instead of a request callback. -@MainActor @Observable final class JournalNotices { - private final class Entry { - let presentation: JournalNoticePresentation - let continuation: CheckedContinuation - var remaining: Duration - var presented = false - var announced = false - init( - presentation: JournalNoticePresentation, - continuation: CheckedContinuation - ) { - self.presentation = presentation - self.continuation = continuation - remaining = .milliseconds(Int64(presentation.durationMilliseconds)) - } - } - - private(set) var presentation: JournalNoticePresentation? - private(set) var active = false - @ObservationIgnored private var entries: [Entry] = [] - @ObservationIgnored private var timer: Task? - @ObservationIgnored private var timerStarted: ContinuousClock.Instant? - @ObservationIgnored private var voiceOver = false - private let clock = ContinuousClock() - - func setActive(_ value: Bool) { - guard active != value else { return } - active = value - reconcileTimer() - } - func setVoiceOver(_ value: Bool) { - guard voiceOver != value else { return } - voiceOver = value - reconcileTimer() - } - - func shown(_ id: UUID) { - guard let first = entries.first, first.presentation.id == id else { return } - first.presented = true - if !first.announced { - first.announced = true - AccessibilityNotification.Announcement(first.presentation.message).post() - } - reconcileTimer() - } - func hidden(_ id: UUID) { - guard let first = entries.first, first.presentation.id == id else { return } - first.presented = false - reconcileTimer() - } - func close(_ id: UUID, reason: JournalNoticePresentation.Close) { - guard active, let first = entries.first, first.presented, - first.presentation.id == id, - reason != .action || first.presentation.actionLabel != nil - else { return } - finish(id, close: reason) - } - - /// Awaits the close reason for one notice request. - func show(token: String, message: String, actionLabel: String?, durationMs: Int) - async -> JournalNoticePresentation.Close - { - guard entries.count < 256 else { return .dismiss } - let id = UUID() - return await withCheckedContinuation { continuation in - entries.append( - Entry( - presentation: JournalNoticePresentation( - id: id, token: token, message: message, - actionLabel: actionLabel, durationMilliseconds: durationMs), - continuation: continuation)) - if entries.count == 1 { presentation = entries[0].presentation } - } - } - - /// Handles LJP2 tag 27. A notice cancelled before it was ever presented is - /// dropped silently; one already shown resolves as a host dismiss (the - /// tag-26 response still rides back on the original tag-25 request). - func cancel(token: String) { - guard let index = entries.firstIndex(where: { $0.presentation.token == token }) - else { return } - finish(entries[index].presentation.id, close: .dismiss) - } - - private func stopTimer() { - if let started = timerStarted, let first = entries.first { - first.remaining = max(.zero, first.remaining - started.duration(to: clock.now)) - } - timerStarted = nil - timer?.cancel() - timer = nil - } - private func reconcileTimer() { - guard active, let first = entries.first, first.presented, - !(voiceOver && first.presentation.actionLabel != nil) - else { - stopTimer() - return - } - guard timer == nil else { return } - timerStarted = clock.now - let id = first.presentation.id - let delay = first.remaining - timer = Task { [weak self] in - do { try await ContinuousClock().sleep(for: delay) } catch { return } - guard let self, !Task.isCancelled, entries.first?.presentation.id == id, - active, entries.first?.presented == true - else { return } - finish(id, close: .timeout) - } - } - private func finish(_ id: UUID, close: JournalNoticePresentation.Close) { - guard let index = entries.firstIndex(where: { $0.presentation.id == id }) - else { return } - if index == 0 { stopTimer() } - let entry = entries.remove(at: index) - if index == 0 { presentation = entries.first?.presentation } - entry.continuation.resume(returning: close) - } - func cancelAll() { - stopTimer() - let pending = entries - entries = [] - presentation = nil - for entry in pending { entry.continuation.resume(returning: .dismiss) } - } -} - -/// Presents the head notice as a bottom safe-area banner; ported from the -/// bonsai `NativeNoticePresenter` (same layout, gesture, VoiceOver gating). -struct JournalNoticePresenter: ViewModifier { - let controller: JournalNotices - @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver - - private func notice(_ presentation: JournalNoticePresentation) -> some View { - let message = Text(presentation.message) - .lineLimit(5).fixedSize(horizontal: false, vertical: true) - let buttons = HStack(spacing: 10) { - if let action = presentation.actionLabel { - Button(action) { - controller.close(presentation.id, reason: .action) - } - .buttonStyle(.bordered) - } - Button { - controller.close(presentation.id, reason: .dismiss) - } label: { - Image(systemName: "xmark") - } - .buttonStyle(.borderless).accessibilityLabel("Dismiss notification") - } - return ViewThatFits(in: .horizontal) { - HStack(alignment: .center, spacing: 16) { - message - buttons - } - VStack(alignment: .leading, spacing: 10) { - message - buttons.frame(maxWidth: .infinity, alignment: .trailing) - } - } - .padding(12) - .frame(maxWidth: 560) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) - .contentShape(RoundedRectangle(cornerRadius: 12)) - .accessibilityElement(children: .contain) - .disabled(!controller.active) - .simultaneousGesture( - DragGesture(minimumDistance: 12).onEnded { value in - if value.translation.height > 40, abs(value.translation.width) < value.translation.height { - controller.close(presentation.id, reason: .swipe) - } - } - ) - .padding(.horizontal, 12).padding(.vertical, 8) - .onAppear { controller.shown(presentation.id) } - .onDisappear { controller.hidden(presentation.id) } - .id(presentation.id) - } - - func body(content: Content) -> some View { - content - .safeAreaInset(edge: .bottom, spacing: 0) { - if let presentation = controller.presentation { notice(presentation) } - } - .onAppear { controller.setVoiceOver(voiceOver) } - .onChange(of: voiceOver) { _, value in controller.setVoiceOver(value) } - } -} diff --git a/swift/JournalPlatformServices.swift b/swift/JournalPlatformServices.swift index fe7462c5..2ddf66a8 100644 --- a/swift/JournalPlatformServices.swift +++ b/swift/JournalPlatformServices.swift @@ -240,8 +240,8 @@ struct JournalLocalAccount: Equatable, Sendable { return .signedOut case .terminationReady: return .terminationReady - case .showNotice, .cancelNotice, .copyText: - // Notice requests are intercepted by JournalApplicationPlatform before + case .copyText: + // Clipboard requests are intercepted by JournalApplicationPlatform before // reaching services. throw Failure.unavailable } diff --git a/swift/JournalPlatformWire.swift b/swift/JournalPlatformWire.swift index 12a29236..e6d641f7 100644 --- a/swift/JournalPlatformWire.swift +++ b/swift/JournalPlatformWire.swift @@ -6,8 +6,6 @@ enum JournalPlatformWire { enum Request: Equatable { case authenticatedUser, signOut, terminationReady, localAccount, timelinePresented case idToken(challengeID: String) - case showNotice(token: String, message: String, actionLabel: String?, durationMs: Int) - case cancelNotice(token: String) case copyText(String) } enum Response: Equatable { @@ -16,12 +14,8 @@ enum JournalPlatformWire { case signedOut, terminationReady, timelinePresented case localAccount(userID: String, origin: String) case noLocalAccount - case notice(token: String, result: String) case textCopied } - enum NoticeResult: String { - case action, dismiss, swipe, timeout - } enum Lifecycle: UInt16 { case backgrounded = 1, foregroundResumed = 2 } // Absence is an explicit null; omitted account fields indicate an invalid packet. @@ -47,9 +41,6 @@ enum JournalPlatformWire { private struct SignedOut: Codable { let signedOut: Bool } private struct Ready: Codable { let ready: Bool } private struct Presented: Codable { let presented: Bool } - private struct Notice: Codable { let token: Int64; let message: String; let actionLabel: String?; let durationMs: Int } - private struct NoticeResponse: Codable { let token: Int64; let result: String } - private struct Cancel: Codable { let token: Int64 } private struct Text: Codable { let text: String } private struct Copied: Codable { let copied: Bool } @@ -82,15 +73,8 @@ enum JournalPlatformWire { case 8: let value = try decode(Challenge.self, payload) return .idToken(challengeID: value.challengeId) - case 25: - let value = try decode(Notice.self, payload) - guard value.durationMs > 0, value.actionLabel?.isEmpty != true else { throw Failure.invalidPacket } - return .showNotice(token: String(value.token), message: value.message, - actionLabel: value.actionLabel, durationMs: value.durationMs) case 28: return .copyText(try decode(Text.self, payload).text) - case 27: - return .cancelNotice(token: String(try decode(Cancel.self, payload).token)) default: throw Failure.invalidPacket } @@ -120,9 +104,7 @@ enum JournalPlatformWire { return try json(tag: 23, Presented(presented: true)) case .textCopied: return try json(tag: 29, Copied(copied: true)) - case .notice(let token, let result): - guard let token = Int64(token), NoticeResult(rawValue: result) != nil else { throw Failure.invalidPacket } - return try json(tag: 26, NoticeResponse(token: token, result: result)) + } } diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 08d848ed..68ccf04a 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -7,7 +7,7 @@ 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 +/// lui root with the environment plumbing attached. Shared by the app /// scene (App.swift) and the apple-tests acceptance harnesses — it replaces /// `BonsaiApplicationView(entrypoint:payload:nativeViews:applicationBridge:)`. struct JournalRuntimeHost: View { @@ -53,7 +53,6 @@ struct JournalRuntimeHost: View { #endif } .environment(\.journalStartupClock, clock) - .modifier(JournalNoticePresenter(controller: platform.notices)) .background(JournalEnvironmentObserver { platform.pushEnvironment($0) }) .task(id: retry) { if runtime == nil { diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 297a67c4..bf33f3c9 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -402,6 +402,7 @@ let run_favorites_native_visibility ?(check_capture_imports = false) ?detail_capture ?status_scenario + ?delete_scenario ?(on_initialized = fun () -> ()) ?media_rows ?(shared_media = false) @@ -420,6 +421,9 @@ let run_favorites_native_visibility | Some ("now" | "waiting" | "later" as value) -> Some value | Some _ -> Some "doing" | None -> None) in + let delete_writes = Atomic.make 0 in + let delete_release = Atomic.make false in + let delete_notices = ref 0 in let status_writes = Atomic.make [] in let status_revision () = if Atomic.get status_writes = [] then "root" else "status-updated" in let status_release = Atomic.make (status_scenario <> Some "duplicate") in @@ -775,6 +779,20 @@ let run_favorites_native_visibility in let outcome = match request.command with + | P.V2_delete_blocks { root; mutation_id; preconditions } when Option.is_some delete_scenario -> + Alcotest.(check string) "delete keeps target UUID" + (G.Uuid.to_string (uuid 1)) (G.Uuid.to_string root); + Alcotest.(check (list (pair string string))) "delete preserves revision preconditions" + [ G.Uuid.to_string root, "root" ] + (List.map (fun (id, revision) -> G.Uuid.to_string id, revision) preconditions.blocks); + Atomic.incr delete_writes; + while not (Atomic.get delete_release) do + Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001 + done; + if delete_scenario = Some "failure" then + P.V2_failed { code = Logseq_db_worker.Error.code_string Invalid_request; message = "Fixture delete failed" } + else P.V2_mutation_committed { mutation_id; status = V2_applied; generation = "g" + ; before_projection_revision = "p"; after_projection_revision = "p2" } | V2_set_task_status { mutation_id; block; status; _ } when Option.is_some status_scenario -> let value = match status with | P.V2_todo -> "todo" | V2_doing -> "doing" | V2_in_review -> "in-review" @@ -1180,6 +1198,7 @@ let run_favorites_native_visibility ?calendar_sampler ~on_client:(fun value -> client := Some value) ~on_platform_request:(fun request -> + if Bytes.length request >= 32 && Bytes.get_uint16_le request 6 = 25 then incr delete_notices; if Bytes.length request >= 32 && Bytes.get_uint16_le request 6 = 28 then clipboard_requests := request :: !clipboard_requests) ~on_view_region:(fun name -> @@ -1315,6 +1334,7 @@ let run_favorites_native_visibility in Fun.protect ~finally:(fun () -> + Atomic.set delete_release true; Atomic.set status_release true; Atomic.set child_release true; Atomic.set calendar_feed_release true; @@ -1350,6 +1370,50 @@ let run_favorites_native_visibility wait "Journals mounted" (fun () -> Option.is_some (find "accessibility-label" "Favorites") && Option.is_some (find "text" "Timeline fixture 2")); + (* Delete admission is private to Application, absent from Root_navigation's + public pure events. Use its existing public bridge and controlled Worker + fixture to execute the actual UI admission/completion boundary. *) + Option.iter (fun scenario -> + let send_delete () = + let list = Option.get (find "_extension" "journal-list") in + let row_payload = Yojson.Safe.to_string (`Assoc + [ "key", `String "delete"; "row", `String ("block:" ^ G.Uuid.to_string (uuid 1)) ]) in + let event = Yojson.Safe.to_string (`Assoc + [ "type", `String "row_event"; "payload", `String row_payload ]) in + hooks.extension_event list "event" (Yojson.Safe.to_string + (`Assoc [ "id", `Int 1; "payload", `String event ])) |> consume + in + send_delete (); + Alcotest.(check bool) "pending delete keeps content until commit" true + (Option.is_some (find "text" "Timeline fixture 0")); + Alcotest.(check int) "delete never requests an Undo notice" 0 !delete_notices; + let immediate_deadline = Unix.gettimeofday () +. 0.5 in + while Atomic.get delete_writes = 0 && Unix.gettimeofday () < immediate_deadline do + hooks.pump () |> consume; Unix.sleepf 0.001 + done; + Alcotest.(check int) "delete reaches Worker immediately without an Undo deadline" 1 + (Atomic.get delete_writes); + send_delete (); + Alcotest.(check int) "repeated pending row action sends once" 1 (Atomic.get delete_writes); + Atomic.set delete_release true; + wait "delete completion updates content or shows failure" (fun () -> + if scenario = "failure" then Option.is_some (find "text" "Delete failed") + else Option.is_none (find "text" "Timeline fixture 0")); + Alcotest.(check bool) "failure preserves block; success removes it" (scenario = "failure") + (Option.is_some (find "text" "Timeline fixture 0")); + Alcotest.(check int) "completion cannot leave an Undo notice" 0 !delete_notices; + if scenario = "failure" then ( + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Details"))); + wait "failure details explain recovery" (fun () -> + Option.is_some (find "text" "The block is kept. Review it before trying Delete again.")); + Alcotest.(check bool) "failure includes worker reason" true + (Option.is_some (find "text" "Fixture delete failed")); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-identifier" "journal-error-info-close"))); + send_delete (); + wait "failure releases admission for explicit retry" (fun () -> Atomic.get delete_writes = 2)) + else ( + send_delete (); + Alcotest.(check int) "deleted identity cannot submit again" 1 (Atomic.get delete_writes))) delete_scenario; Option.iter (fun scenario -> let option tag = find "accessibility-identifier" ("journal-status-sheet-option:" ^ tag) in let close () = Option.get (find "accessibility-identifier" "journal-status-close") in @@ -1416,7 +1480,7 @@ let run_favorites_native_visibility if List.mem scenario ["expanded"; "dismiss"; "none"; "now"; "waiting"; "later"] then ( Alcotest.(check bool) "closing removes status UI" true (Option.is_none (option "doing")); Alcotest.(check int) "closing never submits" 0 (List.length (Atomic.get status_writes)))) status_scenario; - if Option.is_none status_scenario then ( + if Option.is_none status_scenario && Option.is_none delete_scenario then ( if Option.is_some calendar_change then ( let label () = Hashtbl.fold (fun _ values found -> match List.assoc_opt "payload" values with @@ -5424,7 +5488,10 @@ let () = Journal_bridge.register hooks) else Alcotest.run "application view" - [ ( "status list", List.map (fun scenario -> + [ ( "direct delete", List.map (fun scenario -> + Alcotest.test_case scenario `Quick (fun () -> run_favorites_native_visibility ~delete_scenario:scenario ())) + ["success"; "failure"]) + ; ( "status list", List.map (fun scenario -> Alcotest.test_case scenario `Quick (fun () -> run_favorites_native_visibility ~status_scenario:scenario ())) ["expanded"; "none"; "now"; "waiting"; "later"; "select"; "clear"; "dismiss"; "failure"; "duplicate"]) ; ("Favorites compact title",[Alcotest.test_case "page property values keep name without duplication" `Quick test_detail_page_property_value_presentation; Alcotest.test_case "root Capture only in Journals" `Quick test_root_capture_placement; Alcotest.test_case "LUI titles icons and no arrow" `Quick test_favorites_compact_shared_titles; Alcotest.test_case "page outline click and native Back" `Quick (fun () -> run_favorites_native_visibility ~check_favorite_targets:true ())]) diff --git a/test/journal_platform_test.ml b/test/journal_platform_test.ml index 2f36d589..3d7740ae 100644 --- a/test/journal_platform_test.ml +++ b/test/journal_platform_test.ml @@ -198,12 +198,6 @@ let test_generated_native_decoders_reject_untyped_payloads () = ; "accessibleNavigation", `Bool false; "highContrast", `Bool false ; "unsupported", `Bool true ]))) "environment accepted an unknown generated-record field"; - require - (Result.is_error - (Journal_platform.decode_notice_response ~token:7L - (envelope 26 - (Bytes.of_string {|{"token":7,"result":"action","unexpected":true}|})))) - "notice accepted an unknown generated-record field"; require (Result.is_error (Journal_asset_import.decode_event {|{"action":"unavailable"}|})) "picker accepted unavailable without its required reason"; diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index 4feeb365..73542178 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -868,12 +868,9 @@ let test_outline_branches_append_and_delete () = request_generation | _ -> assert false in - let removed_append, append_delete = - D.stage_delete created ~block_id:(Journal_model.id new_child) |> Option.get - in - let restored_append = D.undo_delete removed_append append_delete in + let restarted, _ = - D.step restored_append (Load_failed (pending_generation, true, "Cursor changed")) + D.step created (Load_failed (pending_generation, true, "Cursor changed")) in let restarted, requests = D.step restarted (Load_more root_id) in let restart_generation = @@ -893,7 +890,7 @@ let test_outline_branches_append_and_delete () = (List.mem (Journal_model.id new_child) (List.map Journal_model.id (D.children restarted))) - "cursor restart lost an appended child restored by Undo"; + "cursor restart lost an appended child after creation"; let paging, requests = D.step restarted (Load_more root_id) in let paging_generation = match requests with @@ -925,18 +922,16 @@ let test_outline_branches_append_and_delete () = require (List.length row_keys = List.length (List.sort_uniq String.compare row_keys)) "native outline received duplicate row identities"; - let deleted, staged = D.stage_delete created ~block_id:child_id |> Option.get in + let deleted = D.remove_block created ~block_id:child_id |> Option.get in require (D.find_block deleted ~block_id:child_id = None) - "staged subtree remained visible"; + "committed subtree remained visible"; require (D.find_block deleted ~block_id:(Journal_model.id grandchild) = None) - "staged descendant remained visible"; - let restored = D.undo_delete deleted staged in - require (List.exists (function D.Block { block; _ } -> Journal_model.id block = Journal_model.id grandchild | _ -> false) (D.rows restored)) "Undo lost visible descendant"; + "committed descendant remained visible"; require - (Option.is_some (D.find_block restored ~block_id:(Journal_model.id new_child))) - "Undo discarded unrelated appended child"; + (Option.is_some (D.find_block deleted ~block_id:(Journal_model.id new_child))) + "delete discarded unrelated appended child"; require (D.request_back created = `Close) "reading Back was blocked" ;; @@ -1071,7 +1066,7 @@ let test_favorites_state_isolates_requests_and_refreshes () = "inactive current completion was discarded" ;; -let test_favorite_target_origin_and_undo () = +let test_favorite_target_origin_and_delete () = let module P = Logseq_db_worker.Protocol in let module G = Logseq_db_types.Graph_types in let module F = Journal_routes.Favorites in @@ -1139,8 +1134,7 @@ let test_favorite_target_origin_and_undo () = require (List.length (F.items hidden) = 1) "delete did not hide only the matching block favorite"; - let restored, _ = F.step hidden (Reveal_target (G.Uuid.to_string target)) in - require (F.items restored = F.items loaded) "Undo did not restore favorite membership" + ;; let load_parent routes parent generation = @@ -2187,7 +2181,7 @@ let test_typed_covered_failure_missing_and_branch () = "covered missing result changed the top entry" ;; -let test_typed_shared_data_delete_undo_and_drafts () = +let test_typed_shared_data_delete_and_drafts () = let module R = Journal_routes in let parent = block () in let first = load_parent (R.create ()) parent 900L in @@ -2225,37 +2219,15 @@ let test_typed_shared_data_delete_undo_and_drafts () = (Journal_capture.source (Option.get (Journal_detail.child_capture (entry_detail shared first_id)))) "first independent draft after graph update"; - let staged, undo = R.stage_delete shared ~block_id:(Journal_model.id parent) in - List.iter - (fun id -> - require - (R.route (entry staged id) = Missing_detail) - "delete left a matching live root visible") - [ first_id; second_id ]; - let deleted_reopened = R.pop_to_root staged |> fun t -> load_parent t parent 925L in - require_string - "Second retained" - (Journal_capture.source - (Option.get - (Journal_detail.child_capture (Option.get (R.detail deleted_reopened))))) - "deleted root kept its independent retained composer"; - let restored = R.undo_delete staged (Option.get undo) in + let deleted, _ = R.commit_delete shared ~block_id:(Journal_model.id parent) in List.iter - (fun id -> - require - (R.route (entry restored id) = Detail) - "aggregate Undo did not restore a live owner") + (fun id -> require (R.route (entry deleted id) = Missing_detail) + "delete left a matching live root visible") [ first_id; second_id ]; - require_string - "Second retained" - (Journal_capture.source - (Option.get (Journal_detail.child_capture (entry_detail restored second_id)))) - "second independent draft after Undo"; - let popped = R.back staged |> fun t -> R.undo_delete t (Option.get undo) in - require - (R.at_entry popped ~entry_id:second_id = None && path_ids popped = [ first_id ]) - "Undo revived a popped owner"; - let detached = R.pop_to_root restored in + let deleted_reopened = R.pop_to_root deleted |> fun t -> load_parent t parent 925L in + require (Journal_detail.child_capture (Option.get (R.detail deleted_reopened)) = None) + "committed deletion retained a root draft"; + let detached = R.pop_to_root shared in let reopened = load_parent detached parent 920L in require_string "Second retained" @@ -2603,16 +2575,8 @@ let test_retained_detail_picker_owner_and_terminal_delete () = == updated) "stale picker generation modified a retained draft"; let reopened = load_parent updated root 990L in - let staged, undo = R.stage_delete reopened ~block_id:(Journal_model.id root) in - require - (List.length (R.pending_attachments staged) = 1) - "Undo staging retired the attachment too early"; - let restored = R.undo_delete staged (Option.get undo) in - require - (List.length (R.pending_attachments restored) = 1) - "Undo failed to restore the attachment"; let committed, released = - R.commit_delete staged ~block_id:(Journal_model.id root) + R.commit_delete reopened ~block_id:(Journal_model.id root) in require (List.length released = 1 && R.pending_attachments committed = []) @@ -3131,17 +3095,10 @@ let test_followup_snapshot_invalidation_and_owner_isolation () = "block attributes did not invalidate rows"; require (D.rows old_owner == rows) "derived outline overwrote retained snapshot cache"; let rows = D.rows owner in - let owner, staged = - D.stage_delete owner ~block_id:(Journal_model.id child) |> Option.get - in + let owner = D.remove_block owner ~block_id:(Journal_model.id child) |> Option.get in require (D.rows owner != rows && fst (followup_counts owner) = 1) "delete retained stale rows"; - let deleted = D.rows owner in - let owner = D.undo_delete owner staged in - require - (D.rows owner != deleted && fst (followup_counts owner) = 2) - "Undo retained deleted rows"; let owner = D.reconcile_children owner (followup_page root [] None) in require (fst (followup_counts owner) = 1) "authoritative refresh retained removed rows"; let owner = D.update_child_source owner "Appended QA block" in @@ -3583,109 +3540,25 @@ let test_initial_staging_refresh_and_delete () = && 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" + ; ( "root deletion fences pending completion" , fun () -> let pending, _, old = admit (start [ child ]) in - let missing, undo = R.stage_delete pending ~block_id:(id 0) in + let missing, _ = R.commit_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"; + "committed 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" ) + "deleted root is republished by late completion" ) ; ( "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 + let hidden, _ = R.commit_delete pending ~block_id:(id 2) in require - (Option.is_some undo && R.initial_load hidden = None && R.detail hidden = None) + (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 []) @@ -3693,7 +3566,7 @@ let test_initial_staging_refresh_and_delete () = require (ids ready = [ id 0; id 1 ]) "unaffected completion cannot publish the surviving body" ) - ; ( "descendant deletion removes subtree and Undo fences its old read" + ; ( "descendant deletion removes subtree and fences its old read" , fun () -> let tail = node ~parent:(id 0) 2 and grandchild = node ~parent:(id 1) 3 in @@ -3706,145 +3579,16 @@ let test_initial_staging_refresh_and_delete () = 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 + let hidden, _ = R.commit_delete pending ~block_id:(id 1) in require - (Option.is_some undo && Option.map snd (R.initial_load hidden) = Some (id 2)) + (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" ) + require (ids ready = [ id 0; id 2 ]) "deleted subtree remains in published text" ) ] in let failures = ref [] in @@ -3884,7 +3628,21 @@ let test_page_detail_append_matches_exact_page () = require (Journal_detail.mode completed = Reading && Journal_detail.child_capture completed = None) "page append did not complete its exact owner"; require (List.map Journal_model.id (Journal_detail.children completed) = [child_id]) - "page append went to another root" + "page append went to another root"; + let drafted = Journal_detail.update_child_source completed "Surviving page draft" in + let deleted = Journal_detail.remove_block drafted ~block_id:child_id |> Option.get in + require (Journal_model.id (Journal_detail.root deleted) = page_id + && Journal_detail.children deleted = []) + "deleting an outline child removed its enclosing page detail"; + require (Journal_capture.source (Option.get (Journal_detail.child_capture deleted)) + = "Surviving page draft") + "child deletion retired the enclosing page composer"; + let routes = load_parent (Journal_routes.create ()) root 8080L + |> fun routes -> Journal_routes.update_detail routes drafted in + let deleted, released = Journal_routes.commit_delete routes ~block_id:child_id in + require (Journal_routes.route deleted = Detail && released = [] + && Journal_detail.children (Option.get (Journal_routes.detail deleted)) = []) + "route deletion confused a page owner with the deleted outline child" ;; let tests = @@ -3908,7 +3666,7 @@ let tests = , test_multiselect_rejected_resource_ownership ) ; "multiselect graph owner and cancel", test_multiselect_graph_owner_and_cancel ; "typed covered failure and branch", test_typed_covered_failure_missing_and_branch - ; "typed shared data and aggregate Undo", test_typed_shared_data_delete_undo_and_drafts + ; "typed shared data and committed deletion", test_typed_shared_data_delete_and_drafts ; "typed duplicate detached mutations", test_typed_detached_duplicate_pending_mutations ; "public nested detail owners", test_public_nested_detail_owners ; ( "normal attachment pick without replacement metadata" @@ -3936,7 +3694,7 @@ let tests = , test_graph_interruption_preserves_capture_attempt ) ; "Append drafts follow parent navigation", test_append_drafts_follow_parent_navigation ; "detached Append completion and failure", test_detached_append_completion_and_failure - ; "favorite target identity, origin and Undo", test_favorite_target_origin_and_undo + ; "favorite target identity, origin and deletion", test_favorite_target_origin_and_delete ; ( "detail rejects another root even with matching generation" , fun () -> let routes = diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 2fa97275..9f0da6bb 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -1049,19 +1049,83 @@ let test_empty_media_has_no_attachment_chrome () = "empty media created attachment layout") ;; +(* Status/tag rendering has no pure reducer owner; the public mounted row is + the narrowest boundary that executes native metadata presentation. *) let test_status_and_tags_mount () = - let block = - Journal_model.with_tag_titles (block ~task_state:Doing ()) ~tag_titles:[ "阅读"; "工作" ] + let cases = + [ [], None + ; [ "阅读" ], Some "#阅读" + ; [ "阅读"; "工作"; "旅行" ], Some "#阅读 #工作 #旅行" + ; [ "阅读"; "工作"; "旅行"; "hidden" ], Some "#阅读 #工作 #旅行 +1" + ; List.init 9 (fun i -> "tag" ^ string_of_int i), Some "#tag0 #tag1 #tag2 +6" + ; ( [ "重复"; "重复"; "非常长的标签名称没有空格并保留完整文字"; "隐藏" ] + , Some "#重复 #重复 #非常长的标签名称没有空格并保留完整文字 +1" ) + ] in - let view = - Journal_row.view - ~show_timestamp:false - ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) - { Journal_graph_projection.block; child_summaries = [] } + List.iter + (fun task_state -> + List.iter + (fun (titles, expected) -> + let block = + Journal_model.with_tag_titles (block ~task_state ()) ~tag_titles:titles + in + let view = + Journal_row.view + ~show_timestamp:false + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) + { Journal_graph_projection.block; child_summaries = [] } + in + with_mounted view (fun _ ops -> + let ops = ops () in + require + (has_text ops "Doing" = (task_state = Journal_model.Doing)) + "status presence changed with tags"; + let tag_nodes = + List.filter_map + (function + | Lui_protocol.SetProp (node, TextValue, StringValue value) + when String.starts_with ~prefix:"#" value -> Some (node, value) + | _ -> None) + ops + in + match expected, tag_nodes with + | None, [] -> () + | Some expected, [ (node, actual) ] -> + require + (actual = expected) + "tags must keep the first three titles and exact remaining count: %s" + actual; + require + (List.exists + (function + | Lui_protocol.SetProp (id, TextAlignment, StringValue "end") -> + id = node + | _ -> false) + ops) + "tags must align to the trailing edge, with and without status"; + require + (not + (List.exists + (function + | Lui_protocol.SetProp (id, StyleClass, StringValue value) -> + id = node + && (String.equal value "single-line" + || String.equal value "line-clamp-3") + | _ -> false) + ops)) + "tags must retain native multiline wrapping" + | _ -> fail "unexpected tags rendering")) + cases) + [ Journal_model.No_status; Doing ]; + let detail = + Journal_model.with_tag_titles (block ()) ~tag_titles:[ "one"; "two"; "three"; "four" ] in - with_mounted view (fun _ ops -> - require (has_text (ops ()) "Doing") "status must mount using supported LUI nodes"; - require (has_text (ops ()) "#阅读 #工作") "named tags must accompany status") + with_mounted + (V.column (Journal_row.metadata detail)) + (fun _ ops -> + require + (has_text (ops ()) "#one #two #three #four") + "Timeline tag limit must not truncate Detail metadata") ;; (* Title layout is owned by the mounted view, outside the pure reducers. *) diff --git a/test/journal_timeline_state_test.ml b/test/journal_timeline_state_test.ml index 8faa5825..326f36ae 100644 --- a/test/journal_timeline_state_test.ml +++ b/test/journal_timeline_state_test.ml @@ -548,244 +548,7 @@ let test_prepend_first_today_entry_before_older_days () = | _ -> fail "first today Capture was inserted outside the visible day ordering" ;; -let require_staged state block_id = - match Timeline.stage_delete state ~block_id with - | Some value -> value - | None -> fail "expected %s to stage" block_id -;; - -let test_stage_delete_and_exact_undo () = - let parent = block ~child_count:3 500 in - let sibling = block ~order:"z" 501 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed [ day_feed 20260809 "Today" [ sibling; parent ] ]) - in - let staged_collapsed, collapsed_backup = - require_staged initial (Journal_model.id parent) - in - require_equal_string_list - (slot_keys staged_collapsed) - [ "block:" ^ Journal_model.id sibling ] - "collapsed delete removed unrelated slots"; - require - (Timeline.total_count staged_collapsed = Timeline.total_count initial - 1) - "collapsed delete did not repair total count"; - require_equal_string_list - (slot_keys (Timeline.undo_delete staged_collapsed collapsed_backup)) - (slot_keys initial) - "collapsed Undo did not restore exact slots"; - let loaded = initial in - let loaded = Timeline.observe_visible_range loaded ~first_index:0 ~last_exclusive:3 in - require - (Timeline.next_request loaded = None) - "static More exposed an automatic paging request"; - let staged, backup = require_staged loaded (Journal_model.id parent) in - require_equal_string_list - (slot_keys staged) - [ "block:" ^ Journal_model.id sibling ] - "expanded delete retained projected descendants or removed a sibling"; - require (Timeline.pending_request staged = None) "staging did not fence pending paging"; - let restored = Timeline.undo_delete staged backup in - require_equal_string_list - (slot_keys restored) - (slot_keys loaded) - "Undo changed slot keys"; - require (Timeline.pending_request restored = None) "Undo restored a stale request"; - require - (Timeline.total_count restored = Timeline.total_count loaded) - "Undo changed total count" -;; - -let test_undo_preserves_current_hidden_day_membership () = - let placeholder = block ~day:20260808 ~source:"" 520 in - let target = block ~day:20260808 ~order:"b" 521 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed [ day_feed 20260808 "Saturday" [ placeholder; target ] ]) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let current = Timeline.remove_block deleted ~block_id:(Journal_model.id placeholder) in - let restored = Timeline.undo_delete current backup in - require_equal_string_list - (slot_keys restored) - [ "day:20260808"; "block:" ^ Journal_model.id target ] - "Undo resurrected an intervening deletion of the hidden sibling"; - require - (Timeline.find_block restored ~block_id:(Journal_model.id placeholder) = None) - "Undo retained the deleted hidden sibling"; - require (Timeline.total_count restored = 2) "Undo counted a deleted hidden sibling" -;; - -let test_undo_uses_current_day_order_when_old_anchor_is_gone () = - let target = block ~day:20260808 530 in - let older = block ~day:20260807 531 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed - [ day_feed 20260808 "Saturday" [ target ] - ; day_feed 20260807 "Friday" [ older ] - ]) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let replacement = block ~day:20260807 ~source:"Refreshed older day" 532 in - let current = - Timeline.replace_timeline_entry_page - deleted - ~page:(page 20260807 "Friday") - (timeline_page [ replacement ]) - |> fun state -> - Timeline.prepend_timeline_entry state (entry (block 533)) - |> fun state -> - Timeline.observe_visible_range state ~first_index:2 ~last_exclusive:3 - |> fun state -> - Timeline.begin_request state ~generation:9L (Feed { before_day = Some 20260807 }) - in - let anchor = - Timeline.retained_slot current (Timeline.first_visible_index current) - |> Option.get - |> Timeline.slot_key - in - let restored = Timeline.undo_delete current backup in - require_equal_string_list - (slot_keys restored) - [ "block:" ^ id "block" 533 - ; "day:20260808" - ; "block:" ^ id "block" 530 - ; "day:20260807" - ; "block:" ^ id "block" 532 - ] - "Undo appended the restored day after newer pagination content"; - require - (Timeline.retained_slot restored (Timeline.first_visible_index restored) - |> Option.map Timeline.slot_key - = Some anchor) - "Undo moved the current visible anchor"; - require (Timeline.pending_request restored = None) "Undo retained a pending request"; - require - (Timeline.scroll_target restored = Timeline.scroll_target current) - "Undo replaced the intervening Capture scroll command" -;; - -let test_undo_keeps_reappeared_target_and_current_pagination () = - let target = block ~order:"b" 540 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed ~more:true [ day_feed ~more:true 20260809 "Today" [ target ] ]) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let cursor = continuation_of_entries ~more:true [ entry target ] in - let sibling = block ~order:"c" 541 in - let current = - Timeline.begin_request deleted ~generation:2L (Day { day = 20260809; after = cursor }) - |> fun state -> - Timeline.apply_timeline_entry_page state ~generation:2L (timeline_page [ sibling ]) - in - let restored = Timeline.undo_delete current backup in - require_equal_string_list - (slot_keys restored) - [ "block:" ^ id "block" 540; "block:" ^ id "block" 541; "feed-continuation:20260809" ] - "Undo lost current page rows or restored a retired continuation"; - let changed_target = block ~order:"b" ~source:"Reappeared target" 540 in - let duplicate = Timeline.prepend_timeline_entry current (entry changed_target) in - let unchanged = Timeline.undo_delete duplicate backup in - require_equal_string_list - (slot_keys unchanged) - (slot_keys duplicate) - "Undo inserted a duplicate stable key"; - require - (Timeline.find_block unchanged ~block_id:(id "block" 540) - |> Option.map Journal_model.source - = Some "Reappeared target") - "Undo overwrote the newer same-key target" -;; - -let test_undo_keeps_reappeared_hidden_target () = - let target = block ~day:20260808 550 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed [ day_feed 20260808 "Saturday" [ target ] ]) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let placeholder = block ~day:20260808 ~source:"" 550 in - let current = Timeline.prepend_timeline_entry deleted (entry placeholder) in - require_equal_string_list (slot_keys current) [] "same-key placeholder is not hidden"; - let restored = Timeline.undo_delete current backup in - require_equal_string_list - (slot_keys restored) - [] - "Undo exposed a newer same-key hidden target"; - require - (Timeline.find_block restored ~block_id:(id "block" 550) - |> Option.map Journal_model.source - = Some "") - "Undo overwrote the newer hidden target" -;; - -let test_undo_orders_target_among_intervening_siblings () = - let target = block ~order:"b" 560 in - let sibling = block ~order:"d" 561 in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed [ day_feed 20260809 "Today" [ target; sibling ] ]) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let current = Timeline.prepend_timeline_entry deleted (entry (block ~order:"c" 562)) in - let restored = Timeline.undo_delete current backup in - require_equal_string_list - (slot_keys restored) - [ "block:" ^ id "block" 560; "block:" ^ id "block" 562; "block:" ^ id "block" 561 ] - "Undo placed the target after an intervening later sibling" -;; - -let test_undo_retains_deleted_day_context_beyond_hidden_budget () = - let target = block ~day:20260808 570 in - let incomplete_days = - List.init 513 (fun index -> - let year = 2025 - (index / 336) in - let month = (index mod 336 / 28) + 1 in - let day = (index mod 28) + 1 in - { Journal_graph_projection.page = - page ((year * 10_000) + (month * 100) + day) "Older incomplete day" - ; entries = [] - ; has_more_entries = true - ; continuation = None - }) - in - let initial = - Timeline.empty ~today:20260809 - |> begin_and_apply_feed - ~generation:1L - ~before_day:None - (feed (day_feed 20260808 "Saturday" [ target ] :: incomplete_days)) - in - let deleted, backup = require_staged initial (Journal_model.id target) in - let restored = Timeline.undo_delete deleted backup in - require_equal_string_list - (slot_keys restored) - (slot_keys initial) - "Undo lost the deleted day's context when hidden knowledge was pruned" -;; - -let test_static_child_cannot_stage_delete_and_parent_delete_repairs_heading () = +let test_static_child_delete_is_ignored_and_parent_delete_repairs_heading () = let parent = block ~day:20260808 ~child_count:1 510 in let state = Timeline.empty ~today:20260809 @@ -798,8 +561,8 @@ let test_static_child_cannot_stage_delete_and_parent_delete_repairs_heading () = block ~day:20260808 ~parent_id:(Journal_model.id parent) ~source:"Visible child" 511 in require - (Timeline.stage_delete state ~block_id:(Journal_model.id child) = None) - "static child preview exposed a stage-delete path"; + (slot_keys (Timeline.remove_block state ~block_id:(Journal_model.id child)) = slot_keys state) + "static child preview exposed a delete path"; (match retained_slots state with | Timeline.Day_heading _ :: Timeline.Top_level entry :: _ -> require @@ -813,11 +576,11 @@ let test_static_child_cannot_stage_delete_and_parent_delete_repairs_heading () = ~before_day:None (feed [ day_feed 20260808 "Older" [ parent ] ]) in - let deleted, _ = require_staged root_only (Journal_model.id parent) in + let deleted = Timeline.remove_block root_only ~block_id:(Journal_model.id parent) in require_equal_string_list (slot_keys deleted) [] "last dated row left an orphan heading"; require - (Timeline.stage_delete deleted ~block_id:"forged" = None) - "forged delete ID was staged" + (slot_keys (Timeline.remove_block deleted ~block_id:"forged") = slot_keys deleted) + "forged delete ID changed the timeline" ;; let paginating_day () = @@ -1228,7 +991,7 @@ let test_capture_converges_with_later_pagination () = require (!failures = []) "%s" (String.concat "\n" (List.rev !failures)) ;; -let test_persistent_point_updates_and_undo () = +let test_persistent_point_updates_and_delete () = let blocks = List.init 512 (fun n -> block ~order:(Printf.sprintf "%012d" n) n) in let initial = Timeline.empty ~today:20260809 @@ -1244,12 +1007,10 @@ let test_persistent_point_updates_and_undo () = Gc.full_major (); let retained_words = (Gc.stat ()).live_words - live_before in require (slot_keys initial = slot_keys updated) "point update changed slot ordering"; - let staged, backup = require_staged initial (id "block" 256) in - let changed = Timeline.replace_block staged replacement in + let deleted = Timeline.remove_block initial ~block_id:(id "block" 256) in + let changed = Timeline.replace_block deleted replacement in let inserted = block ~order:"000000000600" 600 |> entry in - let restored = - Timeline.undo_delete (Timeline.prepend_timeline_entry changed inserted) backup - in + let current = Timeline.prepend_timeline_entry changed inserted in let find_source state block_id = retained_slots state |> List.find_map (function @@ -1261,17 +1022,17 @@ let test_persistent_point_updates_and_undo () = (find_source initial (id "block" 511) = Some "Journal entry") "persistent update mutated the retained old state"; require - (find_source restored (id "block" 511) = Some "Updated sibling") - "undo discarded an intervening point update"; + (find_source current (id "block" 511) = Some "Updated sibling") + "delete discarded an intervening point update"; require - (find_source restored (id "block" 600) = Some "Journal entry") - "undo discarded an intervening insertion"; + (find_source current (id "block" 600) = Some "Journal entry") + "delete discarded an intervening insertion"; require - (find_source restored (id "block" 256) = Some "Journal entry") - "undo did not restore the deleted target"; + (find_source current (id "block" 256) = None) + "committed deletion restored the deleted target"; require - (Timeline.retained_slot_count restored = 513) - "Undo discarded a loaded sibling to enforce an obsolete retention cap"; + (Timeline.retained_slot_count current = 512) + "Delete discarded a loaded sibling to enforce an obsolete retention cap"; require (retained_words < 600) "one point update retained %d new words; expected less than 600 with structural \ @@ -1317,7 +1078,7 @@ let test_empty_day_visibility_boundaries () = visible ;; -let test_empty_day_mutations_and_undo () = +let test_empty_day_mutations_and_delete () = let placeholder = block ~day:20260808 ~source:" " 1 in let sibling = block ~day:20260808 ~order:"b" 2 in let older = block ~day:20260807 3 in @@ -1358,21 +1119,18 @@ let test_empty_day_mutations_and_undo () = let hidden = Timeline.replace_block child placeholder in let two = Timeline.prepend_timeline_entry hidden (entry sibling) in require (Timeline.total_count two = 4) "creation restored an empty sibling"; - let deleted, backup = - Option.get (Timeline.stage_delete two ~block_id:(Journal_model.id sibling)) - in + let deleted = Timeline.remove_block two ~block_id:(Journal_model.id sibling) in require_equal_string_list (slot_keys deleted) older_keys "delete failed to hide remaining placeholder"; let changed = Timeline.replace_block deleted content in - let undone = Timeline.undo_delete changed backup in - require (Timeline.total_count undone = 5) "undo did not restore complete section"; + require (Timeline.total_count changed = 4) "later edit lost its section"; require (Journal_model.source - (Option.get (Timeline.find_block undone ~block_id:(Journal_model.id placeholder))) + (Option.get (Timeline.find_block changed ~block_id:(Journal_model.id placeholder))) = "Written later") - "undo overwrote a later edit to a hidden sibling"; + "delete overwrote a later edit to a hidden sibling"; let removed = Timeline.remove_block hidden ~block_id:(Journal_model.id placeholder) in require (Timeline.find_block removed ~block_id:(Journal_model.id placeholder) = None) @@ -1867,19 +1625,6 @@ let test_partial_day_parent_restore () = let () = test_partial_day_parent_restore (); test_parent_only_cards_and_point_updates (); - let failures = ref [] in - List.iter - (fun test -> - try test () with - | Failure message -> failures := message :: !failures) - [ test_undo_orders_target_among_intervening_siblings - ; test_undo_keeps_reappeared_hidden_target - ; test_undo_preserves_current_hidden_day_membership - ; test_undo_uses_current_day_order_when_old_anchor_is_gone - ; test_undo_keeps_reappeared_target_and_current_pagination - ; test_undo_retains_deleted_day_context_beyond_hidden_budget - ]; - require (!failures = []) "%s" (String.concat "\n" (List.rev !failures)); test_capture_converges_with_later_pagination (); test_capture_scroll_commands_are_explicit (); test_capture_scroll_terminal_ownership (); @@ -1887,10 +1632,10 @@ let () = test_parent_insertion_preserves_all_loaded_rows (); test_hidden_day_restoration_orders_siblings (); test_empty_day_visibility_boundaries (); - test_empty_day_mutations_and_undo (); + test_empty_day_mutations_and_delete (); test_empty_day_pagination_and_failure (); test_empty_day_retained_fragment_and_anchor (); - test_persistent_point_updates_and_undo (); + test_persistent_point_updates_and_delete (); test_capture_identity_converges_with_reconciliation (); test_stale_day_rebuild_is_atomic_and_generation_owned (); test_repeated_staleness_stops_until_retry_and_does_not_block_feed (); @@ -1909,6 +1654,5 @@ let () = test_populated_feed_refresh_preserves_position (); test_initial_feed_refresh_supersedes_pending_pagination (); test_prepend_first_today_entry_before_older_days (); - test_stage_delete_and_exact_undo (); - test_static_child_cannot_stage_delete_and_parent_delete_repairs_heading () + test_static_child_delete_is_ignored_and_parent_delete_repairs_heading () ;; diff --git a/test/macos_mutation_input_diagnostics_pure_reducer_test.ml b/test/macos_mutation_input_diagnostics_pure_reducer_test.ml index f6c2e229..1f6e6c39 100644 --- a/test/macos_mutation_input_diagnostics_pure_reducer_test.ml +++ b/test/macos_mutation_input_diagnostics_pure_reducer_test.ml @@ -322,52 +322,8 @@ let test_detail_reload_after_session_replacement () = equal routes unchanged "reload rejects the previous detail request completion" ;; -let test_undo_keeps_reconciled_sibling () = - let module Timeline = Journal_timeline_state in - let retained_slots state = - Timeline.fold_slots (fun slots slot -> slot :: slots) [] state |> List.rev - in - let target = block () in - let sibling_id = "70000000-0000-4000-a000-000000000002" in - let sibling = block ~id:sibling_id () in - let entry block : Journal_graph_projection.timeline_entry = - { block; child_summaries = [] } - in - let initial = - Timeline.empty ~today:20260901 - |> fun state -> - Timeline.prepend_timeline_entry state (entry sibling) - |> fun state -> Timeline.prepend_timeline_entry state (entry target) - in - let staged, backup = - Timeline.stage_delete initial ~block_id:(Journal_model.id target) |> Option.get - in - let latest = - block ~id:sibling_id ~source:"Reconciled sibling" ~revision:"sibling-2" () - in - let current = Timeline.replace_block staged latest in - let restored = Timeline.undo_delete current backup in - let blocks = - retained_slots restored - |> List.filter_map (function - | Timeline.Top_level entry -> Some entry.block - | _ -> None) - in - equal 2 (List.length blocks) "Undo restores only the deleted target"; - let actual = List.find (fun block -> Journal_model.id block = sibling_id) blocks in - equal - "sibling-2" - (Journal_model.revision actual) - "Undo restored an obsolete sibling revision"; - equal - "Reconciled sibling" - (Journal_model.source actual) - "Undo discarded reconciled sibling text" -;; - let cases = - [ "M03 Undo keeps reconciled sibling", test_undo_keeps_reconciled_sibling - ; "M05 reload after replacement session", test_detail_reload_after_session_replacement + [ "M05 reload after replacement session", test_detail_reload_after_session_replacement ; "M06 reopened request correlation", test_admission_reopen_fence ; "M05 Capture full-value pipeline", test_capture_pipeline ; "M05 Detail append and retry pipeline", test_detail_pipeline diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index a82c312e..bad6a45b 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1550,7 +1550,6 @@ let () = ; "Lui_app.create_with_extensions" ; "application_theme" ; "V.Sheet.create" - ; "Journal_platform.show_notice_request" ; "V.button" ; "local-cache-reset-confirmation" ; "journal-detail-route"