diff --git a/app/application.ml b/app/application.ml index e57a57a..303c3a6 100644 --- a/app/application.ml +++ b/app/application.ml @@ -148,7 +148,7 @@ type timeline_notice = | Copy_failed of string | Import_failed of string -let operation_failure = function +let operation_failure ?(can_retry_imports = false) = function | Some (Delete_failed message) -> Some ( "Delete failed" @@ -165,8 +165,12 @@ let operation_failure = function Some ( "Attachment not added" , message - , "Your journal entry and other attachments are kept. Select the file again to \ - retry." ) + , if can_retry_imports then + "Your journal entry and other attachments are kept. Select Retry imports to \ + try again." + else + "Your journal entry and other attachments are kept. Select the file again to \ + retry." ) | None | Some Delete_undo -> None ;; @@ -2245,26 +2249,24 @@ let composer_content match assets with | None -> [] | Some assets -> + let disabled = not (enabled && not saving && Journal_capture.can_attach capture) in + (* The shared buttons composite has no disabled state. Native leaf buttons + retain the same glass capsule and 44-point hit areas. *) let action ~label ~icon source = - V.buttons_action - ~label - ~icon - ~on_press: - (Ui.Event.Handler.create (fun _ -> - if enabled && (not saving) && Journal_capture.can_attach capture - then assets.on_attach source)) - () + Lui_elements.button + ~label ~icon:(`app icon) ~variant:`ghost ~size:`icon + ~foreground:"foreground" ~width:44 ~height:44 ~disabled + ~on_press:(fun _ -> if not disabled then assets.on_attach source) + [] in - [ V.buttons - ~actions: - ([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files - ; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos - ] - @ - if assets.camera - then [ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ] - else []) - () + [ V.of_lui + (Lui_elements.button_group ~gap:0 ~height:44 ~background:"glass" + ~corner_radius:999 + ([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files + ; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos ] + @ if assets.camera then + [ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ] + else [])) |> V.with_test_id (Ui.Test_id.string (scope ^ "-attach")) ] in @@ -2323,7 +2325,16 @@ let composer_content ~value:(Journal_capture.value capture) ~send_disabled:(not can_submit) ?attachments - ?feedback:(Option.map live_region_text error) + ?feedback: + (let messages = + Option.to_list error + @ if List.length pending >= Journal_capture.attachment_limit then + [ "9 attachments maximum. Remove one to add another." ] + else [] + in + match messages with + | [] -> None + | messages -> Some (V.column (List.map live_region_text messages))) ~actions:(attach @ [ task ] @ discard) ~on_edit:on_edit.native ~on_submit:ignored @@ -4478,6 +4489,11 @@ let start when Hashtbl.mem import_worker_requests request_id -> let generation, operation, owner = Hashtbl.find import_worker_requests request_id in Hashtbl.remove import_worker_requests request_id; + let interruption = + if Hashtbl.mem capture_staged_items operation then + "Import was interrupted. Select Retry imports to try again." + else "Import was interrupted. Select the file again." + in let capture_completion = match Hashtbl.find_opt capture_staged_items operation with | None -> Effect.ignore @@ -4503,10 +4519,10 @@ let start timeline_notice = (match owner with | None -> - Some (Import_failed "Import was interrupted. Select the file again.") + Some (Import_failed interruption) | Some _ -> state.timeline_notice) ; import_completion = - Some (operation, Some "Import was interrupted. Select the file again.") + Some (operation, Some interruption) }) in Effect.bind @@ -6741,7 +6757,10 @@ let start Some (error_info_page ~sync_error:(Option.bind state.manager (fun manager -> manager.last_error)) - ~operation_failure:(operation_failure state.timeline_notice) + ~operation_failure: + (operation_failure + ~can_retry_imports:(state.failed_capture_imports <> []) + state.timeline_notice) (newest_first_worker_errors state) dispatch) in diff --git a/app/journal_capture.ml b/app/journal_capture.ml index 1bc0041..cda811a 100644 --- a/app/journal_capture.ml +++ b/app/journal_capture.ml @@ -133,7 +133,27 @@ let rebind t ~session_number = ;; let phase t = t.phase -let source_is_blank source = String.equal (String.trim source) "" +(* Unicode White_Space, shared by button admission and attachment-only naming. + Do not trim meaningful drafts or treat format characters as whitespace. *) +let source_is_blank source = + let whitespace code = + (code >= 0x0009 && code <= 0x000d) + || (code >= 0x2000 && code <= 0x200a) + || match code with + | 0x0020 | 0x0085 | 0x00a0 | 0x1680 + | 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000 -> true + | _ -> false + in + let rec loop offset = + if offset = String.length source then true + else + let decoded = String.get_utf_8_uchar source offset in + Uchar.utf_decode_is_valid decoded + && whitespace (Uchar.to_int (Uchar.utf_decode_uchar decoded)) + && loop (offset + Uchar.utf_decode_length decoded) + in + loop 0 +;; let can_save t = t.phase = Editing && ((not (source_is_blank (source t))) || t.pending_attachments <> []) diff --git a/app/journal_view.ml b/app/journal_view.ml index 31572cc..4f298c8 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -867,7 +867,7 @@ module View = struct ~placeholder ~text:(Text_editing.Value.text value) ~autofocus - ~submit_on_enter:true + ~submit_on_enter:false ~send_disabled_signal:(Signal.constant context.Lui_ui.ui_scheduler send_disabled) ~actions:(List.map (fun action -> action.mount) actions) ~on_input: diff --git a/test/application_view_test.ml b/test/application_view_test.ml index a5fd469..6bd73ac 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -681,14 +681,16 @@ let run_favorites_native_visibility done; if source.title = "First attachment" && detail_capture <> Some "selection-error" - && (detail_capture <> Some "retry-imports" + && (not (List.mem detail_capture [Some "retry-imports"; Some "interrupted-imports"]) || List.length (List.filter (( = ) "First attachment") (Atomic.get imports_entered)) = 1) then ( while not (Atomic.get import_release) do Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001 done; Atomic.incr imports_finished; - Ok (Service.Asset_imported (Error "Fixture first attachment failed"))) + if List.mem detail_capture [Some "interrupted-imports"; Some "legacy-interrupted"] + then Error "Fixture interrupted import" + else Ok (Service.Asset_imported (Error "Fixture first attachment failed"))) else let module A = Logseq_db_types.Asset_descriptor in let asset = @@ -1474,6 +1476,38 @@ let run_favorites_native_visibility in let original_root_content = Option.get (find "text" "Timeline fixture 1") in enter 0; + if scenario = "legacy-interrupted" then ( + (* No pure reducer owns native completion copy or the Worker queue. + The existing non-staged adapter is tested via its public callback; + it has no visible iOS opener, so this is not a GUI claim. *) + let adapter = Hashtbl.fold (fun id values found -> + match List.assoc_opt "_extension" values, List.assoc_opt "payload" values with + | Some (`String "journal-asset-import"), Some (`String payload) -> + let request = Yojson.Safe.Util.member "request" (Yojson.Safe.from_string payload) in + if Yojson.Safe.Util.member "staged" request = `Bool false then Some id else found + | _ -> found) props None |> Option.get in + let operation = G.Uuid.to_string (uuid 34001) in + let path = Filename.temp_file "journal-legacy-import-" ".txt" in + Fun.protect ~finally:(fun () -> Sys.remove path) (fun () -> + let picked = `Assoc [ + "operation",`String operation;"asset",`String (G.Uuid.to_string (uuid 34002)); + "localMutation",`String (G.Uuid.to_string (uuid 34003)); + "metadataMutation",`String (G.Uuid.to_string (uuid 34004)); + "path",`String path;"title",`String "First attachment";"type",`String "txt"] in + hooks.extension_event adapter "event" (Yojson.Safe.to_string + (`Assoc ["id",`Int 1;"payload",`String (Yojson.Safe.to_string picked)])) |> consume; + Atomic.set import_release true; + wait "non-staged interruption requires source reselection" (fun () -> + Hashtbl.fold (fun _ values found -> found || + match List.assoc_opt "payload" values with + | Some (`String payload) -> + let json = Yojson.Safe.from_string payload in + Yojson.Safe.Util.member "completion" json = `String operation + && Yojson.Safe.Util.member "error" json = + `String "Import was interrupted. Select the file again." + | _ -> false) props false); + Alcotest.(check bool) "non-staged import cannot offer retained-byte retry" true + (Option.is_none (find "accessibility-label" "Retry imports")))); Alcotest.(check bool) "old Append toolbar action is removed" true @@ -1496,7 +1530,7 @@ let run_favorites_native_visibility if List.mem scenario - [ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports" ] + [ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports"; "attachment-limit"; "interrupted-imports" ] then ( dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件"))); let adapter, request = @@ -1539,7 +1573,9 @@ let run_favorites_native_visibility [ "action", `String "picked-batch" ; "request", request ; ( "items" - , `List [ first; second ] ) + , `List (if scenario = "attachment-limit" then + first :: second :: List.init 7 (fun i -> item (i + 3) (Printf.sprintf "Extra attachment %d" (i + 3))) + else [ first; second ]) ) ] in let batch = if scenario = "selection-error" then @@ -1569,6 +1605,24 @@ let run_favorites_native_visibility "accessibility-identifier" ("composer-attachment:" ^ G.Uuid.to_string (uuid 30002))) = 1); + if scenario = "attachment-limit" then ( + (* Capture.can_attach already rejects the tenth pick; only the + real view boundary owns disabled controls and cap feedback. *) + let check enabled = List.iter (fun label -> + let node = Option.get (find "accessibility-label" label) in + Alcotest.(check (option bool)) (label ^ " enabled matches capacity") + (Some enabled) + (Option.map Yojson.Safe.Util.to_bool + (List.assoc_opt "enabled" (Hashtbl.find props node)))) ["文件";"照片";"相机"] in + check false; + Alcotest.(check bool) "cap explains how to add another attachment" true + (Option.is_some (find "text" "9 attachments maximum. Remove one to add another.")); + for i = 3 to 9 do + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" (Printf.sprintf "Remove Extra attachment %d" i)))) + done; + check true; + Alcotest.(check bool) "removal retires cap feedback" true + (Option.is_none (find "text" "9 attachments maximum. Remove one to add another."))); if scenario = "selection-error" then ( wait "partial selection failure is visible on its owner" (fun () -> Option.is_some (find "text" "Fixture partial selection failed")); collapse (); back (); enter 2; open_capture (); @@ -1645,10 +1699,21 @@ let run_favorites_native_visibility (Atomic.get imports_entered); Atomic.set import_release true; wait "both imports finish" (fun () -> Atomic.get imports_finished = 2); - if scenario = "retry-imports" + if List.mem scenario ["retry-imports";"interrupted-imports"] then ( wait "failed import exposes target retry" (fun () -> Option.is_some (find "accessibility-label" "Retry imports")); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Details"))); + wait "retained imports explain in-place retry" (fun () -> + Option.is_some (find "text" + "Your journal entry and other attachments are kept. Select Retry imports to try again.")); + if scenario = "interrupted-imports" then + Alcotest.(check bool) "interrupted retained import gives retry guidance" true + (Option.is_some (find "text" "Import was interrupted. Select Retry imports to try again.")); + Alcotest.(check bool) "retained import never requires reselection" true + (Option.is_none (find "text" + "Your journal entry and other attachments are kept. Select the file again to retry.")); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Close"))); (* Routes reducers own the path, but cannot express native feedback ancestry or layout. Mount the real failure through the public Application boundary; navigation must cover the @@ -1748,14 +1813,14 @@ let run_favorites_native_visibility "import retry does not create another child" 1 (Atomic.get child_attempts)); - if not (List.mem scenario [ "retry-imports"; "selection-error" ]) then ( + if not (List.mem scenario [ "retry-imports"; "interrupted-imports"; "selection-error" ]) then ( wait "failed import offers explicit discard" (fun () -> Option.is_some (find "accessibility-label" "Dismiss")); dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Dismiss")))); wait "all staging files are released" (fun () -> List.for_all (fun p -> not (Sys.file_exists p)) !staged_paths); Alcotest.(check (list string)) "imports target the newly appended child" - (List.init (if scenario = "retry-imports" then 3 else 2) (fun _ -> G.Uuid.to_string child.uuid)) + (List.init (if List.mem scenario ["retry-imports";"interrupted-imports"] then 3 else 2) (fun _ -> G.Uuid.to_string child.uuid)) (List.map G.Uuid.to_string (Atomic.get import_targets)); if scenario = "offscreen-attachments" then @@ -3122,6 +3187,11 @@ let run_favorites_native_visibility true (Option.is_some (find "style-class" "composer-input")); let editor = Option.get (find "style-class" "composer-input") in + (* Navigation/Capture reducers cannot express the native keyboard label. *) + Alcotest.(check (option bool)) "multiline composer requests Return" + (Some false) + (Option.map Yojson.Safe.Util.to_bool + (List.assoc_opt "submit-on-enter" (Hashtbl.find props editor))); Hashtbl.clear regions; dispatch (Lui_protocol.TextChanged (editor, "iOS persistent draft")); for _ = 1 to 10 do @@ -4649,6 +4719,9 @@ let () = ; "selection-error" ; "picker-owner" ; "retry-imports" + ; "attachment-limit" + ; "interrupted-imports" + ; "legacy-interrupted" ] ) ; ( "Capture attachment imports" , [ Alcotest.test_case diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index 6ef40b4..31bc75e 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -2811,6 +2811,45 @@ let test_detail_retry_uses_128_item_first_page () = | _ -> fail "Detail retry lost its first-page owner" ;; +(* Capture owns admission, source preservation and attachment-only naming; + reproduce Unicode blank input through its public reducer boundary. *) +let test_unicode_blank_capture_admission () = + let whitespace = List.init 5 (fun i -> Uchar.of_int (i + 9)) + @ List.map Uchar.of_int [0x20;0x85;0xa0;0x1680] + @ List.init 11 (fun i -> Uchar.of_int (i + 0x2000)) + @ List.map Uchar.of_int [0x2028;0x2029;0x202f;0x205f;0x3000] in + let encoded chars = + let b = Buffer.create 64 in + List.iter (Buffer.add_utf_8_uchar b) chars; Buffer.contents b in + let blank_sources = "" :: "  \n " :: encoded whitespace :: List.map (fun c -> encoded [c]) whitespace in + let admit capture = Journal_capture.admit_save capture + ~mutation_id:"70000000-0000-4000-9000-000000000014" + ~block_id:"70000000-0000-4000-a000-000000000014" + ~sibling_order:"000000000014" ~calendar_generation:7L + ~creation_time:(creation_time 543) in + let pick = staged_pick ~operation:"70000000-0000-4000-a000-00000000a014" + ~path:"/tmp/blank-title.txt" ~title:"blank-title.txt" ~file_type:"txt" in + List.iter (fun source -> + let capture = Journal_capture.create ~session_number:14L ~source in + require (not (Journal_capture.can_save capture)) "Unicode blank %S enabled Send" source; + require (Option.is_none (snd (admit capture))) "Unicode blank %S admitted Save" source; + let attached = Journal_capture.add_attachment capture pick in + require (Journal_capture.can_save attached) "Unicode blank attachment-only disabled Save"; + match snd (admit attached) with + | Some (Journal_graph_request.Capture {command;_}) -> + require_string "blank-title.txt" command.source "blank attachment-only title" + | _ -> fail "Unicode blank attachment-only did not admit Save") blank_sources; + List.iter (fun source -> + let capture = Journal_capture.create ~session_number:15L ~source:"" in + let capture = Journal_capture.update_source capture ~source in + require (Journal_capture.can_save capture) "Meaningful text %S disabled Send" source; + match snd (admit capture) with + | Some (Journal_graph_request.Capture {command;_}) -> + require_string source command.source "meaningful source must remain exact" + | _ -> fail "Meaningful text %S did not admit Save" source) + [" 中文\n second line ";" 🌱 ";"​";""] +;; + (* These regressions reproduce the review findings through Detail's public owner, including native visibility observations and admitted completions. *) let followup_block number parent = @@ -3145,6 +3184,8 @@ let test_followup_review_regressions () = ;; let tests = + [ "Unicode blank Capture admission", test_unicode_blank_capture_admission + ] @ [ "PR63 public review regressions", test_followup_review_regressions ] @ [ "projected unknown children are read once", test_projected_unknown_children_are_read_once