From 3ab2b8b0597f3c0116c50b43813cbdfe7ff80d17 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 11:01:49 +0800 Subject: [PATCH 1/5] Fix Android smoke setup and native modal identifiers --- .../logseq/app/AndroidNativeSurfacesTest.kt | 50 +++++++++++++++++ apple/Package.resolved | 2 +- apple/Package.swift | 2 +- logseq.opam | 2 +- tests/e2e/android-capture-search.yaml | 3 ++ tests/e2e/android-local-graph-setup.yaml | 53 ++++++++++++------- tests/e2e/android-search-navigation.yaml | 1 + 7 files changed, 90 insertions(+), 23 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNativeSurfacesTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNativeSurfacesTest.kt index 8eddda05..5ef81a71 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNativeSurfacesTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNativeSurfacesTest.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.logseq.app.runtime.logseqIconResolver import dev.lui.LuiBackend import dev.lui.LuiEvent @@ -135,6 +136,55 @@ class AndroidNativeSurfacesTest { ui.runOnIdle { assertEquals(listOf(LuiEvent.Dismiss(2)), events) } } + private fun assertModalControlsExposeResourceIds(kind: String) { + render(kind) + ui.runOnIdle { + val ops = JSONArray() + listOf("text-field", "switch", "button").forEachIndexed { index, control -> + val id = index + 3 + ops.put(op("create-node", "id" to id, "kind" to control)) + .put(op("set-prop", "id" to id, "property" to "accessibility-identifier", "value" to "modal.$control")) + .put(op("set-prop", "id" to id, "property" to "text", "value" to control)) + .put(op("insert-child", "parent" to 2, "child" to id, "index" to index)) + } + apply(ops) + } + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val originalInfo = automation.serviceInfo + automation.serviceInfo = android.accessibilityservice.AccessibilityServiceInfo().apply { + eventTypes = originalInfo.eventTypes + feedbackType = originalInfo.feedbackType + flags = originalInfo.flags or + android.accessibilityservice.AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or + android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + fun resourceIds(): Set { + val pending = java.util.ArrayDeque() + automation.windows.forEach { window -> window.root?.let { pending.add(it) } } + val ids = mutableSetOf() + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + node.viewIdResourceName?.let { ids.add(it) } + repeat(node.childCount) { index -> node.getChild(index)?.let { pending.add(it) } } + } + return ids + } + val expected = setOf("modal.text-field", "modal.switch", "modal.button") + try { + ui.waitUntil(5_000) { resourceIds().containsAll(expected) } + } finally { + automation.serviceInfo = originalInfo + } + } + + @Test fun dialogControlsExposeResourceIdsInTheirOwnNativeWindow() { + assertModalControlsExposeResourceIds("dialog") + } + + @Test fun sheetControlsExposeResourceIdsInTheirOwnNativeWindow() { + assertModalControlsExposeResourceIds("sheet") + } + @Test fun selectedListItemsExposeNativeSingleSelection() { render("list-item", mapOf("text" to "Example", "selected" to true, "press-enabled" to true)) ui.onNodeWithText("Example").assertIsSelected().performClick() diff --git a/apple/Package.resolved b/apple/Package.resolved index ccb47c81..77e5e272 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "db5cca5056a76775b2ce12d085c9eadde44cc42d" + "revision" : "52aae8634c9add1e0c11fecfdda102b3f3644b08" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 00c4f810..3b7d2922 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -44,7 +44,7 @@ let package = Package( .library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "db5cca5056a76775b2ce12d085c9eadde44cc42d"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "52aae8634c9add1e0c11fecfdda102b3f3644b08"), .package(url: "https://github.com/gonzalezreal/swiftui-math", from: "0.1.0"), .package(url: "https://github.com/appstefan/highlightswift.git", from: "1.1.0") ], diff --git a/logseq.opam b/logseq.opam index c0d532e0..3f826fc7 100644 --- a/logseq.opam +++ b/logseq.opam @@ -34,7 +34,7 @@ pin-depends: [ ["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] - ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#db5cca5056a76775b2ce12d085c9eadde44cc42d"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#52aae8634c9add1e0c11fecfdda102b3f3644b08"] ["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"] diff --git a/tests/e2e/android-capture-search.yaml b/tests/e2e/android-capture-search.yaml index 29d5004e..479209b3 100644 --- a/tests/e2e/android-capture-search.yaml +++ b/tests/e2e/android-capture-search.yaml @@ -59,6 +59,9 @@ appId: com.logseq.app - assertVisible: id: "screen.search" - assertVisible: "Android E2E capture" +# Returning to search restores focus to its field. Back first dismisses the +# Android IME; close the keyboard explicitly before testing route dismissal. +- hideKeyboard - pressKey: BACK - assertVisible: id: "button.composer.expand" diff --git a/tests/e2e/android-local-graph-setup.yaml b/tests/e2e/android-local-graph-setup.yaml index 8155539a..07b7529d 100644 --- a/tests/e2e/android-local-graph-setup.yaml +++ b/tests/e2e/android-local-graph-setup.yaml @@ -15,28 +15,34 @@ appId: com.logseq.app - tapOn: "Use without an account" - extendedWaitUntil: visible: - id: "signInFormUsername" + id: "signInFormUsername|screen.graph-picker|button.composer.expand" timeout: 30000 - - tapOn: - id: "signInFormUsername" - - inputText: ${USERNAME} + # A retained browser session can complete sign-in without the form. - runFlow: when: - visible: "Try out your stylus" + visible: + id: "signInFormUsername" commands: - - tapOn: "Cancel" - - tapOn: - id: "signInFormPassword" - - inputText: ${PASSWORD} - - hideKeyboard - - extendedWaitUntil: - visible: "submit" - timeout: 10000 - - tapOn: "submit" - - extendedWaitUntil: - visible: - id: "screen.graph-picker" - timeout: 60000 + - tapOn: + id: "signInFormUsername" + - inputText: ${USERNAME} + - runFlow: + when: + visible: "Try out your stylus" + commands: + - tapOn: "Cancel" + - tapOn: + id: "signInFormPassword" + - inputText: ${PASSWORD} + - hideKeyboard + - extendedWaitUntil: + visible: "submit" + timeout: 10000 + - tapOn: "submit" + - extendedWaitUntil: + visible: + id: "screen.graph-picker|button.composer.expand" + timeout: 60000 - runFlow: when: visible: @@ -46,8 +52,15 @@ appId: com.logseq.app id: "button.graph-add" - extendedWaitUntil: visible: - id: "field.graph-name" + # Login can finish opening the bootstrap graph after the picker + # was observed. Wait for either stable outcome of that transition. + id: "field.graph-name|button.composer.expand" timeout: 30000 +- runFlow: + when: + visible: + id: "field.graph-name" + commands: - tapOn: id: "field.graph-name" - inputText: "android-e2e-smoke" @@ -65,5 +78,5 @@ appId: com.logseq.app timeout: 60000 - extendedWaitUntil: visible: - id: "button.sidebar" + id: "button.composer.expand" timeout: 60000 diff --git a/tests/e2e/android-search-navigation.yaml b/tests/e2e/android-search-navigation.yaml index 0a10fee7..800970d8 100644 --- a/tests/e2e/android-search-navigation.yaml +++ b/tests/e2e/android-search-navigation.yaml @@ -27,6 +27,7 @@ appId: com.logseq.app - assertVisible: id: "screen.search" - assertVisible: "E2E Page Target 20260816" +- hideKeyboard - pressKey: BACK - assertVisible: id: "button.search" From c7ea5def788dceef44bccd335901fe9a395edeba Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 11:05:17 +0800 Subject: [PATCH 2/5] Use a CI sync server that supplies snapshot transaction cursors --- .github/actions/db-sync-server/action.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/actions/db-sync-server/action.yml b/.github/actions/db-sync-server/action.yml index caf74ea7..d5b5cfff 100644 --- a/.github/actions/db-sync-server/action.yml +++ b/.github/actions/db-sync-server/action.yml @@ -1,13 +1,15 @@ name: Start db-sync server description: >- - Check out logseq@feature/native-mobile-app, build the db-sync node adapter, + Check out the native-compatible Logseq revision, build the db-sync node adapter, and start it on the given port (default 8787). The app e2e flows create sync graphs against it. logseq/logseq is public, so no credential is needed. inputs: ref: description: logseq/logseq ref to check out. required: false - default: feature/native-mobile-app + # This revision includes the snapshot transaction cursor required by the + # shared native bootstrap protocol. The old feature branch omitted it. + default: a63da5cb7993302f8c58ccf42eaf0083b77cf42b port: description: Port the server binds. required: false From 80ecd99a3ac6a4742e03f60242fa44a45798b7fb Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 16:56:00 +0800 Subject: [PATCH 3/5] Fix E2E server preferences and current Settings navigation --- scripts/test-android-e2e-runner.sh | 2 ++ scripts/test-android-e2e.sh | 2 +- tests/e2e/ios-capture-responsive.yaml | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/test-android-e2e-runner.sh b/scripts/test-android-e2e-runner.sh index 167c5f49..781391d5 100755 --- a/scripts/test-android-e2e-runner.sh +++ b/scripts/test-android-e2e-runner.sh @@ -259,6 +259,8 @@ PATH="$mock_bin:$PATH" \ "$runner" connect >/dev/null grep -Fxq -- '-s test-device reverse tcp:8787 tcp:8787' "$adb_args" \ || die "Android E2E runner did not expose the host-local backend to the device" +grep -Eq 'run-as com\.logseq\.app cp .* shared_prefs/logseq\.xml$' "$adb_args" \ + || die "Android E2E server override did not use the app's SharedPreferences file" PATH="$mock_bin:$PATH" \ ANDROID_SERIAL=test-device \ diff --git a/scripts/test-android-e2e.sh b/scripts/test-android-e2e.sh index fa6d99ed..27479904 100755 --- a/scripts/test-android-e2e.sh +++ b/scripts/test-android-e2e.sh @@ -380,7 +380,7 @@ if [[ -n ${LOGSEQ_E2E_BASE_URL:-} ]]; then '' >"$preferences_file" adb -s "$device" push "$preferences_file" "$remote_preferences" >/dev/null adb -s "$device" shell run-as "$app_id" mkdir -p shared_prefs - adb -s "$device" shell run-as "$app_id" cp "$remote_preferences" shared_prefs/defaults.xml + adb -s "$device" shell run-as "$app_id" cp "$remote_preferences" shared_prefs/logseq.xml adb -s "$device" shell rm -f "$remote_preferences" fi diff --git a/tests/e2e/ios-capture-responsive.yaml b/tests/e2e/ios-capture-responsive.yaml index cadc22ad..a6621aca 100644 --- a/tests/e2e/ios-capture-responsive.yaml +++ b/tests/e2e/ios-capture-responsive.yaml @@ -15,8 +15,9 @@ appId: com.logseq.logseq - assertVisible: id: "button.composer.expand" - tapOn: - id: "button.connection" -- tapOn: "Settings" + id: "button.sidebar" +- tapOn: + id: "button.sidebar.settings" - assertVisible: "Sync server" - assertVisible: id: "field.base-url" From e9eb00ac33836be490ebd4cae7c9f7fe3ccb126d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 17:11:29 +0800 Subject: [PATCH 4/5] Return from sidebar Settings before testing capture --- tests/e2e/ios-capture-responsive.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/ios-capture-responsive.yaml b/tests/e2e/ios-capture-responsive.yaml index a6621aca..4a959714 100644 --- a/tests/e2e/ios-capture-responsive.yaml +++ b/tests/e2e/ios-capture-responsive.yaml @@ -23,6 +23,8 @@ appId: com.logseq.logseq id: "field.base-url" - tapOn: id: "button.connection.cancel" +- tapOn: + id: "button.sidebar.dismiss" - assertVisible: id: "button.composer.expand" - tapOn: From fc066a5a7201c6809e8dc6db0839870150f80016 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 17:24:13 +0800 Subject: [PATCH 5/5] Reduce native query execution and result preparation costs (#73) --- logseq.opam | 4 +- shared/src/logseq/core/graph_read.ml | 12 +++++ shared/src/logseq/core/graph_read.mli | 6 +++ shared/src/logseq/core/query_block.ml | 2 +- shared/src/logseq/core/result_view.ml | 3 +- shared/src/logseq/core/rpc_session.ml | 28 ++++++----- shared/src/logseq/view_query.ml | 37 +++++++------- shared/test/logseq/query_block_test.ml | 68 ++++++++++++++++++++++++++ shared/test/logseq/result_view_test.ml | 50 +++++++++++++++++++ 9 files changed, 176 insertions(+), 34 deletions(-) diff --git a/logseq.opam b/logseq.opam index 3f826fc7..65e48559 100644 --- a/logseq.opam +++ b/logseq.opam @@ -25,8 +25,8 @@ depends: [ "uunf" ] pin-depends: [ - ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] - ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] + ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#505c85564d62dc2864c43a0634ad65ee6f58550c"] + ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#505c85564d62dc2864c43a0634ad65ee6f58550c"] ["persistent_sorted_set_ocaml.dev" "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#695223e0a28cec1ff13a6ac7e870e4983ad3bb46"] ["melange-edn-core.0.5.0" "git+https://github.com/logseq/melange-edn.git#main"] ["melange-edn-native.0.5.0" "git+https://github.com/logseq/melange-edn.git#main"] diff --git a/shared/src/logseq/core/graph_read.ml b/shared/src/logseq/core/graph_read.ml index b6c6dda4..4e99fc64 100644 --- a/shared/src/logseq/core/graph_read.ml +++ b/shared/src/logseq/core/graph_read.ml @@ -1012,6 +1012,18 @@ let blocks_for_page decrypt_title db uuid = read_block ~with_breadcrumbs:false ~page_info decrypt_title db row.entity_id) |> List.of_seq +let children_for_block decrypt_title db (parent : Model.block) = + match Ds.entid db "block/uuid" (Ds.Uuid parent.uuid) with + | None -> [] + | Some eid -> + let reader = page_reader db parent.page_id in + let page_info = lazy (parent.page_id, parent.journal) in + ordered_page_children reader.tree eid + |> List.filter_map (fun child -> + if page_entity_visible reader.tree child then + read_block ~with_breadcrumbs:false ~page_info decrypt_title db child + else None) + let node_destination decrypt_title db uuid = match Ds.entid db "block/uuid" (Ds.Uuid uuid) with | Some eid -> diff --git a/shared/src/logseq/core/graph_read.mli b/shared/src/logseq/core/graph_read.mli index 7f353bac..22046078 100644 --- a/shared/src/logseq/core/graph_read.mli +++ b/shared/src/logseq/core/graph_read.mli @@ -101,6 +101,12 @@ val blocks_for_page : Datascript.db -> string -> Cache_model.block list + +(** Hydrate direct outline children through the parent index, excluding metadata. *) +val children_for_block : + (string -> (string, string) result) -> Datascript.db -> + Cache_model.block -> Cache_model.block list + val references_for_node : (string -> (string, string) result) -> Datascript.db -> diff --git a/shared/src/logseq/core/query_block.ml b/shared/src/logseq/core/query_block.ml index 4a828cdd..c77dfd87 100644 --- a/shared/src/logseq/core/query_block.ml +++ b/shared/src/logseq/core/query_block.ml @@ -138,7 +138,7 @@ let execute ?current_page db owner definition source = | Some rows -> rows | None -> invalid_arg "Invalid query DSL") in rows, field "result-transform" config, field "remove-block-children?" config <> Some (Ds.QueryFormBool false) | None -> - (match Dev.Db_query_dsl.execute_query db source opts with + (match Dev.Db_query_dsl.execute_query db source {opts with opt_block_attrs = Some "[:db/id]"} with | Some rows -> rows, None, true | None -> invalid_arg "Invalid query DSL") in let rows = filter_rows db owner definition remove_children query_rows in diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 2b2f755b..b4d9cfc6 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -229,9 +229,10 @@ let render_rows ?(input = "") db ~owner ~feature ~encode_block rows = | Ds.Keyword attr when public_attr attr -> Some attr | _ -> None) |> List.of_seq in let catalog = List.sort_uniq String.compare (known @ List.map (fun p -> p.attr) available) |> List.map (property db) in + let cell_properties = if typ = "list" then [] else properties in `Assoc (fields @ ["error", `Null; "columns", `List (List.map (column_json db original_rows) properties); "properties", `List (List.map (column_json db original_rows) catalog); - "rows", `List (List.map (row_json db encode_block properties) rows)]) + "rows", `List (List.map (row_json db encode_block cell_properties) rows)]) with error -> `Assoc (fields @ ["error", `String (match error with Invalid_argument message -> message | _ -> Printexc.to_string error); "rows", `List []; "columns", `List []]) in `Assoc ["ownerUuid", `String owner; "featureType", `String feature; "views", `List (List.map view_json views)] diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index f2263d01..198c12d3 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -101,30 +101,32 @@ let summary_json session summary = Rpc.summary_json (current_summary session sum let result_block_encoder db = let presentation = Node_presentation.decorate db in - let pages = Hashtbl.create 4 in + let children = Hashtbl.create 32 in + let encoded = Hashtbl.create 32 in let encode_tree block = - let children = match Hashtbl.find_opt pages block.Model.page_id with + let child_blocks (block : Model.block) = match Hashtbl.find_opt children block.uuid with | Some children -> children | None -> - let children = Hashtbl.create 16 in - List.iter (fun (block : Model.block) -> Option.iter (fun parent -> - let siblings = Option.value ~default:[] (Hashtbl.find_opt children parent) in - Hashtbl.replace children parent (block :: siblings)) block.parent_id) - (Graph_read.blocks_for_page (fun text -> Ok text) db block.page_id); - Hashtbl.iter (fun parent blocks -> Hashtbl.replace children parent - (List.sort Outliner.compare_blocks blocks)) children; - Hashtbl.replace pages block.page_id children; - children in + let blocks = Graph_read.children_for_block (fun text -> Ok text) db block + |> List.sort Outliner.compare_blocks in + Hashtbl.add children block.uuid blocks; + blocks in let rec encode ancestors depth (block : Model.block) = if depth > 128 then invalid_arg "Result block nesting is too deep"; - let descendants = Option.value ~default:[] (Hashtbl.find_opt children block.uuid) + let descendants = child_blocks block |> List.filter (fun (child : Model.block) -> not (Outliner.Sset.mem child.uuid ancestors)) in let ancestors = Outliner.Sset.add block.uuid ancestors in match presentation block.uuid (Rpc.block_json block) with | `Assoc fields -> `Assoc (("children", `List (List.map (encode ancestors (depth + 1)) descendants)) :: fields) | value -> value in encode Outliner.Sset.empty 0 block in - encode_tree + fun (block : Model.block) -> + match Hashtbl.find_opt encoded block.uuid with + | Some value -> value + | None -> + let value = encode_tree block in + Hashtbl.add encoded block.uuid value; + value let query_for session db uuid = let current_page = match active_node_route session with diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 28999345..384214c6 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -17,9 +17,9 @@ let rec decode_block depth json = | _ -> [] in {row = {row with Model.query_json = ""; has_children = children <> []; is_collapsed = children <> []}; children} -let decode raw = +let invalid_query = {rows = []; error = Some "Unable to display query results"; source = ""} +let decode_json json = try - let json = Yojson.Basic.from_string raw in let rows = match member "rows" json with Some (`List rows) -> rows | _ -> [] in {source = string (member "source" json); error = (match member "error" json with Some (`String text) -> Some text | _ -> None); @@ -31,7 +31,8 @@ let decode raw = | None -> let cells = match member "cells" json with Some (`List xs) -> xs | _ -> [] in {index; uuid = None; title = String.concat " ยท " (List.map (fun value -> string (Some value)) cells); block = None; json}) rows} - with _ -> {rows = []; error = Some "Unable to display query results"; source = ""} + with _ -> invalid_query +let decode raw = try decode_json (Yojson.Basic.from_string raw) with _ -> invalid_query let result_item owner render_block send source : t = fun context parent -> @@ -112,8 +113,8 @@ let legacy_view ~render_block _model_source row_source send : t = let block_values ~render_block owner source send = let render_block source send = render_block (Signal.map (fun row -> {row with Model.row_breadcrumb = ""; row_breadcrumbs = []}) source) send in - let rows = Signal.map (fun json -> (decode (Yojson.Basic.to_string (`Assoc [ - "rows", `List (match json with `List blocks -> List.map (fun block -> `Assoc ["block", block]) blocks | _ -> [])]))).rows) source in + let rows = Signal.map (fun json -> (decode_json (`Assoc [ + "rows", `List (match json with `List blocks -> List.map (fun block -> `Assoc ["block", block]) blocks | _ -> [])])).rows) source in keyed ~source:rows ~key:(fun item -> Option.value ~default:"" item.uuid) ~cmp:String.compare ~mount:(result_item owner render_block send) @@ -123,7 +124,7 @@ let jfield name json = Option.value ~default:`Null (member name json) let jtext json = string (Some json) let jlist = function `List values -> values | _ -> [] let jbool = function `Bool value -> value | _ -> false -let items json = (decode (Json.to_string json)).rows +let items json = (decode_json json).rows let text_value = function | `String value -> value | `Int value -> string_of_int value | `Float value -> string_of_float value | `Bool value -> string_of_bool value | `Null -> "" | value -> Json.to_string value @@ -138,12 +139,13 @@ let local_input set = function Lui_protocol.TextChanged (_, value) -> set value let list_take count values = List.filteri (fun index _ -> index < count) values type saved_view = {id : string; name : string; typ : string; data : Json.t} -let saved_views raw = - try Json.from_string raw |> jfield "views" |> jlist |> List.mapi (fun index data -> +let saved_views_json json = + try json |> jfield "views" |> jlist |> List.mapi (fun index data -> let id = jtext (jfield "uuid" data) in {id = (if id = "" then "invalid." ^ string_of_int index else id); name = jtext (jfield "name" data); typ = jtext (jfield "type" data); data}) with _ -> [] +let saved_views raw = try saved_views_json (Json.from_string raw) with _ -> [] let operator_title = function | "is" -> "Is" | "is-not" -> "Is not" | "text-contains" -> "Contains" | "text-not-contains" -> "Does not contain" | "number-gt" -> "Greater than" @@ -554,15 +556,16 @@ let value_sheet owner feature view model_source send item property close = let collection ~render_block model_source raw_source send : t = fun context parent -> let own value = Signal.own_signal context.ui_scope value in - let initial = try Json.from_string (Signal.sample raw_source) with _ -> `Null in + let parsed = own (Signal.map (fun raw -> try Json.from_string raw with _ -> `Null) raw_source) in + let initial = Signal.sample parsed in let owner = jtext (jfield "ownerUuid" initial) and feature = jtext (jfield "featureType" initial) in - let views = own (Signal.map saved_views raw_source) in + let views = own (Signal.map saved_views_json parsed) in let active = Signal.state context.ui_scheduler "" and search = Signal.state context.ui_scheduler "" in - let is_query_expansion raw = try member "expanded" (Json.from_string raw) <> None with _ -> false in - let source_expanded raw = try match member "expanded" (Json.from_string raw) with Some (`Bool value) -> value | _ -> true with _ -> true in - let expanded = Signal.state context.ui_scheduler (source_expanded (Signal.sample raw_source)) in - ignore (Signal.own context.ui_scope (Signal.observe raw_source (fun raw -> - if is_query_expansion raw then Signal.set expanded (source_expanded raw)))); + let is_query_expansion json = member "expanded" json <> None in + let source_expanded json = match member "expanded" json with Some (`Bool value) -> value | _ -> true in + let expanded = Signal.state context.ui_scheduler (source_expanded initial) in + ignore (Signal.own context.ui_scope (Signal.observe parsed (fun json -> + if is_query_expansion json then Signal.set expanded (source_expanded json)))); let search_open = Signal.state context.ui_scheduler false in let chosen = own (Signal.map2 (fun views id -> match List.find_opt (fun view -> view.id = id) views with Some view -> [view] @@ -570,7 +573,7 @@ let collection ~render_block model_source raw_source send : t = let results_toggle () = let label = own (Signal.map2 (fun views expanded -> if not expanded then "Results" else - string_of_int (List.fold_left (fun count view -> max count (List.length (items view.data))) 0 views) ^ " results") + string_of_int (List.fold_left (fun count view -> max count (List.length (jlist (jfield "rows" view.data)))) 0 views) ^ " results") views (Signal.value expanded)) in View_base.with_label_signal label (button ~variant:`ghost ~text_alignment:`start ~grow:1.0 @@ -578,7 +581,7 @@ let collection ~render_block model_source raw_source send : t = ~disabled_signal:(waiting model_source owner) ~on_press:(fun _ -> let next = not (Signal.sample (Signal.value expanded)) in - if is_query_expansion (Signal.sample raw_source) then + if is_query_expansion (Signal.sample parsed) then ignore (send (Model.SubmitResultView (owner, payload owner feature "" "setExpanded" ["expanded", `Bool next]))) else Signal.set expanded next) [results_disclosure context ("query.disclosure." ^ owner) (Signal.value expanded) label]) in diff --git a/shared/test/logseq/query_block_test.ml b/shared/test/logseq/query_block_test.ml index fd87a8dd..4243bf26 100644 --- a/shared/test/logseq/query_block_test.ml +++ b/shared/test/logseq/query_block_test.ml @@ -506,7 +506,75 @@ let results_disclosure_executes_real_session () = ["#Project"; "{:query [:find (pull ?b [*]) :where [?b :user.property/score ?s] [(> ?s 1)]]}"]) [App_test.ios_backend; App_test.kotlin_backend] +let result_encoding_reads_subtrees_without_hydrating_page_siblings () = + let db = fixture "#Project" in + let siblings = List.init 5_000 Fun.id |> List.concat_map (fun index -> + let id = Ds.Entity_id (1_000_000 + index) in + [Ds.Add (id, "block/uuid", Ds.Uuid (Printf.sprintf "30000000-0000-4000-8000-%012d" index)); + Ds.Add (id, "block/title", Ds.String ("Unrelated sibling " ^ string_of_int index)); + Ds.Add (id, "block/page", Ds.Ref_to (ref_uuid page)); + Ds.Add (id, "block/parent", Ds.Ref_to (ref_uuid page))]) in + let db = Ds.db_with (put beta "block/parent" (Ds.Ref_to (ref_uuid alpha)) :: siblings) db in + let storage = Ds.memory_storage () in + let snapshot = match Graph_bootstrap.snapshot_rows db with Ok rows -> rows | Error error -> fail error in + storage.Ds.storage_store (List.map (fun (row : Snapshot.snapshot_row) -> + string_of_int row.addr, Storage_codec.decode row.addresses row.content) snapshot); + let reads = ref 0 in + let counted = {storage with Ds.storage_restore = (fun address -> + incr reads; storage.storage_restore address)} in + let db = match Ds.restore counted with Some db -> db | None -> fail "missing restored query db" in + let session = Session.create_session {Session.default_options with graph_db = Some (fun () -> Some db)} in + reads := 0; + let result = query session in + check_eq (row_ids result) [alpha]; + let block = member "block" (List.hd (items "rows" result)) in + check_eq ~msg:"nonmatching children remain available under the result" + (List.map (member "uuid") (items "children" block)) [`String beta]; + check ~msg:(Printf.sprintf "encoding one result must not hydrate 5k unrelated siblings (%d storage reads)" !reads) + (!reads < 300) + +let reference_query_keeps_bound_values_indexed value expected () = + let source = Printf.sprintf "(property :user.property/assignee %S)" value in + let db = fixture source in + let attr = "user.property/assignee" in + let db = {db with Ds.schema = (attr, {Storage_codec.default_schema_attr with + Ds.cardinality = Ds.Many; value_type = Some Ds.RefType}) :: db.schema} in + let extras = List.init 5_000 (fun index -> + Ds.Add (Ds.Entity_id (1_000_000 + index), "block/title", Ds.String ("Unrelated title " ^ string_of_int index))) in + let db = Ds.db_with ([ + Ds.Add (Ds.Entity_id 920000, "db/ident", Ds.Keyword attr); + Ds.Add (Ds.Entity_id 920000, "db/cardinality", Ds.Keyword "db.cardinality/many"); + Ds.Add (Ds.Entity_id 920000, "db/valueType", Ds.Keyword "db.type/ref"); + Ds.Add (Ds.Entity_id 920000, "logseq.property/type", Ds.Keyword "node"); + Ds.Add (Ds.Entity_id 920000, "block/tags", Ds.Ref_to (Ds.Ident "logseq.class/Property")); + Ds.Add (Ds.Entity_id 920001, "block/title", Ds.String "Alice"); + Ds.Add (Ds.Entity_id 920002, "block/title", Ds.String "Bob"); + put alpha attr (Ds.Ref 920001); put alpha attr (Ds.Ref 920002); + put beta attr (Ds.Ref 920002)] @ extras) db in + let storage = Ds.memory_storage () in + let snapshot = match Graph_bootstrap.snapshot_rows db with Ok rows -> rows | Error error -> fail error in + storage.Ds.storage_store (List.map (fun (row : Snapshot.snapshot_row) -> + string_of_int row.addr, Storage_codec.decode row.addresses row.content) snapshot); + let reads = ref 0 in + let counted = {storage with Ds.storage_restore = (fun address -> + incr reads; storage.storage_restore address)} in + let db = Option.get (Ds.restore counted) in + reads := 0; + let rows = Option.get (Dev_query.Db_query_dsl.execute_query db source { + opt_cards = false; opt_block_attrs = Some "[:db/id]"; + opt_current_page_title = None; opt_today_day = None}) in + let ids = List.filter_map (function + | [Ds.Result_pull entity] -> Some entity.Ds.pulled_id | _ -> None) rows in + check_eq (List.sort Int.compare ids) + (List.map (fun uuid -> Option.get (Ds.entid db "block/uuid" (Ds.Uuid uuid))) expected |> List.sort Int.compare); + check ~msg:(Printf.sprintf "bound reference values must not scan 5k unrelated titles (%d storage reads)" !reads) + (!reads < 100) + let cases = [ + case "reference queries retain bound index probes" (reference_query_keeps_bound_values_indexed "Alice" [alpha]); + case "multi-valued reference queries retain bound index probes" (reference_query_keeps_bound_values_indexed "Bob" [alpha; beta]); + case "unmatched reference queries retain bound index probes" (reference_query_keeps_bound_values_indexed "Nobody" []); + case "result encoding reads only the requested subtree on large pages" result_encoding_reads_subtrees_without_hydrating_page_siblings; case "results disclosure executes and folds real simple and advanced queries" results_disclosure_executes_real_session; case "queries stay unevaluated until expanded and refresh when reopened" collapsed_queries_do_not_evaluate_until_opened; case "result lists render breadcrumbs and independently folded children" result_list_breadcrumbs_and_local_children; diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index cc8f505d..25ab56f0 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -135,6 +135,22 @@ let saved_views_and_isolated_failures () = let block = List.find (fun block -> member "uuid" block = `String owner) (items "blocks" snapshot) in check_eq (List.length (items "views" (member "query" block))) 4 +let list_results_defer_cell_editor_data () = + let session, _ = make_session (fixture ()) in + let result = read session in + let list = find_view list_view result in + List.iter (fun row -> + check_eq ~msg:"list results load block content without unused table cell editors" + (items "properties" row) []) (items "rows" list); + List.iter (fun view -> + let view = find_view view result in + check ~msg:"switching to a grid retains typed cell values and editing" + (List.exists (fun row -> List.exists (fun cell -> + member "attr" cell = `String score && member "editable" cell = `Bool true) + (items "properties" row)) (items "rows" view)); + check_eq ~msg:"view layout does not change result membership" + (List.sort compare (ids view)) (List.sort compare (ids list))) [table_view; gallery_view] + let typed_filters_and_search () = let base = fixture () in let session, current = make_session base in @@ -1034,6 +1050,38 @@ let virtual_result_response ?(count = 10_001) ?(input = "") ?(reverse = false) ? "block", (match Q.query_result_block owner "Large query" with | `Assoc fields -> `Assoc (("query", query) :: fields) | _ -> assert false)]]]]) +let hidden_filter_catalog_does_not_multiply_mount_allocations () = + let response catalog = + let rec decorate = function + | `Assoc fields as value when member "uuid" value = `String "virtual" -> + `Assoc (("properties", `List [`Assoc ["attr", `String summary; + "kind", `String "default"; "title", `String "Summary"; + "filterValues", `List [`Assoc ["title", `String catalog]]]]) :: fields) + | `Assoc fields -> `Assoc (List.map (fun (key, value) -> key, decorate value) fields) + | `List values -> `List (List.map decorate values) + | value -> value in + Json.to_string (decorate (Json.from_string (virtual_result_response ~count:400 "list"))) in + let allocations catalog = + let projection = match Response_snapshot.decode_response (response catalog) with + | Ok value -> value | Error message -> fail message in + let app = App.create (App_test.ios_backend ()) in + Fun.protect ~finally:(fun () -> ignore (Lui_app.dispose app)) (fun () -> + App_test.start app; + Gc.full_major (); + let before = Gc.allocated_bytes () in + App_test.send app (Model.ApplyCoreSnapshot projection); App_test.flush app; + let allocated = Gc.allocated_bytes () -. before in + let runtime = Lui_app.runtime app in + check ~msg:"large metadata keeps the first result visible" + (App_test.descendant_with_identifier runtime (Lui_app.root_node app) + ("query.result." ^ owner ^ ".0") <> -1); + allocated) in + let small = allocations "short value" in + let catalog = String.make (512 * 1024) 'x' in + let large = allocations catalog in + check ~msg:(Printf.sprintf "hidden filter metadata must be parsed once, not copied for every row/count: %.0f -> %.0f bytes" small large) + (large -. small < float_of_int (String.length catalog * 16)) + let table_and_gallery_mount_bounded_windows () = List.iter (fun backend -> List.iter (fun typ -> Q.with_ui backend (virtual_result_response typ) (fun app -> @@ -1108,6 +1156,8 @@ let virtual_result_windows_follow_search_sort_and_resize () = [App_test.ios_backend; App_test.kotlin_backend] let cases = [ + case "list results defer cell data while grids retain typed editors" list_results_defer_cell_editor_data; + case "hidden filter catalogs do not multiply query mount allocations" hidden_filter_catalog_does_not_multiply_mount_allocations; case "table and gallery virtualize 10k results and retain distant cell editors" table_and_gallery_mount_bounded_windows; case "virtual result windows follow search sort empty results and gallery resize" virtual_result_windows_follow_search_sort_and_resize; case "table Name saves with stale-edit protection while timestamps stay read-only" table_name_is_editable_and_timestamps_are_read_only;