From 779a7c677750be9ba7669ba8b4862820d635fccb Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:20:38 +0800 Subject: [PATCH 01/25] Implement shared native editor commands and lazy page placeholders --- .../logseq/app/AndroidEditorCompletionTest.kt | 144 +++++++ .../com/logseq/app/runtime/LogseqRuntime.kt | 18 + .../app/ui/extensions/LogseqExtensions.kt | 9 +- .../app/ui/extensions/OutlinerBlockContent.kt | 57 ++- .../app/ui/extensions/OutlinerEditor.kt | 88 +++- .../Logseq/LGOutlinerEditorExtension.swift | 9 +- apple/Sources/Logseq/LogseqApp.swift | 24 ++ apple/Sources/Logseq/OutlinerEditing.swift | 9 +- .../Sources/Logseq/OutlinerInlineEditor.swift | 26 +- .../Logseq/OutlinerRichBlockContent.swift | 44 +- .../Sources/Logseq/OutlinerRowExtension.swift | 6 +- apple/Sources/LogseqModel/Models.swift | 2 + apple/Tests/UI/editor-completion.swift | 89 ++++ docs/agent-guide/014-editor-commands.md | 79 ++++ shared/native/dune_modules/core.sexp | 3 + shared/src/logseq/core/core_effect.ml | 8 +- shared/src/logseq/core/editor_command.ml | 103 +++++ shared/src/logseq/core/editor_command.mli | 25 ++ .../src/logseq/core/editor_command_effects.ml | 165 ++++++++ .../logseq/core/editor_command_effects.mli | 2 + shared/src/logseq/core/graph_read.mli | 1 + shared/src/logseq/core/graph_runtime.ml | 20 +- shared/src/logseq/core/graph_runtime.mli | 3 + shared/src/logseq/core/markup.ml | 33 +- shared/src/logseq/core/markup.mli | 2 + shared/src/logseq/core/mobile_graph.ml | 5 + shared/src/logseq/core/mobile_graph.mli | 2 + shared/src/logseq/core/mobile_session.ml | 1 + shared/src/logseq/core/node_presentation.ml | 100 +++++ shared/src/logseq/core/node_presentation.mli | 1 + shared/src/logseq/core/outliner_effects.ml | 24 +- shared/src/logseq/core/outliner_state.ml | 162 +++++++- shared/src/logseq/core/outliner_state.mli | 15 +- shared/src/logseq/core/rpc_encode.ml | 3 + shared/src/logseq/core/rpc_session.ml | 13 +- shared/src/logseq/core/rpc_wire.ml | 7 +- shared/src/logseq/core/session_outliner.ml | 4 +- shared/src/logseq/core/session_types.ml | 2 + shared/src/logseq/core/session_types.mli | 1 + shared/src/logseq/core/shared_capture.ml | 12 +- shared/src/logseq/model.ml | 41 +- shared/src/logseq/model.mli | 10 + shared/src/logseq/native_bridge.ml | 7 +- shared/src/logseq/response_snapshot.ml | 11 +- shared/src/logseq/view.ml | 3 + shared/src/logseq/view_base.ml | 11 +- shared/src/logseq/view_outliner.ml | 153 +++++-- shared/src/logseq/view_outliner.mli | 4 +- shared/src/logseq/view_query.mli | 3 + shared/src/logseq/view_rows.ml | 75 +++- shared/src/logseq/view_rows.mli | 4 + shared/src/logseq/view_screens.ml | 5 - shared/test/logseq/app_test.ml | 47 +-- .../logseq/drive/empty-node-add-block.drive | 4 +- shared/test/logseq/editor_command_test.ml | 385 ++++++++++++++++++ shared/test/logseq/graph_runtime_test.ml | 23 +- shared/test/logseq/outliner_state_test.ml | 4 +- shared/test/logseq/shared_capture_test.ml | 4 + shared/test/logseq/test_main.ml | 1 + 59 files changed, 1962 insertions(+), 154 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt create mode 100644 apple/Tests/UI/editor-completion.swift create mode 100644 docs/agent-guide/014-editor-commands.md create mode 100644 shared/src/logseq/core/editor_command.ml create mode 100644 shared/src/logseq/core/editor_command.mli create mode 100644 shared/src/logseq/core/editor_command_effects.ml create mode 100644 shared/src/logseq/core/editor_command_effects.mli create mode 100644 shared/src/logseq/core/node_presentation.ml create mode 100644 shared/src/logseq/core/node_presentation.mli create mode 100644 shared/test/logseq/editor_command_test.ml diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt new file mode 100644 index 00000000..8ba4b4e1 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt @@ -0,0 +1,144 @@ +package com.logseq.app + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.logseq.app.runtime.LogseqNativeBridge +import com.logseq.app.runtime.LuiPatchFilter +import com.logseq.app.runtime.dispatchLuiEvent +import com.logseq.app.runtime.logseqIconResolver +import com.logseq.app.ui.LogseqTheme +import com.logseq.app.ui.extensions.logseqExtensionRegistry +import dev.lui.LuiBackend +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidEditorCompletionTest { + @get:Rule val ui = createComposeRule() + private lateinit var bridge: LogseqNativeBridge + private lateinit var filter: LuiPatchFilter + + @After fun closeBridge() { if (::bridge.isInitialized) ui.runOnIdle { bridge.close() } } + + private fun snapshot(kind: String?, title: String): String = JSONObject().put("ok", true).put("result", JSONObject() + .put("selectedGraphId", "demo").put("graphName", "Demo") + .put("outlinerRows", org.json.JSONArray().put(JSONObject().put("depth", 0).put("block", JSONObject() + .put("uuid", "editing").put("title", title).put("pageId", "page")))) + .put("outlinerState", JSONObject().put("editing", JSONObject().put("uuid", "editing") + .put("title", title).put("caretUTF16Offset", title.length)) + .put("selectedBlockIds", org.json.JSONArray()).put("autocomplete", if (kind == null) JSONObject.NULL + else JSONObject().put("kind", kind).put("query", ""))) + .put("outlinerAutocompleteCandidates", org.json.JSONArray().apply { + if (kind != null) put(JSONObject().put("label", "Project").put("value", if (kind == "command") "command:embed" else "project")) + })).toString() + + private fun start(title: String, kind: String?) { + val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, + extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) + filter = LuiPatchFilter(backend) + bridge = LogseqNativeBridge(onPatch = { if (it.isNotEmpty()) filter.applyJson(it) }) + val root = bridge.initialize(authenticationCode = 3) + filter.applyJson(bridge.applySnapshot(snapshot(kind, title))) + bridge.applyHostUpdate("graph-loading", "false").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + ui.setContent { + MaterialExpressiveTheme(colorScheme = LogseqTheme.darkScheme, typography = LogseqTheme.typography) { + Surface(Modifier.fillMaxSize(), color = LogseqTheme.darkScheme.background) { backend.Content(root) } + } + } + } + + @Test fun commandsReferencesAndTagsUseOneNativePanel() { + start("/", "command") + for ((kind, title) in listOf("command" to "/", "node" to "[[Pro", "tag" to "#Pro")) { + ui.runOnIdle { bridge.applySnapshot(snapshot(kind, title)).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("toolbar.outliner.autocomplete").assertIsDisplayed() + ui.onNodeWithTag("button.outliner.autocomplete.dismiss").assertIsDisplayed() + ui.onNodeWithTag("button.outliner.autocomplete.0").performClick() + ui.runOnIdle { + var chosen: JSONObject? = null + repeat(24) { + val encoded = bridge.takeEffect() + if (encoded.isNotEmpty()) { + val effect = JSONObject(encoded).optJSONObject("effect") ?: return@repeat + if (effect.optString("kind") == "choose-outliner-autocomplete") chosen = effect + } + } + assertEquals(if (kind == "command") "command:embed" else "project", chosen?.optString("text")) + } + } + } + + @Test fun nativePasteCarriesItsOriginAcrossTheBridge() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + InstrumentationRegistry.getInstrumentation().runOnMainSync { + (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) + .setPrimaryClip(ClipData.newPlainText("test", "Pasted text /")) + } + start("Keep ", null) + ui.onNodeWithTag("field.outliner.block.editing").performTouchInput { longClick() } + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + automation.serviceInfo = automation.serviceInfo.apply { + flags = flags or android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or + android.accessibilityservice.AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS + } + fun pasteAction(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? { + if (node == null) return null + if (node.text?.toString().equals("Paste", ignoreCase = true) || node.contentDescription?.toString().equals("Paste", ignoreCase = true)) return node + for (index in 0 until node.childCount) pasteAction(node.getChild(index))?.let { return it } + return null + } + try { + ui.waitUntil(timeoutMillis = 5000) { automation.windows.any { pasteAction(it.root) != null } } + } catch (error: Throwable) { + automation.takeScreenshot()?.let { bitmap -> + java.io.File(context.getExternalFilesDir(null), "editor-paste.png").outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + } + throw error + } + assertTrue(automation.windows.firstNotNullOf { pasteAction(it.root) }.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)) + ui.waitUntil(timeoutMillis = 5000) { + ui.onNodeWithTag("field.outliner.block.editing").fetchSemanticsNode().config + .getOrElse(androidx.compose.ui.semantics.SemanticsProperties.EditableText) { androidx.compose.ui.text.AnnotatedString("") } + .text.contains("Pasted text /") + } + ui.mainClock.advanceTimeBy(200) + ui.waitForIdle() + var pasted = false + val observed = mutableListOf() + try { ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + val encoded = bridge.takeEffect() + if (encoded.isNotEmpty()) { + observed.add(encoded) + JSONObject(encoded).optJSONObject("effect")?.let { effect -> + pasted = effect.optString("kind") == "paste-outliner-text" && + effect.optString("text").contains("Pasted text /") + } + } + } + pasted + } } catch (error: Throwable) { throw AssertionError("Paste effects: $observed", error) } + assertTrue("A pasted slash must reach the core as a paste: $observed", pasted) + } +} diff --git a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt index e6fc428a..97c551c5 100644 --- a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt +++ b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt @@ -115,6 +115,8 @@ internal class LogseqRuntime( @Volatile private var selectedGraphId = "" @Volatile private var storageBaseUrl = "" private var pendingSyncJob: kotlinx.coroutines.Job? = null + private var journalDayJob: kotlinx.coroutines.Job? = null + private var refreshedJournalDay: String? = null private var graphEventsJob: kotlinx.coroutines.Job? = null private var graphEventsGraphId: String? = null private var graphLifecycle: AndroidGraphLifecycle? = null @@ -222,6 +224,22 @@ internal class LogseqRuntime( appIsForeground = resumed updateCatalogPolling("lifecycle") if (resumed && hasOpenGraph) appEntries.markReady() + journalDayJob?.cancel() + journalDayJob = if (resumed) scope.launch { + while (true) { + if (hasOpenGraph) { + val key = selectedGraphId + ":" + java.time.LocalDate.now().toString() + if (key != refreshedJournalDay) { + val response = bridge.callCore("""{"apiVersion":1,"method":"snapshot","params":{}}""") + if (JSONObject(response).optBoolean("ok")) { + applyCoreResponse(response) + refreshedJournalDay = key + } + } + } + kotlinx.coroutines.delay(30_000) + } + } else null } suspend fun publishAppEntry(entry: Map) = appEntries.publish(entry) diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt index 3e4ce95e..77806b4f 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt @@ -60,7 +60,7 @@ private const val EDITOR_FINGERPRINT = "properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:" + "required:none,8:block-id:string:required:none|events:11:text-change[" + "18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[" + - "18:caret-utf16-offset:int:required],6:return[18:caret-utf16-offset:int:" + + "18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:" + "required,5:title:string:required],9:backspace[16:selection-length:int:" + "required,5:title:string:required]" private const val RICH_FINGERPRINT = @@ -109,6 +109,13 @@ fun logseqExtensionRegistry( LuiExtensionProperty("block-id", LuiExtensionValueKind.STRING), ), events = listOf( + LuiExtensionEventSchema( + "paste", + listOf( + LuiExtensionProperty("caret-utf16-offset", LuiExtensionValueKind.INTEGER, required = true), + LuiExtensionProperty("title", LuiExtensionValueKind.STRING, required = true), + ), + ), LuiExtensionEventSchema( "text-change", listOf( diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt index 33db0ee1..c578e9a5 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt @@ -83,6 +83,9 @@ import dev.lui.luiThemeColor import java.io.File import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException import org.json.JSONArray import org.json.JSONObject @@ -314,7 +317,7 @@ private fun decodeMarkup(encoded: String): List { } private fun isRichNode(node: JSONObject): Boolean = node.optString("type") in setOf( - "quote", "math", "codeBlock", "video", "iframe", "youtubeTimestamp", "cloze", + "quote", "math", "codeBlock", "video", "iframe", "html", "image", "youtubeTimestamp", "cloze", ) private fun markupChunks(nodes: List): List> { @@ -351,6 +354,10 @@ private fun AnnotatedString.Builder.appendNode(node: JSONObject, decoration: Tex } "emphasis" -> { val style = when (node.optString("style")) { + "heading-1" -> SpanStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold) + "heading-2" -> SpanStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold) + "heading-3" -> SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold) + "heading-4", "heading-5", "heading-6" -> SpanStyle(fontSize = 17.sp, fontWeight = FontWeight.Bold) "bold" -> SpanStyle(fontWeight = FontWeight.Bold) "italic" -> SpanStyle(fontStyle = FontStyle.Italic) "underline" -> SpanStyle(textDecoration = TextDecoration.Underline) @@ -520,6 +527,8 @@ private fun MarkupRichNode( AndroidEmbeddedMedia(url = url, isVideo = true, headers = mediaHeaders(node)) } "iframe" -> AndroidEmbeddedMedia(url = node.optString("embedURL"), isVideo = false, headers = mediaHeaders(node)) + "html" -> AndroidHtml(node.optString("text")) + "image" -> AndroidLinkedImage(node.optString("url"), node.optString("text")) "youtubeTimestamp" -> { val target = node.optString("url") val seconds = node.optInt("seconds", -1) @@ -535,6 +544,52 @@ private fun MarkupRichNode( // --- assets --- +@Composable +private fun AndroidHtml(html: String) { + val content = "" + + "" + html + AndroidView(factory = { context -> WebView(context).apply { + setBackgroundColor(android.graphics.Color.TRANSPARENT) + webViewClient = WebViewClient() + tag = content + loadDataWithBaseURL(null, content, "text/html", "UTF-8", null) + } }, update = { view -> if (view.tag != content) { + view.tag = content + view.loadDataWithBaseURL(null, content, "text/html", "UTF-8", null) + } }, onRelease = { it.destroy() }, modifier = Modifier.fillMaxWidth().height(220.dp).testTag("block.rich.html")) +} + +@Composable +private fun AndroidLinkedImage(url: String, alt: String) { + var bitmap by remember(url) { mutableStateOf(null) } + var failed by remember(url) { mutableStateOf(false) } + LaunchedEffect(url) { + try { + val uri = androidEmbeddedMediaUri(url) ?: error("Invalid image URL") + bitmap = withContext(Dispatchers.IO) { + val connection = java.net.URL(uri.toString()).openConnection().apply { + connectTimeout = 10_000 + readTimeout = 10_000 + } + val bytes = connection.getInputStream().use { it.readNBytes(20_000_001) } + require(bytes.size <= 20_000_000) { "Image is too large" } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Invalid image" } + var sample = 1 + while (bounds.outWidth / sample > 2048 || bounds.outHeight / sample > 2048) sample *= 2 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + failed = bitmap == null + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { failed = true } + } + val image = bitmap + if (image != null) Image(image.asImageBitmap(), alt, contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth().height(220.dp).clip(RoundedCornerShape(12.dp)).testTag("block.rich.image")) + else Text(if (failed) "Image unavailable" else "Loading image…", style = outlinerBodyStyle) +} + @Composable private fun ProjectedAsset( uuid: String, title: String, assetType: String, localPath: String, diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt index dea70ed4..928828ca 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -21,9 +22,18 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.geometry.Rect +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys +import androidx.compose.foundation.text.contextmenu.builder.item +import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents +import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.TextRange @@ -48,6 +58,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val modelCaret = context.int("caret-utf16-offset") ?: -1 val selectionRevision = context.int("selection-revision") ?: 0 val inputPolicy = context.string("input-policy") ?: "{}" + val multiline = remember(inputPolicy) { runCatching { org.json.JSONObject(inputPolicy).optBoolean("multiline") }.getOrDefault(false) } val focusRequester = remember { FocusRequester() } var value by remember { @@ -67,6 +78,27 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val handoffSegments = remember { ArrayDeque() } var applyingModel by remember { mutableStateOf(false) } var debounceSeq by remember { mutableIntStateOf(0) } + var pasting by remember { mutableStateOf(false) } + var pastedChange by remember { mutableStateOf(false) } + val nativeToolbar = LocalTextToolbar.current + val completionToolbar = remember(nativeToolbar) { + object : TextToolbar { + override val status get() = nativeToolbar.status + override fun hide() = nativeToolbar.hide() + override fun showMenu(rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)?) { + nativeToolbar.showMenu(rect, onCopyRequested, onPasteRequested?.let { paste -> { + pasting = true + paste() + } }, onCutRequested, onSelectAllRequested) + } + } + } + val androidContext = LocalContext.current + val clipboard = remember(androidContext) { + androidContext.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + } + val pasteKey = remember { Any() } val publishedTitles = remember { mutableStateListOf() } var handoffReturn by remember { mutableStateOf?>(null) } @@ -131,7 +163,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { LaunchedEffect(Unit) { focusRequester.requestFocus() } // Debounced text-change emission, cancelled by the next edit. LaunchedEffect(debounceSeq) { - if (debounceSeq == 0 || awaitingHandoff) return@LaunchedEffect + if (debounceSeq == 0 || awaitingHandoff || pastedChange || value.composition != null) return@LaunchedEffect delay(80) val title = value.text publishedTitles.add(title) @@ -169,9 +201,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { ) } - BasicTextField( - value = value, - onValueChange = { incoming -> + fun acceptValue(incoming: TextFieldValue) { if (awaitingHandoff) { val prior = value.text var prefix = 0 @@ -189,7 +219,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } value = incoming observedText = incoming.text - return@BasicTextField + return } val location = incoming.selection.start val deletion = if (incoming.composition == null && value.selection.collapsed && location >= 0 @@ -200,7 +230,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val next = deletion?.let { TextFieldValue(it.first, TextRange(it.second)) } ?: incoming if (applyingModel) { value = next - return@BasicTextField + return } val textChanged = next.text != observedText if (textChanged) { @@ -216,12 +246,23 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { "caret-change", "caret-utf16-offset" to next.selection.start, ) - return@BasicTextField + return + } + if (!textChanged) { + if (prior.composition != null && next.composition == null) debounceSeq++ + return } - if (!textChanged) return@BasicTextField + pastedChange = pasting + pasting = false val title = next.text val caret = next.selection.start.coerceAtLeast(0) + if (pastedChange) { + debounceSeq++ + publishedTitles.add(title) + context.emit("paste", "title" to title, "caret-utf16-offset" to caret) + return + } val newline = caret - 1 val singleInsertion = title.length == previousText.length + 1 && @@ -237,7 +278,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { previousSelection.end, "\n", ) == title - if (singleInsertion || replacedSelection) { + if (!multiline && !pastedChange && (singleInsertion || replacedSelection)) { val splitAt = if (replacedSelection) previousSelection.start else newline val submitted = title.removeRange(splitAt, splitAt + 1) val submittedCaret = splitAt.coerceIn(0, submitted.length) @@ -249,13 +290,35 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } else { debounceSeq++ // schedule text-change } - }, + } + + CompositionLocalProvider(LocalTextToolbar provides completionToolbar) { + BasicTextField( + value = value, + onValueChange = ::acceptValue, modifier = Modifier .fillMaxWidth() + .appendTextContextMenuComponents { + val clip = clipboard.primaryClip + val text = if (clip != null && clip.itemCount > 0) clip.getItemAt(0).coerceToText(androidContext)?.toString() else null + if (text != null) item(pasteKey, androidContext.getString(android.R.string.paste)) { + val start = value.selection.min.coerceIn(0, value.text.length) + val end = value.selection.max.coerceIn(start, value.text.length) + pasting = true + acceptValue(TextFieldValue(value.text.replaceRange(start, end, text), TextRange(start + text.length))) + close() + } + } + .filterTextContextMenuComponents { it.key != TextContextMenuKeys.PasteKey } .focusRequester(focusRequester) .testTag("field.outliner.block.$rememberedBlockId") .semantics { testTagsAsResourceId = true } .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + ((event.key == Key.V && event.isCtrlPressed) || (event.key == Key.Insert && event.isShiftPressed))) { + pasting = true + return@onPreviewKeyEvent false + } if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && event.isShiftPressed) { val start = value.selection.min.coerceIn(0, value.text.length) val end = value.selection.max.coerceIn(start, value.text.length) @@ -269,7 +332,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { debounceSeq++ } true - } else if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) { + } else if (!multiline && event.type == KeyEventType.KeyDown && event.key == Key.Enter) { // Handle hardware Enter before the IME can batch several keys into pasted text. returnAtSelection() true @@ -292,10 +355,11 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { ), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, - imeAction = ImeAction.Next, + imeAction = if (multiline) ImeAction.Default else ImeAction.Next, ), keyboardActions = KeyboardActions(onNext = { returnAtSelection() }), ) + } } private fun caretOffset(value: TextFieldValue, text: String): Int = diff --git a/apple/Sources/Logseq/LGOutlinerEditorExtension.swift b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift index 5bd215b4..39736957 100644 --- a/apple/Sources/Logseq/LGOutlinerEditorExtension.swift +++ b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift @@ -4,7 +4,7 @@ import SwiftUI @MainActor enum LGOutlinerEditorExtension { static let identifier = "outliner-editor" - static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" static func register(in registry: LUIAppleExtensionRegistry) throws { try registry.register( @@ -23,6 +23,10 @@ enum LGOutlinerEditorExtension { .init(name: "title", kind: .string, isRequired: true), .init(name: "caret-utf16-offset", kind: .int, isRequired: true), ]), + .init(name: "paste", fields: [ + .init(name: "title", kind: .string, isRequired: true), + .init(name: "caret-utf16-offset", kind: .int, isRequired: true), + ]), .init(name: "return", fields: [ .init(name: "title", kind: .string, isRequired: true), .init(name: "caret-utf16-offset", kind: .int, isRequired: true), @@ -59,6 +63,9 @@ private struct LGOutlinerEditor: View { "caret-utf16-offset": .int(caret), ]) }, + onPaste: { title, caret in + emit(name: "paste", values: ["title": .string(title), "caret-utf16-offset": .int(caret)]) + }, onReturn: { title, caret in emit(name: "return", values: [ "title": .string(title), diff --git a/apple/Sources/Logseq/LogseqApp.swift b/apple/Sources/Logseq/LogseqApp.swift index 4f771915..a036291a 100644 --- a/apple/Sources/Logseq/LogseqApp.swift +++ b/apple/Sources/Logseq/LogseqApp.swift @@ -43,6 +43,7 @@ public struct LogseqRootView : View { @AppStorage("logseq.appearance") private var appearance = "system" @AppStorage("logseq.language") private var language = "system" @Environment(\.colorScheme) private var colorScheme + @Environment(\.scenePhase) private var scenePhase private let runtime = LogseqLogseqRuntime.shared @@ -84,6 +85,13 @@ public struct LogseqRootView : View { await runtime.setNetworkAvailable(available) } } + .task(id: scenePhase) { + guard scenePhase == .active else { return } + while !Task.isCancelled { + await runtime.refreshLocalJournalDay() + do { try await Task.sleep(for: .seconds(30)) } catch { return } + } + } .onChange(of: runtime.authentication.state) { previousState, state in runtime.publishAuthenticationState() guard state == .signedIn, previousState != .restoring else { return } @@ -150,6 +158,7 @@ public struct LogseqRootView : View { private var isLGApplicationReady = false private var isResumingLGApplication = false private var didResumeCurrentActivation = false + private var refreshedJournalDay: String? private let graphLifecycle: LGGraphLifecycle private let platformCommandRouter: LGPlatformCommandRouter @@ -483,6 +492,21 @@ public struct LogseqRootView : View { } } + func refreshLocalJournalDay() async { + guard isLGApplicationReady, let graphID = store.snapshot.selectedGraphId, + store.snapshot.isGraphUnlocked != false else { return } + let day = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970 + let key = graphID + ":" + String(day) + guard refreshedJournalDay != key else { return } + let response = await LogseqCore.callAsync(LogseqRPCRequest(method: "snapshot", params: LogseqRPCParams(action: nil))) + do { + let envelope = try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any] + guard envelope?["ok"] as? Bool == true else { return } + try lgRuntime.applyCoreResponse(response) + refreshedJournalDay = key + } catch { logger.error("Could not refresh today's journal: " + String(describing: error)) } + } + public func pauseLGApplication() { if didApplyLocalLaunchResult { do { diff --git a/apple/Sources/Logseq/OutlinerEditing.swift b/apple/Sources/Logseq/OutlinerEditing.swift index 7261be6f..878ddc4a 100644 --- a/apple/Sources/Logseq/OutlinerEditing.swift +++ b/apple/Sources/Logseq/OutlinerEditing.swift @@ -22,7 +22,7 @@ enum OutlinerMarkupLink: Equatable { enum OutlinerRichMarkupPolicy { static func isRich(_ type: LogseqMarkupNodeType) -> Bool { switch type { - case .quote, .math, .codeBlock, .video, .iframe, .youtubeTimestamp, .cloze: + case .quote, .math, .codeBlock, .video, .iframe, .html, .image, .youtubeTimestamp, .cloze: return true default: return false @@ -36,7 +36,7 @@ enum OutlinerRichMarkupPolicy { static func containsInteractive(_ nodes: [LogseqMarkupNode]) -> Bool { nodes.contains { node in switch node.type { - case .video, .iframe, .youtubeTimestamp, .cloze: + case .video, .iframe, .html, .image, .youtubeTimestamp, .cloze: return true default: return containsInteractive(node.children) @@ -136,6 +136,11 @@ enum NativeEditorInputPolicy { private struct Policy: Decodable { struct EmptyPair: Decodable { let text: String; let deleteOffset: Int } let emptyPairs: [EmptyPair]? + let multiline: Bool? + } + + static func allowsLineBreak(_ inputPolicy: String) -> Bool { + (try? JSONDecoder().decode(Policy.self, from: Data(inputPolicy.utf8)))?.multiline == true } static func deletingEmptyPair(from text: String, range: NSRange, replacementText: String, diff --git a/apple/Sources/Logseq/OutlinerInlineEditor.swift b/apple/Sources/Logseq/OutlinerInlineEditor.swift index 81c7532a..2e891f20 100644 --- a/apple/Sources/Logseq/OutlinerInlineEditor.swift +++ b/apple/Sources/Logseq/OutlinerInlineEditor.swift @@ -10,6 +10,7 @@ struct OutlinerInlineEditor: View { var inputPolicy: String = "{}" var selectionRevision: Int = 0 let onTextChange: (String, Int) -> Void + var onPaste: ((String, Int) -> Void)? = nil let onReturn: (String, Int) -> Void let onBackspace: (String, Int) -> Void let onCaretChange: (Int) -> Void @@ -28,6 +29,7 @@ struct OutlinerInlineEditor: View { inputPolicy: inputPolicy, selectionRevision: selectionRevision, onTextChange: onTextChange, + onPaste: onPaste, onReturn: onReturn, onBackspace: onBackspace, onCaretChange: onCaretChange @@ -139,6 +141,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { var inputPolicy: String = "{}" var selectionRevision: Int = 0 let onTextChange: (String, Int) -> Void + var onPaste: ((String, Int) -> Void)? = nil let onReturn: (String, Int) -> Void let onBackspace: (String, Int) -> Void let onCaretChange: (Int) -> Void @@ -319,7 +322,12 @@ private struct NativeOutlinerTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { localText = textView.text - parent.onTextChange(textView.text, textView.selectedRange.location) + guard textView.markedTextRange == nil else { return } + if (textView as? FocusRetainingTextView)?.isPasting == true, let onPaste = parent.onPaste { + onPaste(textView.text, textView.selectedRange.location) + } else { + parent.onTextChange(textView.text, textView.selectedRange.location) + } } func textViewDidChangeSelection(_ textView: UITextView) { @@ -356,7 +364,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { } return false } - if isInlineLineBreak { return true } + if isInlineLineBreak || (textView as? FocusRetainingTextView)?.isPasting == true { return true } if textView.markedTextRange == nil, let deletion = NativeEditorInputPolicy.deletingEmptyPair( from: textView.text ?? "", range: range, @@ -372,6 +380,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { parent.onTextChange(deletion.text, deletion.caretUTF16Offset) return false } + if replacement == "\n", NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true } if replacement == "\n" { var submittedText = textView.text ?? "" if range.length > 0 { @@ -404,6 +413,19 @@ private struct NativeOutlinerTextView: UIViewRepresentable { private final class FocusRetainingTextView: OutlinerLayoutTextView { private var focusPending = false private(set) var isInsertingLineBreak = false + private(set) var isPasting = false + + override func paste(_ sender: Any?) { + guard let text = UIPasteboard.general.string else { + super.paste(sender) + return + } + isPasting = true + defer { isPasting = false } + // This editor stores plain text. Insert it in the same input transaction + // so the delegate receives an explicit paste origin, including newlines. + insertText(text) + } override var keyCommands: [UIKeyCommand]? { let lineBreak = UIKeyCommand(input: "\r", modifierFlags: .shift, diff --git a/apple/Sources/Logseq/OutlinerRichBlockContent.swift b/apple/Sources/Logseq/OutlinerRichBlockContent.swift index 160ec73a..66d167fa 100644 --- a/apple/Sources/Logseq/OutlinerRichBlockContent.swift +++ b/apple/Sources/Logseq/OutlinerRichBlockContent.swift @@ -75,7 +75,7 @@ enum OutlinerMarkupMarkdown { static func requiresAttributedText(_ nodes: [LogseqMarkupNode]) -> Bool { nodes.contains { node in switch node.type { - case .text, .codeBlock, .math, .cloze, .youtubeTimestamp, .video, .iframe: + case .text, .codeBlock, .math, .cloze, .youtubeTimestamp, .video, .iframe, .html, .image: return false case .code, .emphasis, .quote, .link, .nodeReference, .tagReference: return true @@ -116,7 +116,7 @@ enum OutlinerMarkupMarkdown { return nodeLink(label: "[[" + (node.title ?? "") + "]]", uuid: node.uuid) case .tagReference: return nodeLink(label: "#" + (node.title ?? ""), uuid: node.uuid) - case .video, .iframe: + case .video, .iframe, .html, .image: return escape(node.url ?? "") } } @@ -147,7 +147,7 @@ enum OutlinerMarkupInteractionPolicy { static func hasInteractiveContent(_ nodes: [LogseqMarkupNode]) -> Bool { nodes.contains { node in switch node.type { - case .link, .nodeReference, .tagReference, .video, .iframe, + case .link, .nodeReference, .tagReference, .video, .iframe, .html, .image, .youtubeTimestamp, .cloze: return true case .emphasis, .quote: @@ -185,6 +185,10 @@ enum OutlinerMarkupAttributedString { case .emphasis, .quote: var value = make(nodes: node.children, fallback: "", textStyle: textStyle) switch node.style { + case "heading-1": value.font = .title.bold() + case "heading-2": value.font = .title2.bold() + case "heading-3": value.font = .title3.bold() + case "heading-4", "heading-5", "heading-6": value.font = .headline case "bold": value.font = .system(textStyle).bold() case "italic": value.font = .system(textStyle).italic() case "underline": value.underlineStyle = .single @@ -209,7 +213,7 @@ enum OutlinerMarkupAttributedString { value.link = OutlinerMarkupLink.node(uuid: uuid).url } return value - case .video, .iframe: + case .video, .iframe, .html, .image: return AttributedString(node.url ?? "") } } @@ -250,6 +254,21 @@ struct OutlinerRichBlockContent: View { node: node, startSeconds: youtubeStartSeconds ) + case .html: + #if os(iOS) + AppleWebView(url: nil, headers: [:], html: node.text).frame(height: 220) + #else + Text(node.text ?? "") + #endif + case .image: + AsyncImage(url: node.url.flatMap(URL.init(string:))) { phase in + if let image = phase.image { image.resizable().scaledToFit() } + else if phase.error != nil { Text("Image unavailable").foregroundStyle(.secondary) } + else { ProgressView() } + } + .frame(maxWidth: .infinity, maxHeight: 240) + .clipShape(.rect(cornerRadius: 12)) + .accessibilityLabel(node.text ?? "Image") case .iframe: EmbeddedWebContent(url: node.embedURL.flatMap { URL(string: $0) }, headers: node.mediaHeaders) case .youtubeTimestamp: @@ -454,18 +473,30 @@ private struct EmbeddedWebContent: View { #if os(iOS) private struct AppleWebView: UIViewRepresentable { - let url: URL + let url: URL? let headers: [String: String] + var html: String? = nil func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.allowsInlineMediaPlayback = true - return WKWebView(frame: .zero, configuration: configuration) + let view = WKWebView(frame: .zero, configuration: configuration) + view.isOpaque = false + view.backgroundColor = .clear + view.scrollView.backgroundColor = .clear + return view } func updateUIView(_ webView: WKWebView, context: Context) { + if let html { + guard context.coordinator.loadedHTML != html else { return } + context.coordinator.loadedHTML = html + webView.loadHTMLString("" + html, baseURL: nil) + return + } + guard let url else { return } var request = URLRequest(url: url) for (field, value) in headers { request.setValue(value, forHTTPHeaderField: field) @@ -477,6 +508,7 @@ private struct AppleWebView: UIViewRepresentable { final class Coordinator { var loadedRequest: URLRequest? + var loadedHTML: String? } } #endif diff --git a/apple/Sources/Logseq/OutlinerRowExtension.swift b/apple/Sources/Logseq/OutlinerRowExtension.swift index 212df61d..1da43305 100644 --- a/apple/Sources/Logseq/OutlinerRowExtension.swift +++ b/apple/Sources/Logseq/OutlinerRowExtension.swift @@ -55,6 +55,7 @@ struct OutlinerRowPayload: Decodable, Equatable { let selected: Bool let opensAsPage: Bool let nodeIcon: NodeIconPayload? + let listNumber: Int? let journalBullet: Bool let syncFailed: Bool let priority: Status? @@ -165,7 +166,10 @@ struct OutlinerRowPayload: Decodable, Equatable { .accessibilityIdentifier("button.outliner.zoom." + row.uuid + "." + row.title) } else { Button { emit("open-node") } label: { - if let icon = row.nodeIcon { + if let number = row.listNumber { + Text("\(number).").font(.subheadline).frame(width: 24, height: 24) + .accessibilityIdentifier("outliner.number." + row.uuid) + } else if let icon = row.nodeIcon { NodeIconView(icon: icon, size: 18).frame(width: 24, height: 24) } else { IconImage(name: row.collapsed && row.hasChildren ? "outliner_bullet_closed" : "outliner_bullet", size: 24).frame(height: 24) diff --git a/apple/Sources/LogseqModel/Models.swift b/apple/Sources/LogseqModel/Models.swift index 118f8527..d425031b 100644 --- a/apple/Sources/LogseqModel/Models.swift +++ b/apple/Sources/LogseqModel/Models.swift @@ -21,6 +21,8 @@ public enum LogseqMarkupNodeType: String, Codable, Hashable, Sendable { case math case video case iframe + case html + case image case youtubeTimestamp case cloze case link diff --git a/apple/Tests/UI/editor-completion.swift b/apple/Tests/UI/editor-completion.swift new file mode 100644 index 00000000..f42b4a3f --- /dev/null +++ b/apple/Tests/UI/editor-completion.swift @@ -0,0 +1,89 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class EditorCompletionEffects: LGEffectExecuting { + var response = "" + var choices = 0 + func execute(_ effect: LGEffect) async -> LGEffectResolution { + if effect.kind == "choose-outliner-autocomplete" { + choices += 1 + return .init(succeeded: true, message: response, output: .coreResponse) + } + return .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct EditorCompletionCheck { + @MainActor static func main() async throws { + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func snapshot(_ title: String, caret: Int, kind: String?, label: String, value: String) throws -> String { + var state: [String: Any] = ["editing": ["uuid": "editing", "title": title, "caretUTF16Offset": caret], "selectedBlockIds": []] + if let kind { state["autocomplete"] = ["kind": kind, "query": ""] } + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "outlinerRows": [["depth": 0, "block": ["uuid": "editing", "title": title, "pageId": "page"]]], + "outlinerState": state, "outlinerAutocompleteCandidates": kind == nil ? [] : [["label": label, "value": value]]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + for (kind, before, after, caret, label, value) in [ + ("command", "🙂 /", "🙂 ", 8, "Underline", "command:underline"), + ("node", "[[Pro", "[[Project]]", 11, "Project", "project"), + ("tag", "Text #Pro", "Text", 4, "Project", "project") + ] { + let effects = EditorCompletionEffects() + effects.response = try snapshot(after, caret: caret, kind: nil, label: label, value: value) + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(snapshot(before, caret: before.utf16.count, kind: kind, label: label, value: value)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + try await Task.sleep(for: .milliseconds(700)) + await runtime.drainEffectsForTesting() + try runtime.applyCoreResponse(snapshot(before, caret: before.utf16.count, kind: kind, label: label, value: value)) + try await Task.sleep(for: .milliseconds(200)) + let backend = runtime.renderer.backend + func nodes(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(nodes) + } + let all = backend.rootIDs.flatMap(nodes) + func node(_ identifier: String) -> Int? { + all.first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + guard node("button.outliner.autocomplete.dismiss") != nil, + let candidate = node("button.outliner.autocomplete.0") else { throw failure("Missing shared panel for \(kind)") } + try backend.performPress(node: candidate) + await runtime.drainEffectsForTesting() + try await Task.sleep(for: .milliseconds(400)) + guard effects.choices == 1, + let editor = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.text == after }), + editor.selectedRange.location == caret, editor.isFirstResponder else { + throw failure("Selecting \(kind) must preserve the editor and apply its caret") + } + runtime.stop() + window.rootViewController = nil + } + var pasted: [String] = [] + window.rootViewController = UIHostingController(rootView: OutlinerInlineEditor( + text: "", blockID: "paste", desiredCaretUTF16Offset: 0, + onTextChange: { _, _ in }, onPaste: { title, _ in pasted.append(title) }, + onReturn: { _, _ in }, onBackspace: { _, _ in }, onCaretChange: { _ in })) + window.makeKeyAndVisible() + try await Task.sleep(for: .milliseconds(300)) + let editor = views(window).compactMap { $0 as? UITextView }.first! + UIPasteboard.general.string = "Pasted text /" + editor.paste(nil) + try await Task.sleep(for: .milliseconds(500)) + guard pasted == ["Pasted text /"] else { + throw failure("Native paste must be distinguished from typing: \(pasted), text: \(editor.text ?? "nil")") + } + print("PASS: commands, pages and tags share a panel, preserve keyboard/caret, and native paste is explicit") + window.isHidden = true + } + static func failure(_ message: String) -> NSError { + NSError(domain: "EditorCompletion", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/docs/agent-guide/014-editor-commands.md b/docs/agent-guide/014-editor-commands.md new file mode 100644 index 00000000..2646c8f0 --- /dev/null +++ b/docs/agent-guide/014-editor-commands.md @@ -0,0 +1,79 @@ +# Shared Editor Commands Implementation Plan + +Goal: Support the agreed master slash commands through the same native completion interaction as page references and tags. + +Architecture: The OCaml outliner owns completion state, command discovery, and semantic mutations. +The existing completion panel renders commands, pages, tags, embeds, and templates on both native platforms. +Native date controls and shared input forms handle commands that require additional input. + +Tech Stack: OCaml, DataScript, LUI, SwiftUI, Jetpack Compose. + +Related: Builds on 013-node-properties.md and the existing outliner reference completion. + +## Problem statement + +Master supports a graph-aware slash menu, while the native editor currently only completes references and tags. +Support every built-in command except Query, Advanced Query, Query function, Add property, Add comment, Upload an asset, and Node reference. +Do not add custom or plugin commands. +Embed deletion must remove the embedding node and preserve its source and source descendants. + +## Testing Plan + +Add RPC integration tests in shared/test/logseq/editor_command_test.ml before implementation. +Exercise typing at UTF-16 caret positions, command filtering, every included command family, graph-defined choices, cancel and stale selection, code and escaped literals, template cloning, date and link forms, semantic synchronization, and deleting an embed. +Add shared UI integration coverage for one completion panel, search, dismissal, and choosing a command on both host profiles. +Add native UI regression flows for keyboard retention, page and tag selection, command filtering, input forms, and real calendar taps. +Run focused tests first, then the complete core gate and native platform builds. + +Regression tests were added and observed failing before each implementation batch. + +## Implementation sequence + +1. Add and run the failing integration tests against current RPC and UI entry points. +2. Add shared/src/logseq/core/editor_command.ml and its signature for the master command catalog, graph choices, and command planning. +3. Extend shared/src/logseq/core/outliner_state.ml, outliner_effects.ml, and session_outliner.ml to share selection state and stage atomic semantic operations. +4. Extend rpc_wire.ml, rpc_encode.ml, core_effect.ml, model.ml, native_bridge.ml, and response_snapshot.ml for completion search, submission, and dismissal. +5. Update shared/src/logseq/view_outliner.ml to render one reusable completion panel with compact rows, explicit dismissal, empty states, and common picker navigation. +6. Reuse the existing native-property-date extension for date and datetime inputs and use shared forms for links and media inputs. +7. Render command output with correct headings, list numbering, code language, and linked embed content while retaining wrapper identity for deletion. +8. Run the focused integration suite, the full core gate, iOS simulator checks, Android checks, and phone build and installation without resetting graph data. + +## Testing Details + +Tests invoke the actual RPC session backed by Graph_runtime and inspect resulting DataScript values and pending synchronization operations. +Embed tests delete a linked wrapper and then read both the source and its child from the real projected database. +UI tests operate the same completion controls for commands, references, and tags, including cancellation without losing the draft. + +## Implementation Details + +- Preserve master property identifiers and semantic value types. +- Read ordered statuses and priorities from graph definitions. +- Store embeds with block/link and never replace their identity with the source UUID. +- Clone templates into new nodes with fresh UUIDs and preserve source data. +- Respect UTF-16 caret offsets, suffix text, escaped markers, code literals, and input composition. +- Keep command errors inside the editor interaction with a useful recovery path. +- Use the existing app sheet palette and native calendar controls. +- Keep the user's Package.resolved originHash change intact. + +## Question + +The command scope and exclusions are confirmed by the user. +YouTube timestamp insertion needs the active player position when available and an explicit timestamp input otherwise. + +--- + +## Follow-up behavior + +- Pasting a slash, page reference, or tag marker stays literal until a new trigger is typed. +- Code and math editors preserve line breaks; choosing Code in prose creates a separate code block. +- All editable pages expose a lazy trailing placeholder. Opening an empty page does not insert filler; one tap inserts and focuses a real block. Zoomed blocks insert children in their containing page. +- Empty journal pages have independent projection metadata, so an existing day remains visible with no blocks. Active apps refresh the local day after midnight or resume. +- Copied URLs include the graph ID. Graph-less block-ID links resolve in the current graph. + +## Validation + +- Complete native core gate: 987 tests passed in the combined working tree, including reference-display follow-ups. +- iOS simulator and signed device builds passed. +- iOS native runtime check passed for shared command/reference/tag completion, keyboard and caret retention, and real clipboard paste. +- Android app and instrumentation APKs built; both native completion and clipboard-paste checks passed. +- No graph reset or uninstall is needed for the device update. diff --git a/shared/native/dune_modules/core.sexp b/shared/native/dune_modules/core.sexp index 0b27e9fc..28304037 100644 --- a/shared/native/dune_modules/core.sexp +++ b/shared/native/dune_modules/core.sexp @@ -35,6 +35,9 @@ "Live_sync" "Markup" "Outliner" +"Editor_command" +"Editor_command_effects" +"Node_presentation" "Outliner_effects" "Outliner_state" "Pending_projection" diff --git a/shared/src/logseq/core/core_effect.ml b/shared/src/logseq/core/core_effect.ml index 1bec115f..28d598b9 100644 --- a/shared/src/logseq/core/core_effect.ml +++ b/shared/src/logseq/core/core_effect.ml @@ -75,20 +75,22 @@ let request ~now ~fresh_id payload = "priorityUuid", Option.value ~default:`Null (List.assoc_opt "uuid" fields)] | _ -> Error "Priority must be a reference or null") | "outliner-toolbar" -> text_event "toolbar" "action" + | "editor-outliner-event" -> raw "outlinerEvent" | "choose-outliner-autocomplete" -> text_event "chooseAutocomplete" "value" | "save-outliner-editing" -> event "saveEditing" [] | "confirm-outliner-delete" -> event "confirmDelete" [] | "cancel-outliner-editing" -> event "cancelEditing" [] - | "change-outliner-text" | "return-outliner-editor" | "backspace-outliner-editor" | "move-outliner-caret" -> + | "paste-outliner-text" | "change-outliner-text" | "return-outliner-editor" | "backspace-outliner-editor" | "move-outliner-caret" -> let* uuid = string fields "uuid" in let* value = integer fields "value" in let name, key = match kind with - | "change-outliner-text" -> "textChanged", "caretUTF16Offset" + | "paste-outliner-text" | "change-outliner-text" -> "textChanged", "caretUTF16Offset" | "return-outliner-editor" -> "returnPressed", "caretUTF16Offset" | "backspace-outliner-editor" -> "backspacePressed", "selectionLength" | _ -> "caretMoved", "caretUTF16Offset" in let* extra = if kind = "move-outliner-caret" then Ok [] else let* title = text () in Ok ["title", `String title] in - event name (["uuid", `String uuid; key, `Int value] @ extra) + event name (["uuid", `String uuid; key, `Int value] @ extra + @ if kind = "paste-outliner-text" then ["inputOrigin", `String "paste"] else []) | "open-graph" -> raw "selectGraph" | "unlock-graph" -> raw "unlockGraph" | "create-graph" -> let* name = text () in let* value = integer fields "value" in diff --git a/shared/src/logseq/core/editor_command.ml b/shared/src/logseq/core/editor_command.ml new file mode 100644 index 00000000..7ce2a954 --- /dev/null +++ b/shared/src/logseq/core/editor_command.ml @@ -0,0 +1,103 @@ +module Ds = Datascript +module Ops = Pending_ops +module Read = Graph_read +type picker = Embed | Template | Link | Image | Date | Deadline | Scheduled | Youtube +type action = + | Insert of string * int + | Properties of (string * Ops.semantic_value option) list + | Choice of string * string option + | Code of string option + | Number of bool + | Journal of int + | Time + | Pick of picker +type entry = { id : string; label : string; action : action } +type operation = + | Insert_code of string * string option + | Ensure_journal of int + | Apply of string * action | Embed_node of string * string * bool | Apply_template of string * string * string +let entry id label action = {id = "command:" ^ id; label; action} +let picker_name = function Embed -> "embed" | Template -> "template" | Link -> "link" + | Image -> "image" | Date -> "date" | Deadline -> "deadline" | Scheduled -> "scheduled" | Youtube -> "youtube" +let picker_of_name = function "embed" -> Some Embed | "template" -> Some Template | "link" -> Some Link + | "image" -> Some Image | "date" -> Some Date | "deadline" -> Some Deadline | "scheduled" -> Some Scheduled + | "youtube" -> Some Youtube | _ -> None +let catalog db = + let choices attr = match db with None -> [] | Some db -> Read.property_choices (fun x -> Ok x) db attr + |> List.map (fun (v : Cache_model.status) -> entry (attr ^ ":" ^ v.uuid) v.title (Choice (attr, Some v.uuid))) in + [entry "embed" "Node embed" (Pick Embed); entry "link" "Link" (Pick Link); + entry "image" "Image link" (Pick Image); entry "underline" "Underline" (Insert ("", 6)); + entry "code" "Code block" (Code None); + entry "quote" "Quote" (Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "quote")]); + entry "math" "Math block" (Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "math")]); + entry "normal" "Normal text" (Properties ["logseq.property/heading", None]); + entry "clear-heading" "Clear heading" (Properties ["logseq.property/heading", None])] + @ List.init 6 (fun i -> let n = i + 1 in entry ("heading-" ^ string_of_int n) ("Heading " ^ string_of_int n) + (Properties ["logseq.property/heading", Some (Ops.Int_value n)])) + @ choices "logseq.property/status" + @ [entry "deadline" "Deadline" (Pick Deadline); entry "scheduled" "Scheduled" (Pick Scheduled)] + @ choices "logseq.property/priority" + @ [entry "no-priority" "No priority" (Choice ("logseq.property/priority", None)); + entry "tomorrow" "Tomorrow" (Journal 1); entry "yesterday" "Yesterday" (Journal (-1)); + entry "today" "Today" (Journal 0); entry "time" "Current time" Time; entry "date" "Date picker" (Pick Date); + entry "number" "Number list" (Number false); entry "number-children" "Number children" (Number true); + entry "calculator" "Calculator" (Code (Some "calc")); entry "template" "Template" (Pick Template); + entry "html" "Embed HTML" (Insert ("@@html: @@", 2)); + entry "video" "Embed Video URL" (Insert ("{{video }}", 2)); + entry "youtube" "Embed YouTube timestamp" (Pick Youtube); + entry "tweet" "Embed Twitter tweet" (Insert ("{{tweet }}", 2)); + entry "cloze" "Cloze" (Insert ("{{cloze }}", 2))] + +let candidates db query = + let query = String.lowercase_ascii (String.trim query) in + let entries = catalog db in + if query = "" then entries else + List.filter_map (fun entry -> + let score = Search_index.fuzzy_score query entry.label in + if score > 0. then Some (entry, score) else None) entries + |> List.stable_sort (fun (_, a) (_, b) -> compare b a) + |> List.map fst + +let journal_reference db ms = + let day = Cache_model.journal_day_for_ms ms in + let title = Option.bind db (fun db -> Option.bind (Read.journal_page_uuid db day) + (fun uuid -> Option.bind (Ds.entid db "block/uuid" (Ds.Uuid uuid)) + (fun eid -> Read.string_value (Read.value db eid "block/title")))) in + "[[" ^ Option.value ~default:(Journal.day_title day) title ^ "]]" + +let date_ms value = + try + if String.length value <> 10 || value.[4] <> '-' || value.[7] <> '-' then invalid_arg "date"; + let y = int_of_string (String.sub value 0 4) and m = int_of_string (String.sub value 5 2) + and d = int_of_string (String.sub value 8 2) in + let seconds, tm = Unix.mktime { (Unix.localtime 0.) with tm_year = y - 1900; tm_mon = m - 1; tm_mday = d; + tm_hour = 0; tm_min = 0; tm_sec = 0; tm_isdst = false } in + if tm.Unix.tm_year <> y - 1900 || tm.tm_mon <> m - 1 || tm.tm_mday <> d then invalid_arg "date"; + Ok (int_of_float (seconds *. 1000.)) + with _ -> Error "Choose a valid date" + +let timestamp value = + try + let parts = String.split_on_char ':' (String.trim value) |> List.map int_of_string in + let seconds = match parts with [s] -> s | [m;s] when s < 60 -> m*60+s + | [h;m;s] when m < 60 && s < 60 -> h*3600+m*60+s | _ -> invalid_arg "time" in + if seconds < 0 then invalid_arg "time"; + Ok seconds + with _ -> Error "Enter a timestamp such as 1:32" + +let link ~image value = + try + let json = Yojson.Basic.from_string value in + let field key = match json with `Assoc fields -> (match List.assoc_opt key fields with Some (`String x) -> x | _ -> "") | _ -> "" in + let url = String.trim (field "url") in + if url = "" then invalid_arg "Enter a URL"; + let lower = String.lowercase_ascii url in + if not (List.exists (fun prefix -> String_kit.starts_with ~prefix lower) + ["https://"; "http://"; "mailto:"; "logseq://"]) then invalid_arg "Enter a valid URL"; + let escape v = String_kit.replace v ~match_:" " ~replacement:"%20" in + let url = String_kit.replace (escape url) ~match_:")" ~replacement:"%29" in + let label = field "label" in + let label = if label = "" then if image then "Image" else url else label in + let label = String_kit.replace label ~match_:"]" ~replacement:"\\]" in + Ok ((if image then "!" else "") ^ "[" ^ label ^ "](" ^ url ^ ")") + with Invalid_argument error -> Error error | _ -> Error "Enter a URL and label" diff --git a/shared/src/logseq/core/editor_command.mli b/shared/src/logseq/core/editor_command.mli new file mode 100644 index 00000000..bc0828d5 --- /dev/null +++ b/shared/src/logseq/core/editor_command.mli @@ -0,0 +1,25 @@ +type picker = Embed | Template | Link | Image | Date | Deadline | Scheduled | Youtube +type action = + | Insert of string * int + | Properties of (string * Pending_ops.semantic_value option) list + | Choice of string * string option + | Code of string option + | Number of bool + | Journal of int + | Time + | Pick of picker +type entry = { id : string; label : string; action : action } +type operation = + | Insert_code of string * string option + | Ensure_journal of int + | Apply of string * action + | Embed_node of string * string * bool + | Apply_template of string * string * string +val catalog : Datascript.db option -> entry list +val candidates : Datascript.db option -> string -> entry list +val picker_name : picker -> string +val picker_of_name : string -> picker option +val journal_reference : Datascript.db option -> int -> string +val date_ms : string -> (int, string) result +val timestamp : string -> (int, string) result +val link : image:bool -> string -> (string, string) result diff --git a/shared/src/logseq/core/editor_command_effects.ml b/shared/src/logseq/core/editor_command_effects.ml new file mode 100644 index 00000000..d67bc8e5 --- /dev/null +++ b/shared/src/logseq/core/editor_command_effects.ml @@ -0,0 +1,165 @@ +open Editor_command +module Ds = Datascript +module Ops = Pending_ops +module Read = Graph_read +module Entity = Ds.Entity +let eid db uuid = match Ds.entid db "block/uuid" (Ds.Uuid uuid) with + | Some eid -> eid | None -> invalid_arg "The node no longer exists" +let semantic db attr = function + | Ds.Ref eid -> (match Read.uuid_for_eid db eid with Some uuid -> Ops.Ref_uuid uuid | None -> invalid_arg "Missing reference") + | Ds.Int64 n when Datascript_value.is_ref_attr db attr -> (match Read.uuid_for_eid db (Int64.to_int n) with Some uuid -> Ops.Ref_uuid uuid | None -> invalid_arg "Missing reference") + | v -> (match Ops.semantic_value_from_datascript v with Ok v -> v | Error msg -> invalid_arg msg) +let set db uuid attr value = + let expected = Option.map (semantic db attr) (Read.value db (eid db uuid) attr) in + Ops.Set_property {uuid; attr; expected; value} +let edit db ~now ~fresh_uuid uuid attr text = + let p = Result_view.property_json db ~owner:uuid ~attr ~encode_block:(fun _ -> `Null) in + let raw = match p with `Assoc fields -> Option.value ~default:`Null (List.assoc_opt "raw" fields) | _ -> `Null in + Result_view.edit_property ~allow_builtin:true db ~now ~fresh_uuid (`Assoc ["blockUuid", `String uuid; + "attr", `String attr; "expected", raw; "value", `String text]) + +let children db parent = + Datascript_value.datoms_by_ref db Ds.Aevt "block/parent" parent |> List.of_seq + |> List.map (fun (d : Ds.datom) -> d.e) + |> List.sort (fun a b -> compare (Read.string_value (Read.value db a "block/order"), a) + (Read.string_value (Read.value db b "block/order"), b)) + +let next_order db target = + let order = Read.string_value (Read.value db target "block/order") in + match Read.ref_eids db target "block/parent" with + | parent :: _ -> children db parent |> List.find_map (fun eid -> + let next = Read.string_value (Read.value db eid "block/order") in + if compare next order > 0 then next else None) + | _ -> None + +let position lower upper = match Fractional_order.between lower upper with + | Ok order -> order | Error message -> invalid_arg message + +let clone_template db ~now ~fresh_uuid target source draft = + let target_eid = eid db target and source_eid = eid db source in + if not (Read.entity_is_instance_of db source_eid "logseq.class/Template") then invalid_arg "Choose a template"; + let roots = children db source_eid in + if roots = [] then invalid_arg "This template has no blocks"; + let page_eid = match Read.ref_eids db target_eid "block/page" with p :: _ -> p | _ -> invalid_arg "Missing target page" in + let page = Option.get (Read.uuid_for_eid db page_eid) in + let page_title = Option.value ~default:"" (Read.string_value (Read.value db page_eid "block/title")) in + let title eid = Option.value ~default:"" (Read.string_value (Read.value db eid "block/title")) in + let expand value = + Str.global_substitute (Str.regexp "<%\\([^%]*\\)%>") (fun matched -> + let variable = String.trim (Str.matched_group 1 matched) in + match String.lowercase_ascii variable with + | "current page" -> "[[" ^ page_title ^ "]]" + | "time" -> let t = Unix.localtime (float_of_int now /. 1000.) in Printf.sprintf "%02d:%02d" t.tm_hour t.tm_min + | "today" | "tomorrow" | "yesterday" as name -> + let t = Unix.localtime (float_of_int now /. 1000.) in + let seconds, _ = Unix.mktime {t with tm_mday = t.tm_mday + (if name = "tomorrow" then 1 else if name = "yesterday" then -1 else 0)} in + Editor_command.journal_reference (Some db) (int_of_float (seconds *. 1000.)) + | _ -> variable) value in + let target_children = children db target_eid in + let replace = String.trim draft = "" && target_children = [] in + let mappings = Hashtbl.create 32 in + let rec allocate seen eid = + if List.mem eid seen then invalid_arg "The template contains a cycle"; + if Hashtbl.length mappings >= 10000 then invalid_arg "The template is too large"; + Hashtbl.add mappings eid (if replace && eid = List.hd roots then target else fresh_uuid ()); + List.iter (allocate (eid :: seen)) (children db eid) in + List.iter (allocate [source_eid]) roots; + let rec convert attr = function + | Ds.Ref eid -> Ops.Ref_uuid (Option.value ~default:(Option.get (Read.uuid_for_eid db eid)) (Hashtbl.find_opt mappings eid)) + | Ds.Int64 eid when Datascript_value.is_ref_attr db attr -> convert attr (Ds.Ref (Int64.to_int eid)) + | Ds.Set values -> Ops.Set_value (List.map (convert attr) values) + | Ds.Vector values -> Ops.Vector_value (List.map (convert attr) values) + | Ds.String text -> Ops.String_value (expand text) + | value -> semantic db attr value in + let properties destination source = + let attrs = Hashtbl.create 16 in + Seq.iter (fun (d : Ds.datom) -> if d.a = "block/tags" || d.a = "block/link" + || String.starts_with ~prefix:"user.property/" d.a + || String.starts_with ~prefix:"logseq.property/" d.a + || String.starts_with ~prefix:"logseq.property." d.a then Hashtbl.replace attrs d.a ()) + (Ds.Db.datoms db Ds.Eavt ~e:source ()); + Hashtbl.fold (fun attr () ops -> + let entity = Option.get (Ds.entity db (Ds.Entity_id source)) in + let value = match Entity.entity_attr_raw entity attr with + | Some (Ds.One_value value) -> Some (convert attr value) + | Some (Ds.Many_values values) -> Some (Ops.Set_value (List.map (convert attr) values)) + | _ -> None in + let expected = if destination = target then + let entity = Option.get (Ds.entity db (Ds.Entity_id target_eid)) in + (match Entity.entity_attr_raw entity attr with + | Some (Ds.One_value value) -> Some (semantic db attr value) + | Some (Ds.Many_values values) -> Some (Ops.Set_value (List.map (semantic db attr) values)) + | _ -> None) else None in + Ops.Set_property {uuid = destination; attr; expected; value} :: ops) attrs [] in + let rec copy parent order source = + let uuid = Hashtbl.find mappings source in + let own = if uuid = target then [Ops.Save_title {uuid; expected_title = title target_eid; title = expand (title source)}] + else [Ops.Insert_block {uuid; title = expand (title source); parent_uuid = parent; + page_uuid = page; order; created_at = now}] in + own @ properties uuid source @ List.concat_map (fun child -> copy uuid + (Option.value ~default:"a0" (Read.string_value (Read.value db child "block/order"))) child) (children db source) in + let inside = target_children <> [] in + let parent = if inside then target else match Read.ref_eids db target_eid "block/parent" with + | p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> page in + let lower = if inside then match List.rev target_children with + | last :: _ -> Read.string_value (Read.value db last "block/order") | _ -> None + else Read.string_value (Read.value db target_eid "block/order") in + let upper = if inside then None else next_order db target_eid in + let _, ops = List.fold_left (fun (lower, ops) source -> + let reuse = Hashtbl.find mappings source = target in + let order = if reuse then Option.value ~default:"a0" lower else position lower upper in + Some order, List.rev_append (copy parent order source) ops) (lower, []) roots in + let ops = List.rev ops in + let ops = if replace then ops else Ops.Save_title {uuid = target; expected_title = title target_eid; title = draft} :: ops in + [Ops.Atomic (ops @ [Ops.Set_property {uuid = Hashtbl.find mappings (List.hd roots); + attr = "logseq.property/used-template"; expected = None; value = Some (Ops.Ref_uuid source)}])] + +let plan db ~now ~fresh_uuid operation = + try Ok (match operation with + | Ensure_journal ms -> + let day = Cache_model.journal_day_for_ms ms in + if Read.journal_page_uuid db day <> None then [] else + [Ops.Create_journal {page_uuid = Printf.sprintf "00000001-%04d-%04d-0000-000000000000" (day / 10000) (day mod 10000); + block_uuid = fresh_uuid (); title = Journal.day_title day; journal_day = day; created_at = now}] + | Insert_code (uuid, language) -> + let target = eid db uuid in + let page = match Read.ref_eids db target "block/page" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> invalid_arg "Missing page" in + let parent = match Read.ref_eids db target "block/parent" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> page in + let fresh = fresh_uuid () in + [Ops.Atomic ([Ops.Insert_block {uuid = fresh; title = ""; page_uuid = page; parent_uuid = parent; + order = position (Read.string_value (Read.value db target "block/order")) (next_order db target); created_at = now}; + Ops.Set_property {uuid = fresh; attr = "logseq.property.node/display-type"; expected = None; value = Some (Ops.Keyword_value "code")}] + @ match language with None -> [] | Some language -> [Ops.Set_property {uuid = fresh; + attr = "logseq.property.code/lang"; expected = None; value = Some (Ops.String_value language)}])] + | Embed_node (uuid, source, replace) -> + let source_eid = eid db source in + if uuid = source then invalid_arg "A node cannot embed itself"; + let rec ancestors seen eid_ = + if List.mem eid_ seen then false + else eid_ = source_eid || List.exists (ancestors (eid_ :: seen)) (Read.ref_eids db eid_ "block/parent") in + if ancestors [] (eid db uuid) then invalid_arg "A node cannot embed an ancestor"; + if replace then [set db uuid "block/link" (Some (Ops.Ref_uuid source))] else + let target = eid db uuid in + let page = match Read.ref_eids db target "block/page" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> invalid_arg "Missing page" in + let parent = match Read.ref_eids db target "block/parent" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> page in + let fresh = fresh_uuid () in + [Ops.Atomic [Ops.Insert_block {uuid = fresh; title = ""; page_uuid = page; parent_uuid = parent; + order = position (Read.string_value (Read.value db target "block/order")) (next_order db target); created_at = now}; + Ops.Set_property {uuid = fresh; attr = "block/link"; expected = None; value = Some (Ops.Ref_uuid source)}]] + | Apply_template (target, source, draft) -> clone_template db ~now ~fresh_uuid target source draft + | Apply (uuid, Properties fields) -> List.map (fun (attr, value) -> set db uuid attr value) fields + | Apply (uuid, Choice (attr, value)) -> [edit db ~now ~fresh_uuid uuid attr (Option.value ~default:"" value)] + | Apply (uuid, Code lang) -> [set db uuid "logseq.property.node/display-type" (Some (Ops.Keyword_value "code"))] + @ (match lang with None -> [] | Some lang -> [set db uuid "logseq.property.code/lang" (Some (Ops.String_value lang))]) + | Apply (uuid, Number children) -> + let targets = if children then Datascript_value.datoms_by_ref db Ds.Aevt "block/parent" (eid db uuid) + |> List.of_seq |> List.filter_map (fun (d : Ds.datom) -> Read.uuid_for_eid db d.e) else [uuid] in + let attr = "logseq.property/order-list-type" in + let on = List.exists (fun uuid -> Read.value db (eid db uuid) attr <> None) targets in + List.concat_map (fun uuid -> if on then [set db uuid attr None] else + let value_uuid = fresh_uuid () in + [Ops.Create_property_value {uuid = value_uuid; owner_uuid = uuid; property_ident = attr; + content = Ops.String_value "number"; created_at = now}; + set db uuid attr (Some (Ops.Ref_uuid value_uuid))]) targets + | Apply (_, _) -> []) + with Invalid_argument message | Failure message -> Error message diff --git a/shared/src/logseq/core/editor_command_effects.mli b/shared/src/logseq/core/editor_command_effects.mli new file mode 100644 index 00000000..4fc9f049 --- /dev/null +++ b/shared/src/logseq/core/editor_command_effects.mli @@ -0,0 +1,2 @@ +val plan : Datascript.db -> now:int -> fresh_uuid:(unit -> string) -> Editor_command.operation -> + (Pending_ops.pending_intent list, string) result diff --git a/shared/src/logseq/core/graph_read.mli b/shared/src/logseq/core/graph_read.mli index 85eac14c..0aa0a224 100644 --- a/shared/src/logseq/core/graph_read.mli +++ b/shared/src/logseq/core/graph_read.mli @@ -7,6 +7,7 @@ type sidebar_pages = } val value : Datascript.db -> int -> string -> Datascript.value option +val display_type_for_eid : Datascript.db -> int -> string option val string_value : Datascript.value option -> string option val uuid_value : Datascript.value option -> string option val int_value : Datascript.value option -> int option diff --git a/shared/src/logseq/core/graph_runtime.ml b/shared/src/logseq/core/graph_runtime.ml index fda60e9c..1bbba0dc 100644 --- a/shared/src/logseq/core/graph_runtime.ml +++ b/shared/src/logseq/core/graph_runtime.ml @@ -45,6 +45,7 @@ type graph_runtime = ; current_creator : unit -> Created_by.identity option ; search_index : Index.search_index option ; state : runtime_state ref + ; auto_create_today : bool } (* How many flattened journal blocks the first snapshot materializes: @@ -334,6 +335,7 @@ let create_base path server_t conn options = conn; encrypt_title = options.encrypt_title; current_creator = options.current_creator; + auto_create_today = options.auto_create_today; search_index; state = ref @@ -535,8 +537,9 @@ let queued_operation runtime operation_id intent = let ensure_today_journal runtime = let created_at = int_of_float (Unix.gettimeofday () *. 1000.0) in let day = Model.journal_day_for_ms created_at in - if Read.journal_page_uuid (db runtime) day <> None then Ok () - else + match Read.journal_page_uuid (db runtime) day with + | Some _ -> Ok () + | None -> stage runtime (queued_operation runtime (fresh_uuid ()) (Ops.Create_journal @@ -562,6 +565,9 @@ let create path server_t conn options = runtime let blocks runtime = + if runtime.auto_create_today then + (match ensure_today_journal runtime with Ok () -> () + | Error message -> failwith ("create today journal: " ^ message)); let caches = caches_for_db runtime (db runtime) in let limit = (state runtime).journal_limit in let block_limit = (state runtime).journal_block_limit in @@ -575,6 +581,16 @@ let blocks runtime = Hashtbl.replace caches.blocks (limit, block_limit, through_day) blocks; blocks +let empty_journals runtime = + let database = db runtime in + Read.recent_journal_page_ids (state runtime).journal_limit database + |> List.filter_map (fun eid -> + if Seq.uncons (Datascript_value.datoms_by_ref database Ds.Aevt "block/parent" eid) <> None then None + else match Read.page_summary (fun value -> Ok value) database eid, + Read.int_value (Read.value database eid "block/journal-day") with + | Some page, Some day -> Some (page, day) + | _ -> None) + let due_flashcards runtime now = Cards.due_cards (db runtime) now let semantic_option value = diff --git a/shared/src/logseq/core/graph_runtime.mli b/shared/src/logseq/core/graph_runtime.mli index bbec21c1..54b48adf 100644 --- a/shared/src/logseq/core/graph_runtime.mli +++ b/shared/src/logseq/core/graph_runtime.mli @@ -38,6 +38,7 @@ type graph_runtime = ; current_creator : unit -> Created_by.identity option ; search_index : Search_index.search_index option ; state : runtime_state ref + ; auto_create_today : bool } val default_options : runtime_options @@ -130,3 +131,5 @@ val search : graph_runtime -> string -> Search_index.indexed_search_hit list val search_page : graph_runtime -> offset:int -> limit:int -> string -> Search_index.indexed_search_hit list * bool + +val empty_journals : graph_runtime -> (Cache_model.entity_summary * int) list diff --git a/shared/src/logseq/core/markup.ml b/shared/src/logseq/core/markup.ml index 4e719bc7..3da3544b 100644 --- a/shared/src/logseq/core/markup.ml +++ b/shared/src/logseq/core/markup.ml @@ -10,6 +10,8 @@ type markup_node = | Markup_math of string * bool | Markup_video of string | Markup_iframe of string + | Markup_html of string + | Markup_image of string * string | Markup_youtube_timestamp of int * string | Markup_cloze of string | Markup_link of string * markup_node list @@ -36,6 +38,8 @@ let rec debug_string node = | Markup_math (expression, display) -> "Math(" ^ string_of_bool display ^ "," ^ Printf.sprintf "%S" expression ^ ")" | Markup_video url -> "Video(" ^ Printf.sprintf "%S" url ^ ")" + | Markup_html html -> "Html(" ^ Printf.sprintf "%S" html ^ ")" + | Markup_image (url, _) -> "Image(" ^ Printf.sprintf "%S" url ^ ")" | Markup_iframe url -> "Iframe(" ^ Printf.sprintf "%S" url ^ ")" | Markup_youtube_timestamp (seconds, label) -> "YoutubeTimestamp(" ^ string_of_int seconds ^ "," ^ Printf.sprintf "%S" label @@ -78,6 +82,8 @@ let rec node_to_yojson node = ] | Markup_video url -> [ ("type", `String "video"); ("url", `String url) ] + | Markup_html html -> ["type", `String "html"; "text", `String html] + | Markup_image (url, alt) -> ["type", `String "image"; "url", `String url; "text", `String alt] | Markup_iframe url -> [ ("type", `String "iframe"); ("url", `String url) ] | Markup_youtube_timestamp (seconds, label) -> @@ -287,11 +293,20 @@ and convert_macro (macro : Inline.Macro.t) raw = | _ -> raw and convert_nodes source references tags nodes = - List.fold_left - (fun converted (node, position) -> - List.fold_left append_node converted - (convert_node source references tags node position)) - [] nodes + let rec collect converted = function + | (Inline.Inline_Html "", _) :: rest -> + let rec closing inside = function + | (Inline.Inline_Html "", _) :: rest -> Some (List.rev inside, rest) + | node :: rest -> closing (node :: inside) rest + | [] -> None in + (match closing [] rest with + | Some (inside, rest) -> collect (append_node converted + (Markup_emphasis ("underline", convert_nodes source references tags inside))) rest + | None -> collect (append_node converted (Markup_text "")) rest) + | (node, position) :: rest -> collect + (List.fold_left append_node converted (convert_node source references tags node position)) rest + | [] -> converted in + collect [] nodes and convert_node source references tags node position = let raw = [ Markup_text (source_slice source position) ] in @@ -301,11 +316,17 @@ and convert_node source references tags node position = | Inline.Break_Line -> [ Markup_text "\n" ] | Inline.Hard_Break_Line -> [ Markup_text "\n" ] | Inline.Code value -> [ Markup_code value ] + | Inline.Inline_Html html when S.starts_with ~prefix:"" html && S.ends_with ~suffix:"" html -> + let inner = String.sub html 5 (String.length html - 11) in + let children = match Angstrom.parse_string ~consume:Angstrom.Consume.All (Inline.parse config) inner with + | Ok nodes -> convert_nodes inner references tags nodes | Error _ -> [Markup_text inner] in + [Markup_emphasis ("underline", children)] | Inline.Verbatim value -> [ Markup_code value ] | Inline.Latex_Fragment (Inline.Inline expression) -> [ Markup_math (expression, false) ] | Inline.Latex_Fragment (Inline.Displayed expression) -> [ Markup_math (expression, true) ] + | Inline.Export_Snippet ("html", html) -> [Markup_html html] | Inline.Macro macro -> convert_macro macro raw | Inline.Emphasis (style, children) -> [ @@ -325,6 +346,8 @@ and convert_node source references tags node position = | Some summary -> [ Markup_tag_ref (summary.Cache_model.uuid, summary.Cache_model.title) ] | None -> [ Markup_text ("#" ^ value) ]) + | Inline.Link link when S.starts_with ~prefix:"![" link.full_text -> + [Markup_image (Inline.string_of_url link.url, String.concat "" (List.map tag_part link.label))] | Inline.Link link -> (match link.url with | Inline.Block_ref _ -> raw diff --git a/shared/src/logseq/core/markup.mli b/shared/src/logseq/core/markup.mli index f80753ea..ee29953d 100644 --- a/shared/src/logseq/core/markup.mli +++ b/shared/src/logseq/core/markup.mli @@ -9,6 +9,8 @@ type markup_node = | Markup_math of string * bool | Markup_video of string | Markup_iframe of string + | Markup_html of string + | Markup_image of string * string | Markup_youtube_timestamp of int * string | Markup_cloze of string | Markup_link of string * markup_node list diff --git a/shared/src/logseq/core/mobile_graph.ml b/shared/src/logseq/core/mobile_graph.ml index 64abeccb..4f02bc5d 100644 --- a/shared/src/logseq/core/mobile_graph.ml +++ b/shared/src/logseq/core/mobile_graph.ml @@ -55,6 +55,11 @@ let blocks (host : mobile_graph) = | Some opened -> Some (Runtime.blocks opened.read_runtime) | None -> None +let empty_journals (host : mobile_graph) = + match !(host.current) with + | Some opened -> Runtime.empty_journals opened.read_runtime + | None -> [] + let authoritative_blocks (host : mobile_graph) = match !(host.current) with | Some opened -> diff --git a/shared/src/logseq/core/mobile_graph.mli b/shared/src/logseq/core/mobile_graph.mli index 912b726d..8a0d8c4c 100644 --- a/shared/src/logseq/core/mobile_graph.mli +++ b/shared/src/logseq/core/mobile_graph.mli @@ -84,3 +84,5 @@ val search_page : mobile_graph -> offset:int -> limit:int -> string -> Search_index.indexed_search_hit list * bool val node_icon : mobile_graph -> string -> Yojson.Basic.t option + +val empty_journals : mobile_graph -> (Cache_model.entity_summary * int) list diff --git a/shared/src/logseq/core/mobile_session.ml b/shared/src/logseq/core/mobile_session.ml index 432f82a2..d2e9f258 100644 --- a/shared/src/logseq/core/mobile_session.ml +++ b/shared/src/logseq/core/mobile_session.ml @@ -63,6 +63,7 @@ let host_options (database : Database.mobile_database) ring catalog = Some (fun body -> Graph.apply_sync_event graph body); sync_cursor = Some (fun () -> Graph.sync_cursor graph); graph_blocks = Some (fun () -> Graph.blocks graph); + empty_journals = Some (fun () -> Graph.empty_journals graph); graph_db = Some (fun () -> Graph.projected_db graph); authoritative_graph_blocks = Some (fun () -> Graph.authoritative_blocks graph); diff --git a/shared/src/logseq/core/node_presentation.ml b/shared/src/logseq/core/node_presentation.ml new file mode 100644 index 00000000..ede6a8aa --- /dev/null +++ b/shared/src/logseq/core/node_presentation.ml @@ -0,0 +1,100 @@ +module Ds = Datascript +module Read = Graph_read +module M = Markup + +let calculator source = + let vars = Hashtbl.create 8 in + let tokenize line = + let re = Str.regexp "[0-9]+\\.?[0-9]*\\([eE][-+]?[0-9]+\\)?\\|[A-Za-z_][A-Za-z_0-9]*\\|[+*/^()=-]" in + let rec loop index acc = + if index >= String.length line then ref (List.rev acc) + else if line.[index] = ' ' || line.[index] = '\t' then loop (index + 1) acc + else if Str.string_match re line index then + let token = Str.matched_string line in loop (index + String.length token) (token :: acc) + else failwith "Unexpected input" in loop 0 [] in + let peek s = match !s with x :: _ -> x | [] -> "" in + let take s = match !s with x :: xs -> s := xs; x | [] -> failwith "Expected a number" in + let rec expression s = sum (product s) s + and sum left s = match peek s with + | "+" -> ignore (take s); sum (left +. product s) s + | "-" -> ignore (take s); sum (left -. product s) s + | _ -> left + and product s = factor (power s) s + and factor left s = match peek s with + | "*" -> ignore (take s); factor (left *. power s) s + | "/" -> ignore (take s); factor (left /. power s) s + | _ -> left + and power s = let left = atom s in match peek s with + | "^" -> ignore (take s); left ** power s | _ -> left + and atom s = match take s with + | "-" -> -. atom s | "+" -> atom s + | "(" -> let n = expression s in if take s = ")" then n else failwith "Expected )" + | token -> (match float_of_string_opt token with + | Some n -> n + | None -> if peek s = "(" then begin + ignore (take s); let n = expression s in + if take s <> ")" then failwith "Expected )"; + match token with "sqrt" -> sqrt n | "sin" -> sin n | "cos" -> cos n | "tan" -> tan n + | "abs" -> abs_float n | "round" -> Float.round n | "log" -> log n | _ -> failwith "Unknown function" + end else if token = "pi" then Float.pi else if token = "e" then exp 1. else Hashtbl.find vars token) in + String.split_on_char '\n' source |> List.map (fun line -> + if String.trim line = "" then line else try + let s = tokenize line in + let name = match !s with name :: "=" :: rest -> s := rest; Some name | _ -> None in + let n = expression s in + if !s <> [] || not (Float.is_finite n) then failwith "Invalid expression"; + Option.iter (fun name -> Hashtbl.replace vars name n) name; + line ^ " = " ^ Printf.sprintf "%.10g" n + with _ -> line ^ " = Error") |> String.concat "\n" + +let decorate db = + let numbers = Hashtbl.create 8 in + let list_number eid = + let numbered eid = match Read.ref_eids db eid "logseq.property/order-list-type" with + | value :: _ -> Read.string_value (Read.value db value "block/title") = Some "number" + | _ -> Read.string_value (Read.value db eid "logseq.property/order-list-type") = Some "number" in + if not (numbered eid) then None else + match Read.ref_eids db eid "block/parent" with + | [] -> Some 1 + | parent :: _ -> + if not (Hashtbl.mem numbers parent) then begin + let siblings = Datascript_value.datoms_by_ref db Ds.Aevt "block/parent" parent |> List.of_seq + |> List.map (fun (d : Ds.datom) -> d.e) + |> List.sort (fun a b -> compare (Read.string_value (Read.value db a "block/order"), a) + (Read.string_value (Read.value db b "block/order"), b)) in + let table = Hashtbl.create 16 in + ignore (List.fold_left (fun count eid -> if numbered eid then + (Hashtbl.add table eid count; count + 1) else 1) 1 siblings); + Hashtbl.add numbers parent table + end; + Hashtbl.find_opt (Hashtbl.find numbers parent) eid in + let rec markup seen budget eid = + if budget <= 0 || List.mem eid seen then [M.Markup_text "Embedded node unavailable"] else + let title = Option.value ~default:"" (Read.string_value (Read.value db eid "block/title")) in + let summaries attr = Read.entity_summaries (fun x -> Ok x) db eid attr in + let parsed = match Read.ref_eids db eid "block/link" with + | source :: _ -> + let children = Datascript_value.datoms_by_ref db Ds.Aevt "block/parent" source |> List.of_seq + |> List.map (fun (d : Ds.datom) -> d.e) + |> List.sort (fun a b -> compare (Read.string_value (Read.value db a "block/order"), a) + (Read.string_value (Read.value db b "block/order"), b)) in + [M.Markup_quote (markup (eid :: seen) (budget - 1) source @ + List.concat_map (fun child -> M.Markup_text "\n" :: markup (eid :: seen) (budget - 1) child) children)] + | [] -> (match Read.display_type_for_eid db eid with + | Some "code" -> + let language = Read.string_value (Read.value db eid "logseq.property.code/lang") in + [M.Markup_code_block (language, if language = Some "calc" then calculator title else title)] + | Some "math" -> [M.Markup_math (title, true)] + | Some "quote" -> [M.Markup_quote (M.parse (summaries "block/refs") (summaries "block/tags") title)] + | _ -> M.parse (summaries "block/refs") (summaries "block/tags") title) in + match Read.int_value (Read.value db eid "logseq.property/heading") with + | Some level when level >= 1 && level <= 6 -> [M.Markup_emphasis ("heading-" ^ string_of_int level, parsed)] + | _ -> parsed in + fun uuid encoded -> match Ds.entid db "block/uuid" (Ds.Uuid uuid), encoded with + | Some eid, `Assoc fields -> + let decorated = Read.ref_eids db eid "block/link" <> [] + || Read.value db eid "logseq.property/heading" <> None + || Read.value db eid "logseq.property.code/lang" <> None in + let fields = if decorated then ("markup", M.to_yojson (markup [] 16 eid)) :: List.remove_assoc "markup" fields else fields in + `Assoc ((match list_number eid with Some n -> ["listNumber", `Int n] | None -> []) @ fields) + | _ -> encoded diff --git a/shared/src/logseq/core/node_presentation.mli b/shared/src/logseq/core/node_presentation.mli new file mode 100644 index 00000000..216e178b --- /dev/null +++ b/shared/src/logseq/core/node_presentation.mli @@ -0,0 +1 @@ +val decorate : Datascript.db -> string -> (Yojson.Basic.t -> Yojson.Basic.t) diff --git a/shared/src/logseq/core/outliner_effects.ml b/shared/src/logseq/core/outliner_effects.ml index f8fc32f0..dc606a53 100644 --- a/shared/src/logseq/core/outliner_effects.ml +++ b/shared/src/logseq/core/outliner_effects.ml @@ -84,6 +84,13 @@ let append_result left right = let command base_t now fresh_uuid context cmd = let ( let* ) = Result.bind in match cmd with + | State.Editor_error message -> Error message + | State.Execute_editor operation_ -> + (match context.State.db with + | None -> Error "Open a graph before using this command" + | Some db -> + let* intents = Editor_command_effects.plan db ~now:(now ()) ~fresh_uuid operation_ in + Ok (result (List.map (operation base_t fresh_uuid) intents) [])) | State.Haptic haptic -> Ok (result [] [ Platform_haptic haptic ]) | State.Commit_title value -> Ok (result [ operation base_t fresh_uuid (Ops.Save_title value) ] []) @@ -258,12 +265,15 @@ let command base_t now fresh_uuid context cmd = let* position = Order.between lower None in let operation_id = fresh_uuid () in let uuid = fresh_uuid () in + let page_uuid = match List.find_opt (fun (block : Cache_model.block) -> + block.uuid = value.page_uuid) context.State.blocks with + | Some block -> block.page_id | None -> value.page_uuid in let intent = Ops.Insert_block { uuid; title = ""; - page_uuid = value.page_uuid; + page_uuid; parent_uuid = value.page_uuid; order = position; created_at = now (); @@ -280,7 +290,17 @@ let command base_t now fresh_uuid context cmd = | State.Copy_text text -> Ok (result [] [ Set_clipboard_text text ]) | State.Copy_references uuids -> Ok (result [] [ Set_clipboard_references uuids ]) - | State.Copy_urls uuids -> Ok (result [] [ Set_clipboard_urls uuids ]) + | State.Copy_urls uuids -> + let encode value = + let buffer = Buffer.create (String.length value) in + String.iter (fun c -> match c with + | 'a'..'z' | 'A'..'Z' | '0'..'9' | '-' | '_' | '.' | '~' -> Buffer.add_char buffer c + | _ -> Buffer.add_string buffer (Printf.sprintf "%%%02X" (Char.code c))) value; + Buffer.contents buffer in + (match context.State.graph_id with + | Some graph -> Ok (result [] [Set_clipboard_text (String.concat "\n" (List.map (fun uuid -> + "logseq://graph/" ^ encode graph ^ "?block-id=" ^ encode uuid) uuids))]) + | None -> Ok (result [] [Set_clipboard_urls uuids])) let interpret base_t now fresh_uuid context commands = let rec loop index operations platform = diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index da8962ab..fdbd20fa 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -7,7 +7,7 @@ module Order = Fractional_order module Ref_text = Ref_text module Search = Search_index -type reducer_autocomplete_kind = Node | Tag | Property +type reducer_autocomplete_kind = Node | Tag | Property | Command | Picker of Editor_command.picker type reducer_autocomplete = { kind : reducer_autocomplete_kind @@ -31,6 +31,7 @@ type outliner_state = ; selected : Sset.t ; pending_deletion : string list ; autocomplete : reducer_autocomplete option + ; completion_dismissed : bool ; collapsed : Sset.t ; zoomed : string list } @@ -87,6 +88,7 @@ type outliner_priority = type outliner_message = | Tap_block of string | Long_press_block of string + | Text_pasted of outliner_text | Text_changed of outliner_text | Caret_moved of int | Return_pressed @@ -96,6 +98,9 @@ type outliner_message = | Toolbar of outliner_toolbar | Drop_blocks of outliner_drop | Choose_autocomplete of string + | Search_autocomplete of string + | Submit_autocomplete of string + | Dismiss_autocomplete | Confirm_delete | Save_editing | Cancel_editing @@ -135,6 +140,8 @@ type outliner_root = type outliner_command = | Haptic of outliner_haptic + | Execute_editor of Editor_command.operation + | Editor_error of string | Commit_title of Ops.pending_title | Split_at of outliner_split | Merge_into_previous of outliner_merge @@ -155,7 +162,9 @@ type outliner_command = | Copy_urls of string list type outliner_context = - { blocks : Model.block list + { graph_id : string option + ; db : Datascript.db option + ; blocks : Model.block list ; pages : outliner_candidate list ; tags : outliner_candidate list ; label_values : (string, Sset.t) Hashtbl.t @@ -177,6 +186,7 @@ let empty = selected = Sset.empty; pending_deletion = []; autocomplete = None; + completion_dismissed = false; collapsed = Sset.empty; zoomed = []; } @@ -217,13 +227,15 @@ let duplicated_of label_values = let duplicated_labels candidates = duplicated_of (candidate_label_values candidates) -let context ?(search_nodes = fun _ -> []) ?is_block_reference blocks pages tags = +let context ?graph_id ?db ?(search_nodes = fun _ -> []) ?is_block_reference blocks pages tags = let is_block_reference = match is_block_reference with | Some resolve -> resolve | None -> fun uuid -> List.exists (fun (block : Model.block) -> block.uuid = uuid && block.page_id <> uuid) blocks in let label_values = candidate_label_values (pages @ tags) in { + graph_id; + db; blocks; pages; tags; @@ -330,6 +342,31 @@ let rec take_n n xs = else match xs with [] -> [] | x :: rest -> x :: take_n (n - 1) rest let autocomplete_candidates (context : outliner_context) request = + match request.kind with + | Command -> List.map (fun (e : Editor_command.entry) -> {label = e.label; value = e.id}) + (Editor_command.candidates context.db request.query) + | Picker Editor_command.Template -> + (match context.db with None -> [] | Some db -> + (match Datascript.entid db "db/ident" (Datascript.Keyword "logseq.class/Template") with + | None -> [] + | Some root -> + Graph_read.class_descendants db root |> List.concat_map (fun cls -> + Datascript_value.datoms_by_ref db Datascript.Aevt "block/tags" cls |> List.of_seq) + |> List.filter_map (fun (d : Datascript.datom) -> + Option.bind (Graph_read.entity_summary (fun x -> Ok x) db d.e) (fun summary -> + if includes_normalized_query summary.title (String.lowercase_ascii request.query) + then Some {label = summary.title; value = summary.uuid} else None)) + |> List.sort_uniq compare |> take_n 30)) + | Picker Editor_command.Embed -> + let candidates = context.search_nodes request.query @ context.pages @ List.map + (fun (b : Model.block) -> {label = b.title; value = b.uuid}) context.blocks in + let seen = Hashtbl.create 32 in + List.filter (fun (c : outliner_candidate) -> + if Hashtbl.mem seen c.value || String.trim c.label = "" + || not (includes_normalized_query c.label (String.lowercase_ascii request.query)) then false + else (Hashtbl.add seen c.value (); true)) candidates |> take_n 30 + | Picker _ -> [] + | Node | Tag | Property -> let query = String_kit.trim request.query in let normalized = String.lowercase_ascii query in let raw = @@ -345,6 +382,7 @@ let autocomplete_candidates (context : outliner_context) request = List.map (fun label -> { label; value = label }) [ "status"; "tags"; "alias"; "priority" ] + | Command | Picker _ -> [] in let fuzzy = request.kind = Tag && normalized <> "" in let seen = Hashtbl.create 32 in @@ -422,7 +460,7 @@ let completion_prefix prefix = if code_width <> 0 then "" else Bytes.sub_string visible start (length - start) else if prefix.[index] = '\\' && code_width = 0 && index + 1 < length then begin (match prefix.[index + 1] with - | '#' | '[' | ':' -> Bytes.set visible (index + 1) ' ' + | '#' | '[' | ':' | '/' -> Bytes.set visible (index + 1) ' ' | _ -> ()); scan (index + 2) code_width start end else if prefix.[index] = '`' then @@ -438,7 +476,9 @@ let completion_prefix prefix = let autocomplete_for title caret = let prefix = completion_prefix (Ref_text.completion_prefix title (byte_index_of_utf16 title caret)) in - match last_substring prefix "[[" with + match token_request Command '/' prefix with + | Some request -> Some request + | None -> match last_substring prefix "[[" with | Some index -> if not (contains_from prefix (index + 2) "]]") then Some @@ -533,6 +573,7 @@ let complete (context : outliner_context) (editing : editor_draft) kind value = | None -> 0 in Some (start, value ^ ":: ") + | Command | Picker _ -> None in match completion with | Some (start, replacement) -> @@ -1180,8 +1221,77 @@ let toolbar_reorder context (state : outliner_state) up_ = | Some moves -> step state [ Reparent_blocks moves; Haptic Impact ] | None -> step state [ Haptic Impact ] +let replace_slash (editing : editor_draft) = + let finish = byte_index_of_utf16 editing.title editing.caret in + match String.rindex_opt (String.sub editing.title 0 finish) '/' with + | None -> editing + | Some start -> {editing with title = replace_range editing.title start finish ""; + caret = utf16_length (String.sub editing.title 0 start)} + +let apply_editor_action context state editing action = + let open Editor_command in + let finish editing commands = step {state with editing = Some editing; autocomplete = None} + (commands @ [Haptic Selection]) in + match action with + | Insert (value, backward) -> finish (insert_at_caret editing value backward) [] + | Time -> + let tm = Unix.localtime (Unix.gettimeofday ()) in + finish (insert_at_caret editing (Printf.sprintf "%02d:%02d" tm.tm_hour tm.tm_min) 0) [] + | Journal offset -> + let tm = Unix.localtime (Unix.gettimeofday ()) in + let seconds, _ = Unix.mktime {tm with tm_mday = tm.tm_mday + offset} in + let ms = int_of_float (seconds *. 1000.) in + finish (insert_at_caret editing (journal_reference context.db ms) 0) [Execute_editor (Ensure_journal ms)] + | Pick picker -> + step {state with editing = Some editing; autocomplete = Some {kind = Picker picker; query = ""}} [] + | Code language when String.trim editing.title <> "" + || Option.bind (find_block context editing.uuid) (fun block -> block.Model.display_type) <> None -> + step {state with editing = None; autocomplete = None} + (commit_effect context (Some editing) @ [Execute_editor (Insert_code (editing.uuid, language))]) + | Properties _ | Choice _ | Code _ | Number _ -> + finish editing (commit_effect context (Some editing) @ [Execute_editor (Apply (editing.uuid, action))]) + +let submit_completion context state value = + match state.editing, state.autocomplete with + | Some editing, Some {kind = Picker picker; _} -> + let open Editor_command in + let inserted result = match result with + | Ok value -> apply_editor_action context state editing (Insert (value, 0)) + | Error message -> step state [Editor_error message] in + (match picker with + | Link | Image -> inserted (link ~image:(picker = Image) value) + | Date -> (match date_ms value with + | Error message -> step state [Editor_error message] + | Ok ms -> let next, commands = inserted (Ok (journal_reference context.db ms)) in + step next (commands @ [Execute_editor (Ensure_journal ms)])) + | Deadline | Scheduled -> + (match int_of_string_opt value with + | Some _ -> apply_editor_action context state editing + (Choice ((if picker = Deadline then "logseq.property/deadline" else "logseq.property/scheduled"), Some value)) + | None -> step state [Editor_error "Choose a date and time"]) + | Youtube -> inserted (Result.map (fun seconds -> "{{youtube-timestamp " ^ string_of_int seconds ^ "}}") (timestamp value)) + | Embed | Template -> step state []) + | _ -> step state [] + let choose_completion context (state : outliner_state) value = match (state.editing, state.autocomplete) with + | Some editing, Some {kind = Command; _} -> + (match autocomplete_for editing.title editing.caret with + | Some {kind = Command; query} -> + (match List.find_opt (fun (entry : Editor_command.entry) -> entry.id = value) + (Editor_command.candidates context.db query) with + | Some entry -> apply_editor_action context state (replace_slash editing) entry.action + | None -> step state []) + | _ -> step state []) + | Some editing, Some ({kind = Picker (Editor_command.Embed | Editor_command.Template as picker); _} as request) -> + if not (List.exists (fun (c : outliner_candidate) -> c.value = value) (autocomplete_candidates context request)) + then step state [] + else if picker = Editor_command.Embed then + step {state with autocomplete = None; editing = None} + (commit_effect context (Some editing) @ [Execute_editor (Editor_command.Embed_node (editing.uuid, value, String.trim editing.title = "")); Haptic Selection]) + else + step {state with autocomplete = None; editing = None} + [Execute_editor (Editor_command.Apply_template (editing.uuid, value, editing.title)); Haptic Selection] | Some editing, Some request when autocomplete_for editing.title editing.caret = Some request -> let context = if request.kind = Node && String_kit.trim request.query <> "" then @@ -1249,6 +1359,21 @@ let rec take_while pred xs = | [] -> [] | x :: rest -> if pred x then x :: take_while pred rest else [] +let enter context state editing = + match state.autocomplete with + | Some ({kind = (Command | Node | Tag | Property); _} as request) -> + (match autocomplete_candidates context request with + | first :: _ -> choose_completion context {state with editing = Some editing} first.value + | [] -> step state []) + | Some _ -> step state [] + | None -> + if Option.fold ~none:false ~some:(fun block -> List.mem block.Model.display_type [Some "code"; Some "math"]) + (find_block context editing.uuid) then + let index = byte_index_of_utf16 editing.title editing.caret in + step {state with editing = Some {editing with + title = replace_range editing.title index index "\n"; caret = editing.caret + 1}} [] + else split_or_outdent context state editing + let update context (state : outliner_state) message = let state = { @@ -1284,7 +1409,7 @@ let update context (state : outliner_state) message = autocomplete = None; } (commit_effect context state.editing @ [ Haptic Selection ]) - | Text_changed value -> + | Text_pasted value | Text_changed value -> (match state.editing with | Some editing -> step @@ -1292,7 +1417,15 @@ let update context (state : outliner_state) message = state with editing = Some { editing with title = value.title; caret = value.caret }; - autocomplete = autocomplete_for value.title value.caret; + autocomplete = (match message with Text_pasted _ -> None | _ -> + let request = autocomplete_for value.title value.caret in + let prefix = prefix_at value.title value.caret in + let fresh = editing.title <> value.title && + List.exists (fun suffix -> String_kit.ends_with ~suffix prefix) ["/"; "[["; "#"] in + if state.completion_dismissed && not fresh then None else request); + completion_dismissed = (match message with Text_pasted _ -> true | _ -> + state.completion_dismissed && not (editing.title <> value.title && + List.exists (fun suffix -> String_kit.ends_with ~suffix (prefix_at value.title value.caret)) ["/"; "[["; "#"])); } [] | None -> step state []) @@ -1310,14 +1443,21 @@ let update context (state : outliner_state) message = [] | None -> step state []) | Choose_autocomplete value -> choose_completion context state value + | Dismiss_autocomplete -> step {state with autocomplete = None; completion_dismissed = true} [] + | Search_autocomplete query -> + (match state.autocomplete with + | Some ({kind = Picker (Editor_command.Embed | Editor_command.Template); _} as request) -> + step {state with autocomplete = Some {request with query}} [] + | _ -> step state []) + | Submit_autocomplete value -> submit_completion context state value | Return_pressed -> (match state.editing with - | Some editing -> split_or_outdent context state editing + | Some editing -> enter context state editing | None -> step state []) | Return_pressed_with_text value -> (match state.editing with | Some editing -> - split_or_outdent context state + enter context state { editing with title = value.title; caret = value.caret } | None -> step state []) | Backspace_pressed value -> @@ -1450,6 +1590,10 @@ let update context (state : outliner_state) message = (effects @ [ Insert_root_block { page_uuid }; Haptic Impact ]) | Operation_staged intent -> (match intent with + | Ops.Atomic (Ops.Insert_block block :: rest) when List.exists (function + | Ops.Set_property property -> property.uuid = block.uuid + && property.attr = "logseq.property.node/display-type" && property.value = Some (Ops.Keyword_value "code") + | _ -> false) rest -> staged_editing context state block.uuid block.title false | Ops.Split_block value -> staged_editing context state value.new_uuid value.after true | Ops.Insert_block value -> diff --git a/shared/src/logseq/core/outliner_state.mli b/shared/src/logseq/core/outliner_state.mli index b8d0d054..1afe1a29 100644 --- a/shared/src/logseq/core/outliner_state.mli +++ b/shared/src/logseq/core/outliner_state.mli @@ -4,7 +4,7 @@ val native_input_policy : string module Sset : Set.S with type elt = string -type reducer_autocomplete_kind = Node | Tag | Property +type reducer_autocomplete_kind = Node | Tag | Property | Command | Picker of Editor_command.picker type reducer_autocomplete = { kind : reducer_autocomplete_kind @@ -28,6 +28,7 @@ type outliner_state = ; selected : Sset.t ; pending_deletion : string list ; autocomplete : reducer_autocomplete option + ; completion_dismissed : bool ; collapsed : Sset.t ; zoomed : string list } @@ -84,6 +85,7 @@ type outliner_priority = type outliner_message = | Tap_block of string | Long_press_block of string + | Text_pasted of outliner_text | Text_changed of outliner_text | Caret_moved of int | Return_pressed @@ -93,6 +95,9 @@ type outliner_message = | Toolbar of outliner_toolbar | Drop_blocks of outliner_drop | Choose_autocomplete of string + | Search_autocomplete of string + | Submit_autocomplete of string + | Dismiss_autocomplete | Confirm_delete | Save_editing | Cancel_editing @@ -132,6 +137,8 @@ type outliner_root = type outliner_command = | Haptic of outliner_haptic + | Execute_editor of Editor_command.operation + | Editor_error of string | Commit_title of Pending_ops.pending_title | Split_at of outliner_split | Merge_into_previous of outliner_merge @@ -152,7 +159,9 @@ type outliner_command = | Copy_urls of string list type outliner_context = - { blocks : Cache_model.block list + { graph_id : string option + ; db : Datascript.db option + ; blocks : Cache_model.block list ; pages : outliner_candidate list ; tags : outliner_candidate list ; label_values : (string, Sset.t) Hashtbl.t @@ -178,6 +187,8 @@ val zoom_path : outliner_state -> string list val find_block : outliner_context -> string -> Cache_model.block option val duplicated_labels : outliner_candidate list -> Sset.t val context : + ?graph_id:string -> + ?db:Datascript.db -> ?search_nodes:(string -> outliner_candidate list) -> ?is_block_reference:(string -> bool) -> Cache_model.block list -> diff --git a/shared/src/logseq/core/rpc_encode.ml b/shared/src/logseq/core/rpc_encode.ml index 8134ad77..aba28d78 100644 --- a/shared/src/logseq/core/rpc_encode.ml +++ b/shared/src/logseq/core/rpc_encode.ml @@ -89,6 +89,7 @@ let block_json (block : Model.block) = ; ("breadcrumbs", `List (List.map summary_json block.breadcrumbs)) ; ("markup", markup_json block) ] + @ (match block.display_type with Some typ -> ["displayType", `String typ] | None -> []) @ (match block.order with | Some order -> [ ("order", `String order) ] | None -> []) @@ -328,6 +329,8 @@ let autocomplete_kind_json kind = | Outliner.Node -> "node" | Outliner.Tag -> "tag" | Outliner.Property -> "property" + | Outliner.Command -> "command" + | Outliner.Picker picker -> Editor_command.picker_name picker let outliner_state_json (state : Outliner.outliner_state) = Wire.json_object diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index fd3b87c0..cad36cbe 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -164,6 +164,7 @@ let with_queries session result = | None -> result | Some db -> let cache = Hashtbl.create 8 in + let presentation = Node_presentation.decorate db in let query uuid = match Hashtbl.find_opt cache uuid with | Some value -> value | None -> let value = query_projection session db uuid in @@ -176,7 +177,7 @@ let with_queries session result = (match List.assoc_opt "uuid" fields, List.assoc_opt "pageId" fields, List.assoc_opt "isShell" fields with | Some (`String uuid), Some _, shell when shell <> Some (`Bool true) -> - (match query uuid with + presentation uuid (match query uuid with | Some value -> `Assoc (("query", value) :: List.remove_assoc "query" fields) | None -> `Assoc fields) | _ -> `Assoc fields) @@ -281,6 +282,13 @@ let node_routes_json session serialize serialize_plain = ]) (state session).node_routes) +let empty_journals_json session = + let pages = match (host session).Types.empty_journals with + | Some read -> read () | None -> [] in + `List (List.map (fun ((page : Model.entity_summary), day) -> + `Assoc ["uuid", `String page.uuid; "title", `String page.title; + "journalDay", `Int day]) pages) + let snapshot session context_blocks blocks = let s = state session in let h = host session in @@ -416,6 +424,7 @@ let snapshot session context_blocks blocks = (List.map Rpc.outliner_command_json s.outliner_commands) ) ; ( "hasPendingSemanticOperations" , `Bool (has_pending_operations session) ) + ; ("emptyJournals", empty_journals_json session) ; ( "hasOlderJournals" , `Bool (match h.Types.has_older_journals with @@ -451,6 +460,7 @@ let outliner_patch_result session context blocks deleted splices = blocks deleted splices in match Json.from_string encoded with | `Assoc fields -> (match List.assoc_opt "result" fields with + | Some (`Assoc result) -> success session (`Assoc (("emptyJournals", empty_journals_json session) :: result)) | Some result -> success session result | None -> encoded) | _ -> encoded @@ -1538,6 +1548,7 @@ let deep_link_response session extra = let graph_deep_link session graph page block = let current = state session in + let graph = if graph = "" then Option.fold ~none:"" ~some:(fun (c : Api.api_config) -> c.graph_id) current.config else graph in let matches (candidate : Api.api_graph) = candidate.id = graph || candidate.name = graph in let candidates = List.filter matches current.available_graphs in let candidates = if candidates <> [] then candidates else diff --git a/shared/src/logseq/core/rpc_wire.ml b/shared/src/logseq/core/rpc_wire.ml index f2246b89..0ff0d374 100644 --- a/shared/src/logseq/core/rpc_wire.ml +++ b/shared/src/logseq/core/rpc_wire.ml @@ -219,7 +219,9 @@ let decode_outliner_event fields = let* title = required_string fields "title" in let* caret = event_int fields "caretUTF16Offset" in Ok - (Outliner.Text_changed { Outliner.title; caret }) + (if List.assoc_opt "inputOrigin" fields = Some (`String "paste") then + Outliner.Text_pasted { Outliner.title; caret } + else Outliner.Text_changed { Outliner.title; caret }) | "caretMoved" -> let* caret = event_int fields "caretUTF16Offset" in Ok (Outliner.Caret_moved caret) @@ -264,6 +266,9 @@ let decode_outliner_event fields = | "chooseAutocomplete" -> let* value = required_string fields "value" in Ok (Outliner.Choose_autocomplete value) + | "dismissAutocomplete" -> Ok Outliner.Dismiss_autocomplete + | "searchAutocomplete" -> let* query = required_string fields "query" in Ok (Outliner.Search_autocomplete query) + | "submitAutocomplete" -> let* value = required_string fields "value" in Ok (Outliner.Submit_autocomplete value) | "confirmDelete" -> Ok Outliner.Confirm_delete | "saveEditing" -> Ok Outliner.Save_editing | "cancelEditing" -> Ok Outliner.Cancel_editing diff --git a/shared/src/logseq/core/session_outliner.ml b/shared/src/logseq/core/session_outliner.ml index 1e294cb7..92b62315 100644 --- a/shared/src/logseq/core/session_outliner.ml +++ b/shared/src/logseq/core/session_outliner.ml @@ -49,7 +49,9 @@ let outliner_context_with_blocks session sidebar blocks = | Some (_, zoom) -> zoom | None -> List.exists (fun (block : Model.block) -> block.uuid = uuid) blocks) | None -> List.exists (fun (block : Model.block) -> block.uuid = uuid) blocks in - Outliner.context ~search_nodes ~is_block_reference blocks pages tags + let db = match (Types.host session).graph_db with + | Some load -> load () | None -> Some (Types.state session).model.db in + Outliner.context ?graph_id:(Option.map (fun (c : Api.api_config) -> c.graph_id) (Types.state session).config) ?db ~search_nodes ~is_block_reference blocks pages tags let sidebar_node_destination (session : Types.session) (node : Model.entity_summary) = match (Types.host session).graph_node_destination with diff --git a/shared/src/logseq/core/session_types.ml b/shared/src/logseq/core/session_types.ml index ba67e530..5a218096 100644 --- a/shared/src/logseq/core/session_types.ml +++ b/shared/src/logseq/core/session_types.ml @@ -107,6 +107,7 @@ type host_options = (string -> bool -> string -> int -> (unit, string) result) option ; graph_delete_page : (string -> string -> int -> (unit, string) result) option ; load_older_journals : (unit -> unit) option + ; empty_journals : (unit -> (Model.entity_summary * int) list) option ; has_older_journals : (unit -> bool) option ; load_cached_graph_key : (Api.api_config -> (unit, string) result) option ; unlock_graph : @@ -205,6 +206,7 @@ let default_options = graph_set_page_favorite = None; graph_delete_page = None; load_older_journals = None; + empty_journals = None; has_older_journals = None; load_cached_graph_key = None; unlock_graph = None; diff --git a/shared/src/logseq/core/session_types.mli b/shared/src/logseq/core/session_types.mli index fc3ecb3a..f6f6419e 100644 --- a/shared/src/logseq/core/session_types.mli +++ b/shared/src/logseq/core/session_types.mli @@ -100,6 +100,7 @@ type host_options = (string -> bool -> string -> int -> (unit, string) result) option ; graph_delete_page : (string -> string -> int -> (unit, string) result) option ; load_older_journals : (unit -> unit) option + ; empty_journals : (unit -> (Cache_model.entity_summary * int) list) option ; has_older_journals : (unit -> bool) option ; load_cached_graph_key : (Api.api_config -> (unit, string) result) option ; unlock_graph : diff --git a/shared/src/logseq/core/shared_capture.ml b/shared/src/logseq/core/shared_capture.ml index a5d393fa..49706d81 100644 --- a/shared/src/logseq/core/shared_capture.ml +++ b/shared/src/logseq/core/shared_capture.ml @@ -41,12 +41,12 @@ let deep_link fields = let* path = optional fields "path" in let graph = Option.map (fun path -> if String.starts_with ~prefix:"/" path then String.sub path 1 (String.length path - 1) else path) path in - (match graph with - | None | Some "" -> Error "A graph name or identifier is required" - | Some graph -> - let* page = optional values "page" in - let* block = optional values "block-id" in - Ok (`Graph (graph, page, block))) + let* explicit_graph = optional values "graph-id" in + let graph = Option.value ~default:(Option.value ~default:"" graph) explicit_graph in + let* page = optional values "page" in + let* block = optional values "block-id" in + if graph = "" && page = None && block = None then Error "A graph name or identifier is required" + else Ok (`Graph (graph, page, block)) | Some "capture" -> (match text values with Ok value -> Ok (`Capture value) | Error _ -> Ok (`Quick_action "capture")) | Some ("audio" | "journal" as action) -> Ok (`Quick_action action) | _ -> Ok (`Ignore) diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index 365b0870..81bcd362 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -119,6 +119,8 @@ type outline_row = { row_uuid : string ; row_loaded : bool ; row_title : string + ; row_list_number : int option + ; row_display_type : string option ; markup_json : string ; query_json : string ; youtube_target_url : string option @@ -171,6 +173,8 @@ type outliner_autocomplete_kind = | NodeAutocomplete | TagAutocomplete | PropertyAutocomplete + | CommandAutocomplete + | PickerAutocomplete of string type outliner_autocomplete = { autocomplete_kind : outliner_autocomplete_kind @@ -237,6 +241,7 @@ type core_projection = ; reference_update : (string * outline_row list) option ; pending_reference_uuid : string option ; node_routes : node_projection list + ; empty_journals : (sidebar_page * int) list ; journal_outliner_rows : outline_row list ; projection_outliner_editing : outliner_editing option ; projection_outliner_autocomplete : outliner_autocomplete option @@ -275,6 +280,8 @@ type logseq_effect = | LoadPageReferencesEffect of int * string | HydrateOutlinerRowsEffect of int * string list | TapOutlinerBlockEffect of int * string + | EditorOutlinerEventEffect of int * string + | PasteOutlinerTextEffect of int * string * string * int | ChangeOutlinerTextEffect of int * string * string * int | ReturnOutlinerEditorEffect of int * string * string * int | BackspaceOutlinerEditorEffect of int * string * string * int @@ -391,6 +398,7 @@ type logseq_model = ; search_has_more : bool ; node_routes : node_projection list ; navigation_root_rows : outline_row list + ; empty_journals : (sidebar_page * int) list ; journal_outliner_rows : outline_row list ; outliner_rows : outline_row list ; outliner_selected_block_ids : string list @@ -433,6 +441,8 @@ type logseq_action = | ApplySearchResults of string * search_hit list | ApplyCoreSnapshot of core_projection | BeginOutlinerEdit of string + | EditorCompletion of string * string + | PasteOutlinerText of string * string * int | ChangeOutlinerText of string * string * int | ReturnOutlinerEditor of string * string * int | BackspaceOutlinerEditor of string * string * int @@ -723,6 +733,7 @@ let initial () = search_has_more = false; node_routes = []; navigation_root_rows = []; + empty_journals = []; journal_outliner_rows = []; outliner_rows = []; outliner_selected_block_ids = []; @@ -803,6 +814,8 @@ let effect_id eff = | LoadPageReferencesEffect (id, _) -> id | HydrateOutlinerRowsEffect (id, _) -> id | TapOutlinerBlockEffect (id, _) -> id + | PasteOutlinerTextEffect (id, _, _, _) -> id + | EditorOutlinerEventEffect (id, _) -> id | ChangeOutlinerTextEffect (id, _, _, _) -> id | ReturnOutlinerEditorEffect (id, _, _, _) -> id | BackspaceOutlinerEditorEffect (id, _, _, _) -> id @@ -909,6 +922,7 @@ let sign_in_active_ (current : logseq_model) = let change_outliner_text_effect_for_ eff uuid = match eff with + | PasteOutlinerTextEffect (_, effect_uuid, _, _) | ChangeOutlinerTextEffect (_, effect_uuid, _, _) -> effect_uuid = uuid | _ -> false @@ -1312,7 +1326,7 @@ let indexed_outliner_autocomplete_candidates candidates = let reference_autocomplete_candidates (current : logseq_model) = match current.outliner_autocomplete with - | Some { autocomplete_kind = (TagAutocomplete | NodeAutocomplete); _ } -> + | Some _ -> current.outliner_autocomplete_candidates | _ -> [] @@ -1392,6 +1406,7 @@ let cancels_outliner_autosave = function | OutlinerToolbarEffect (_, action) -> (* Movement and media actions retain the draft without committing it. *) List.mem action [ "hideKeyboard"; "tag"; "pageReference"; "delete" ] + | PasteOutlinerTextEffect _ | EditorOutlinerEventEffect _ | ChangeOutlinerTextEffect _ | ReturnOutlinerEditorEffect _ | BackspaceOutlinerEditorEffect _ | TapOutlinerBlockEffect _ | ToggleOutlinerCollapsedEffect _ | LongPressOutlinerBlockEffect _ @@ -1950,6 +1965,17 @@ let rec update (current : logseq_model) action = | BeginOutlinerEdit uuid -> let id = current.next_effect_id in enqueue_effect current (TapOutlinerBlockEffect (id, uuid)) + | EditorCompletion (kind, value) -> + let fields = if kind = "searchAutocomplete" then ["query", `String value] + else if kind = "submitAutocomplete" then ["value", `String value] else [] in + if not (List.mem kind ["searchAutocomplete"; "submitAutocomplete"; "dismissAutocomplete"]) then current + else enqueue_effect current (EditorOutlinerEventEffect (current.next_effect_id, + Yojson.Basic.to_string (`Assoc (("type", `String kind) :: fields)))) + | PasteOutlinerText (uuid, title, caret) -> + let updated = update_editing current uuid title caret in + let updated = {updated with outliner_autocomplete = None; outliner_autocomplete_candidates = []; + sync_state = editing_sync_state current} in + enqueue_effect updated (PasteOutlinerTextEffect (updated.next_effect_id, uuid, title, caret)) | ChangeOutlinerText (uuid, title, caret) -> let updated = { @@ -2177,7 +2203,7 @@ let rec update (current : logseq_model) action = in_flight_effects = remove_effect current.in_flight_effects id; outliner_selection_revision = (match succeeded, eff with - | true, ChooseOutlinerAutocompleteEffect _ -> current.outliner_selection_revision + 1 + | true, (ChooseOutlinerAutocompleteEffect _ | EditorOutlinerEventEffect _) -> current.outliner_selection_revision + 1 | _ -> current.outliner_selection_revision); result_view_feedback = (match eff with | ResultViewEffect (_, uuid, _) | NodePropertyEffect (_, uuid, _) -> (uuid, id, (if succeeded then None else Some message)) @@ -2200,7 +2226,8 @@ let rec update (current : logseq_model) action = { resolved with sync_state = SyncedState } | _ -> resolved in let edited_uuid = match eff with - | ChangeOutlinerTextEffect (_, uuid, _, _) -> Some uuid + | PasteOutlinerTextEffect (_, uuid, _, _) | ChangeOutlinerTextEffect (_, uuid, _, _) -> Some uuid + | EditorOutlinerEventEffect _ -> Option.map (fun editing -> editing.editing_uuid) resolved.outliner_editing | BackspaceOutlinerEditorEffect (_, uuid, _, _) -> Some uuid | ChooseOutlinerAutocompleteEffect _ -> Option.map (fun editing -> editing.editing_uuid) resolved.outliner_editing | _ -> None in @@ -2309,8 +2336,10 @@ let rec update (current : logseq_model) action = } | BackSearchNavigation count -> back_search_navigation current count | AddRootBlock uuid -> - let id = current.next_effect_id in - enqueue_effect current (AddRootBlockEffect (id, uuid)) + if List.exists (function AddRootBlockEffect (_, parent) -> parent = uuid | _ -> false) + (current.pending_effects @ current.in_flight_effects) then current + else let id = current.next_effect_id in + enqueue_effect current (AddRootBlockEffect (id, uuid)) | LoadOlderJournals -> if current.graph_loading || load_older_journals_active_ current then current else begin @@ -2901,6 +2930,7 @@ and apply_core_snapshot (current : logseq_model) has_pending_sync_request = projection.has_pending_sync_request; pending_sync_error = projection.pending_sync_error; navigation_root_rows = (if current.node_routes = [] then projected_rows else current.navigation_root_rows); + empty_journals = projection.empty_journals; journal_outliner_rows = journal_rows; outliner_editing = snapshot_outliner_editing current projection; outliner_autocomplete = snapshot_autocomplete current projection; @@ -3059,6 +3089,7 @@ and apply_core_snapshot (current : logseq_model) navigation_root_rows = (if projection.node_routes = [] then projected_rows else if selected_graph_changed then projection.journal_outliner_rows else retain_shell_contents current.navigation_root_rows projection.journal_outliner_rows); + empty_journals = projection.empty_journals; journal_outliner_rows = journal_rows; outliner_editing = snapshot_outliner_editing current projection; outliner_autocomplete = snapshot_autocomplete current projection; diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index 329ec77e..445963d1 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -116,6 +116,8 @@ type outline_row = { row_uuid : string ; row_loaded : bool ; row_title : string + ; row_list_number : int option + ; row_display_type : string option ; markup_json : string ; query_json : string ; youtube_target_url : string option @@ -168,6 +170,8 @@ type outliner_autocomplete_kind = | NodeAutocomplete | TagAutocomplete | PropertyAutocomplete + | CommandAutocomplete + | PickerAutocomplete of string type outliner_autocomplete = { autocomplete_kind : outliner_autocomplete_kind @@ -234,6 +238,7 @@ type core_projection = ; reference_update : (string * outline_row list) option ; pending_reference_uuid : string option ; node_routes : node_projection list + ; empty_journals : (sidebar_page * int) list ; journal_outliner_rows : outline_row list ; projection_outliner_editing : outliner_editing option ; projection_outliner_autocomplete : outliner_autocomplete option @@ -272,6 +277,8 @@ type logseq_effect = | LoadPageReferencesEffect of int * string | HydrateOutlinerRowsEffect of int * string list | TapOutlinerBlockEffect of int * string + | EditorOutlinerEventEffect of int * string + | PasteOutlinerTextEffect of int * string * string * int | ChangeOutlinerTextEffect of int * string * string * int | ReturnOutlinerEditorEffect of int * string * string * int | BackspaceOutlinerEditorEffect of int * string * string * int @@ -388,6 +395,7 @@ type logseq_model = ; search_has_more : bool ; node_routes : node_projection list ; navigation_root_rows : outline_row list + ; empty_journals : (sidebar_page * int) list ; journal_outliner_rows : outline_row list ; outliner_rows : outline_row list ; outliner_selected_block_ids : string list @@ -430,6 +438,8 @@ type logseq_action = | ApplySearchResults of string * search_hit list | ApplyCoreSnapshot of core_projection | BeginOutlinerEdit of string + | EditorCompletion of string * string + | PasteOutlinerText of string * string * int | ChangeOutlinerText of string * string * int | ReturnOutlinerEditor of string * string * int | BackspaceOutlinerEditor of string * string * int diff --git a/shared/src/logseq/native_bridge.ml b/shared/src/logseq/native_bridge.ml index faf4eae2..5f1293ad 100644 --- a/shared/src/logseq/native_bridge.ml +++ b/shared/src/logseq/native_bridge.ml @@ -300,6 +300,11 @@ let encode_effect (eff : Model.logseq_effect) = "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"tap-outliner-block\",\"text\":" ^ Lui_wire.quoted uuid ^ "}" + | EditorOutlinerEventEffect (id, payload) -> + Yojson.Basic.to_string (`Assoc ["id", `Int id; "kind", `String "editor-outliner-event"; "text", `String payload]) + | PasteOutlinerTextEffect (id, uuid, title, caret) -> + Yojson.Basic.to_string (`Assoc ["id", `Int id; "kind", `String "paste-outliner-text"; + "text", `String title; "uuid", `String uuid; "value", `Int caret]) | ChangeOutlinerTextEffect (id, uuid, title, caret) -> "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"change-outliner-text\",\"text\":" @@ -542,7 +547,7 @@ let extension_event node identifier name text value = | 1 -> "inside" | _ -> "after") ); ] - | _, ("text-change" | "return") -> + | _, ("text-change" | "paste" | "return") -> [ "title", StringValue text; "caret-utf16-offset", IntValue value; diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index 62b77560..0acb3e96 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -239,7 +239,8 @@ let outline_row_from_block youtube_target_url opens_as_page depth match string_member "uuid" fields, string_member "title" fields with | Some row_uuid, Some row_title -> Some - { Model.row_uuid; row_title; row_loaded = not (bool_member "isShell" fields); + { Model.row_uuid; row_title; row_list_number = int_member "listNumber" fields; + row_display_type = string_member "displayType" fields; row_loaded = not (bool_member "isShell" fields); query_json = (match List.assoc_opt "query" fields with | Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); markup_json = @@ -311,6 +312,8 @@ let outliner_autocomplete input = | "node" -> Some Model.NodeAutocomplete | "tag" -> Some Model.TagAutocomplete | "property" -> Some Model.PropertyAutocomplete + | "command" -> Some Model.CommandAutocomplete + | ("embed" | "template" | "link" | "image" | "date" | "deadline" | "scheduled" | "youtube") as kind -> Some (Model.PickerAutocomplete kind) | _ -> None in match kind, string_member "query" fields with @@ -456,6 +459,12 @@ let core_projection fields = search_results = List.filter_map search_hit (list_member "searchResults" fields); node_routes = routes; + empty_journals = List.filter_map (fun item -> match item with + | `Assoc fields -> (match string_member "uuid" fields, string_member "title" fields, + int_member "journalDay" fields with + | Some uuid, Some title, Some day -> Some ({Model.uuid; title}, day) + | _ -> None) + | _ -> None) (list_member "emptyJournals" fields); journal_outliner_rows = base_rows; outliner_rows = or_route diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index 2f27c962..3426a6d3 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -56,6 +56,9 @@ let outliner_editor_schema () = Lui_extension.event_field "title" StringScalar true; Lui_extension.event_field "caret-utf16-offset" IntScalar true; ]; + Lui_extension.event "paste" + [Lui_extension.event_field "title" StringScalar true; + Lui_extension.event_field "caret-utf16-offset" IntScalar true]; Lui_extension.event "return" [ Lui_extension.event_field "title" StringScalar true; diff --git a/shared/src/logseq/view_base.ml b/shared/src/logseq/view_base.ml index 9abb420e..92f640d2 100644 --- a/shared/src/logseq/view_base.ml +++ b/shared/src/logseq/view_base.ml @@ -68,7 +68,9 @@ let handle_outliner_editor_event input_event block_id_source send = match input_event with | ExtensionEvent (_node, _identifier, name, values) -> let uuid = Signal.sample block_id_source in - if name = "text-change" then + if name = "paste" then + send (Model.PasteOutlinerText (uuid, extension_string values "title", extension_int values "caret-utf16-offset")) + else if name = "text-change" then send (Model.ChangeOutlinerText ( uuid @@ -517,8 +519,7 @@ let outliner_autocomplete_active_ (current : Model.logseq_model) = outliner_editor_active_ current && (match current.outliner_autocomplete with - | Some { autocomplete_kind = (Model.TagAutocomplete | Model.NodeAutocomplete); _ } -> true - | Some _ -> current.outliner_autocomplete_candidates <> [] + | Some _ -> true | None -> false) let outliner_autocomplete_identifier @@ -815,7 +816,7 @@ let node_outliner_visible_ (current : Model.logseq_model) = current.outliner_rows <> [] let node_title_visible_ (current : Model.logseq_model) = - node_outliner_visible_ current + page_content_ready_ current && (not (current_content_is_tag_ current)) && (not (active_node_uuid current <> active_node_page_uuid current)) @@ -1354,7 +1355,7 @@ let sync_error_message (current : Model.logseq_model) = let selected_page_content_title_visible_ (current : Model.logseq_model) = selected_page_present_ current && (not current.selected_page_is_tag) - && node_outliner_visible_ current + && page_content_ready_ current let selected_page_title (current : Model.logseq_model) = match current.selected_page with diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 1c2b258a..d2d00ed9 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -6,8 +6,12 @@ let outliner_editor_view model_source block_id_source title_source caret_source fun context parent -> let node = Lui_ui.extension context "outliner-editor" in attach context parent node; - Lui_ui.extension_property context node "input-policy" - (View_base.string_wire_value Outliner_state.native_input_policy); + Lui_ui.extension_property_signal context node "input-policy" + (Signal.map2 (fun (current : Model.logseq_model) uuid -> + let multiline = List.exists (fun (row : Model.outline_row) -> row.row_uuid = uuid + && List.mem row.row_display_type [Some "code"; Some "math"]) current.outliner_rows in + StringValue (if multiline then {|{"multiline":true,"emptyPairs":[{"text":"[[]]","deleteOffset":1}]}|} + else Outliner_state.native_input_policy)) model_source block_id_source); Lui_ui.extension_property_signal context node "selection-revision" (Signal.map (fun (current : Model.logseq_model) -> IntValue current.outliner_selection_revision) model_source); @@ -167,10 +171,15 @@ let outliner_zoom_control (context : Lui_ui.ui_context) model_source [ row ~width:24 ~height:24 ~main:`center ~cross:`center [ + if_ ~test:(Signal.map (fun (row : Model.outline_row) -> row.row_list_number <> None) row_source) + (text ~value_signal:(Signal.map (fun (row : Model.outline_row) -> + Option.fold ~none:"" ~some:(fun n -> string_of_int n ^ ".") row.row_list_number) row_source) + ~font_size:"14" ~foreground:"muted-foreground" + ~accessibility_identifier:("outliner.number." ^ View_base.outliner_row_uuid current_row) []); View_base.node_icon_view ~size:18 (Signal.map2 (fun current (row : Model.outline_row) -> View_base.node_icon current row.row_uuid) model_source row_source); if_ ~test:(Signal.map2 (fun current (row : Model.outline_row) -> - View_base.node_icon current row.row_uuid = "") model_source row_source) + View_base.node_icon current row.row_uuid = "" && row.row_list_number = None) model_source row_source) (icon ~name_signal:(Signal.map (fun (row : Model.outline_row) -> `app (if row.is_collapsed && row.has_children then "outliner-bullet-closed" else "outliner-bullet")) row_source) ~width:24 ~height:24 ~foreground:"border" @@ -429,6 +438,7 @@ let native_outliner_row model_source retained_source row_source send : t = let row = View_rows.retained_row_value retained in Yojson.Basic.to_string (`Assoc [ "uuid", `String row.row_uuid; "title", `String row.row_title; + "listNumber", (match row.row_list_number with Some n -> `Int n | None -> `Null); "nodeIcon", (let icon = View_base.node_icon current row.row_uuid in if icon = "" then `Null else Yojson.Basic.from_string icon); "markup", (try Yojson.Basic.from_string row.markup_json with _ -> `List []); @@ -767,27 +777,73 @@ let outliner_autocomplete_row (context : Lui_ui.ui_context) (Signal.sample candidate_source : Model.outliner_autocomplete_candidate).candidate_value))) []))] -let outliner_autocomplete_bar (context : Lui_ui.ui_context) model_source - send : t = - scroll ~height:220 ~max_height:220 - ~accessibility_identifier:"toolbar.outliner.autocomplete" - ~style_class:"outliner-autocomplete" - [ - column ~gap:2 ~padding:8 ~min_height:204 ~main:`end_ - ~cross:`stretch - ~grow:0.0 - [ - keyed - ~source: - (Signal.map - View_base.model_outliner_autocomplete_candidates - model_source) - ~key:(fun (candidate : Model.outliner_autocomplete_candidate) -> candidate.candidate_value) - ~cmp:compare - ~mount:(fun candidate_source -> - outliner_autocomplete_row context candidate_source send); - ]; - ] +let outliner_autocomplete_bar (context : Lui_ui.ui_context) model_source send : t = + let kind = function + | Model.CommandAutocomplete -> "command" | NodeAutocomplete -> "node" | TagAutocomplete -> "tag" + | PropertyAutocomplete -> "property" | PickerAutocomplete kind -> kind in + let title = function "command" -> "Commands" | "node" -> "Pages" | "tag" -> "Tags" + | "embed" -> "Embed a node" | "template" -> "Templates" | "link" -> "Link" + | "image" -> "Image link" | "date" -> "Date picker" | "deadline" -> "Deadline" + | "scheduled" -> "Scheduled" | "youtube" -> "YouTube timestamp" | _ -> "Properties" in + let emit event value = ignore (send (Model.EditorCompletion (event, value))) in + let dismiss () = emit "dismissAutocomplete" "" in + let source = Signal.map (fun (current : Model.logseq_model) -> + match current.outliner_autocomplete with Some request -> [kind request.autocomplete_kind] | None -> []) model_source in + keyed ~source ~key:Fun.id ~cmp:String.compare ~mount:(fun source -> + let kind = Signal.sample source in + let form = List.mem kind ["link"; "image"; "date"; "deadline"; "scheduled"; "youtube"] in + if form then (fun context parent -> + let temporal = List.mem kind ["date"; "deadline"; "scheduled"] in + let date_kind = if kind = "date" then "date" else "datetime" in + let value = Signal.state context.Lui_ui.ui_scheduler (if temporal then View_query.temporal_default date_kind else "") in + let label = Signal.state context.ui_scheduler "" in + let input state = function TextChanged (_, value) -> Signal.set state value | _ -> () in + let confirm _ = + let value = Signal.sample (Signal.value value) in + let value = if kind = "link" || kind = "image" then Yojson.Basic.to_string (`Assoc + ["url", `String value; "label", `String (Signal.sample (Signal.value label))]) else value in + emit "submitAutocomplete" value in + let cancel = button ~text:"Cancel" ~variant:`ghost ~style_class:"cancellation-action" + ~accessibility_identifier:"button.outliner.autocomplete.dismiss" ~on_press:(fun _ -> dismiss ()) [] in + let done_ = button ~text:"Insert" ~variant:`ghost ~style_class:"confirmation-action" + ~accessibility_identifier:"button.outliner.autocomplete.submit" ~on_press:confirm [] in + (sheet ~text:(title kind) ~detents:"medium,large" ~style_class:"navigation-list navigation-inline-title" + ~accessibility_identifier:"sheet.outliner.autocomplete" ~on_dismiss:(fun _ -> dismiss ()) [ + list ~grow:1.0 ~style:`inset_grouped [list_section ( + if temporal then [list_item ~padding_horizontal:0 ~padding_vertical:0 [ + View_query.temporal_picker date_kind "editor-command" (Signal.value value) (Signal.set value)]] + else [list_item [text_field ~text_signal:(Signal.value value) ~autofocus:true + ~placeholder:(if kind = "youtube" then "1:32" else "https://example.com") + ~label:(if kind = "youtube" then "Timestamp" else "URL") + ~accessibility_identifier:"field.outliner.autocomplete.value" ~on_input:(input value) []]] + @ (if kind = "link" || kind = "image" then [list_item [text_field ~text_signal:(Signal.value label) + ~placeholder:"Label (optional)" ~label:"Label" ~accessibility_identifier:"field.outliner.autocomplete.label" + ~on_input:(input label) []]] else []))]; + (if Lui_ui.host context = KotlinHost then row ~main:`space_between ~padding:12 [cancel; done_] + else toolbar ~label:"Insert content" ~style_class:"navigation-actions" [cancel; done_]); + ]) context parent) + else + column ~gap:2 ~padding:8 ~height:220 ~max_height:220 ~cross:`stretch ~main:`start ~accessibility_identifier:"toolbar.outliner.autocomplete" [ + row ~grow:0.0 ~main:`space_between ~cross:`center [ + text ~value:(title kind) ~font_size:"13" ~foreground:"muted-foreground" ~padding_horizontal:8 []; + button ~text:"Close" ~variant:`ghost ~height:36 ~label:"Close suggestions" + ~accessibility_identifier:"button.outliner.autocomplete.dismiss" ~on_press:(fun _ -> dismiss ()) []]; + (if kind = "embed" || kind = "template" then + search_field ~placeholder:"Search" ~label:(title kind) ~autofocus:true + ~text_signal:(Signal.map (fun (current : Model.logseq_model) -> + Option.fold ~none:"" ~some:(fun request -> request.Model.autocomplete_query) current.outliner_autocomplete) model_source) + ~accessibility_identifier:"field.outliner.autocomplete.search" + ~on_input:(function TextChanged (_, value) -> emit "searchAutocomplete" value | _ -> ()) [] + else box []); + scroll ~height:(if kind = "embed" || kind = "template" then 128 else 164) ~max_height:164 ~style_class:"outliner-autocomplete" [ + column ~gap:2 ~cross:`stretch [ + keyed ~source:(Signal.map View_base.model_outliner_autocomplete_candidates model_source) + ~key:(fun (c : Model.outliner_autocomplete_candidate) -> c.candidate_value) ~cmp:String.compare + ~mount:(fun source -> outliner_autocomplete_row context source send); + if_ ~test:(Signal.map (fun (current : Model.logseq_model) -> current.outliner_autocomplete_candidates = []) model_source) + (text ~value:"No results" ~foreground:"muted-foreground" ~padding:16 []); + ]]; + ]) let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send : t = @@ -975,19 +1031,33 @@ let page_loading_view model_source : t = [ spinner ~width:16 ~height:16 []; text ~foreground:"muted-foreground" ~value:"Loading blocks…" [] ]) -let add_first_block_button model_source send : t = - box ~padding_horizontal:8 - [ - button ~label:"Add first block" - ~accessibility_identifier:"button.outliner.add-first-block" - ~on_press:(fun _ -> - ignore - (send - (Model.AddRootBlock - (View_base.active_node_page_uuid - (Signal.sample model_source))))) - ~text:"Add first block" []; - ] +let placeholder_block context (value : View_rows.placeholder) send : t = + let placeholder : t = fun mount_context parent -> + let node = box ~height:44 ~padding_horizontal:8 + ~accessibility_identifier:("button.outliner.placeholder." ^ value.parent_uuid) + [row ~cross:`center ~gap:0 + [text ~width:24 ~foreground:"muted-foreground" ~value:"•" []; + box ~grow:1.0 []]] mount_context parent in + Lui_ui.string_property mount_context node AccessibilityLabel "Add block"; + Lui_ui.bool_property mount_context node PressEnabled true; + register_press mount_context node (fun _ -> ignore (send (Model.AddRootBlock value.parent_uuid))); + node in + match value.journal_heading with + | None -> box ~padding_horizontal:8 [placeholder] + | Some (title, _, divider) -> + column ~padding_horizontal:8 ~gap:0 [ + (if divider then column ~gap:0 [box ~height:26 []; separator ~accessibility_identifier:"journal.divider" []; box ~height:26 []] + else box []); + (fun mount_context parent -> + let node = View_base.with_label_signal + (Signal.constant mount_context.Lui_ui.ui_scheduler ("Open " ^ title)) + (box ~accessibility_identifier:("button.journal." ^ value.parent_uuid) + ~padding_horizontal:(if Lui_ui.host context = KotlinHost then 12 else 8) + [heading ~level:3 ~style_class:"scroll-section-title" ~value:title []]) mount_context parent in + Lui_ui.bool_property mount_context node PressEnabled true; + register_press mount_context node (fun _ -> ignore (send (Model.RequestAppNode value.parent_uuid))); + node); + box ~height:12 []; placeholder] let outliner_entries context model_source ~source send : t = fun mount_context parent -> @@ -996,11 +1066,7 @@ let outliner_entries context model_source ~source send : t = let first_section = Signal.own_signal mount_context.ui_scope (Signal.map View_rows.first_journal_section_count model_source) in let section_titles = Signal.own_signal mount_context.ui_scope - (Signal.map (fun (current : Model.logseq_model) -> - if View_base.journal_root_visible_ current && current.selected_page = None then - List.map (fun (marker : Model.journal_section_marker) -> - marker.start_index, marker.section_title) current.outliner_section_markers - else []) model_source) in + (Signal.map View_rows.journal_section_titles model_source) in let ensure_content () = let requested = ref [] in fun entries -> @@ -1023,6 +1089,7 @@ let outliner_entries context model_source ~source send : t = let retained = Signal.map View_rows.page_entry_outline entry_source in outliner_entry context model_source retained (Signal.map View_rows.retained_row_value retained) send + | View_rows.Placeholder_entry value -> placeholder_block context value send | View_rows.Reference_entry _ -> reference_entry context model_source (Signal.map View_rows.page_entry_reference entry_source) send in @@ -1065,8 +1132,6 @@ let node_screen (context : Lui_ui.ui_context) model_source send : t = (node_properties context model_source send); outliner_entries context model_source ~source:(reactive View_rows.retained_outliner_rows model_source) send; - if_ ~test:(reactive View_base.node_can_add_first_block_ model_source) - (box ~padding_horizontal:8 [ add_first_block_button model_source send ]); box ~height:120 []; ]); ]) diff --git a/shared/src/logseq/view_outliner.mli b/shared/src/logseq/view_outliner.mli index 70522aec..a26e47c8 100644 --- a/shared/src/logseq/view_outliner.mli +++ b/shared/src/logseq/view_outliner.mli @@ -99,9 +99,7 @@ val node_related_row : (Model.logseq_action -> bool) -> Lui_elements.t val related_section_heading : string -> Lui_elements.t val page_loading_view : Model.logseq_model Signal.signal -> Lui_elements.t -val add_first_block_button : - Model.logseq_model Signal.signal -> - (Model.logseq_action -> 'a) -> Lui_elements.t + val node_screen : Lui_ui.ui_context -> Model.logseq_model Signal.signal -> diff --git a/shared/src/logseq/view_query.mli b/shared/src/logseq/view_query.mli index d5c383b1..43bed916 100644 --- a/shared/src/logseq/view_query.mli +++ b/shared/src/logseq/view_query.mli @@ -12,3 +12,6 @@ val block_values : render_block:(Model.outline_row Signal.signal -> (Model.logse string -> Yojson.Basic.t Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val action_error : Model.logseq_model Signal.signal -> string -> Lui_elements.t + +val temporal_picker : string -> string -> string Signal.signal -> (string -> unit) -> Lui_elements.t +val temporal_default : string -> string diff --git a/shared/src/logseq/view_rows.ml b/shared/src/logseq/view_rows.ml index 2e4bea26..7b135816 100644 --- a/shared/src/logseq/view_rows.ml +++ b/shared/src/logseq/view_rows.ml @@ -187,15 +187,67 @@ let reference_entry_row = function | _ -> invalid_arg "reference_entry_row requires a block entry" +type placeholder = { parent_uuid : string; journal_heading : (string * int * bool) option } type page_entry = Outline_entry of retained_row | Reference_entry of reference_entry + | Placeholder_entry of placeholder + +let needs_placeholder rows = match List.rev rows with + | [] -> true + | last :: _ -> + let title = if last.is_editing then last.editing_title else last.value.row_title in + not (last.value.row_loaded && String_kit.is_blank title && not last.value.has_children) + +let outline_entries rows = List.map (fun row -> Outline_entry row) rows + +let journal_entries rows (current : Model.logseq_model) = + let groups = Hashtbl.create 16 and sequence = ref [] in + List.iter (fun row -> + let page = row.value.Model.page_id in + if not (Hashtbl.mem groups page) then sequence := page :: !sequence; + let previous = Option.value ~default:[] (Hashtbl.find_opt groups page) in + Hashtbl.replace groups page (row :: previous)) rows; + let sections = List.rev_map (fun page -> + let rows = List.rev (Hashtbl.find groups page) in + let first = (List.hd rows).value in + page, first.journal_title, first.journal_day, rows) !sequence in + let rec insert_empty entry = function + | [] -> [entry] + | ((_, _, day, _) as first) :: rest as sections -> + let _, _, incoming, _ = entry in + if incoming > day then entry :: sections else first :: insert_empty entry rest in + let sections = List.fold_left (fun sections ((page : Model.sidebar_page), day) -> + if Hashtbl.mem groups page.uuid then sections + else insert_empty (page.uuid, Some page.title, Some day, []) sections) sections current.empty_journals in + let entries = List.mapi (fun index (page, title, day, rows) -> + let outline = outline_entries rows in + match title, day with + | Some title, Some day when needs_placeholder rows -> + outline @ [Placeholder_entry {parent_uuid = page; + journal_heading = if rows = [] then Some (title, day, index > 0) else None}] + | _ -> outline) sections in + List.rev (List.fold_left (fun acc section -> List.rev_append section acc) [] entries) let page_entries rows current = - List.rev_append (List.rev_map (fun row -> Outline_entry row) rows) + let outline = + if View_base.journal_home_visible_ current then journal_entries rows current + else + let outline = outline_entries rows in + let parent = match View_base.active_node_projection current with + | Some route -> route.node_uuid | None -> View_base.active_node_page_uuid current in + let allowed = View_base.current_content_active_ current + && View_base.page_content_ready_ current && parent <> "" + && (match Model.active_page current with + | Some page -> String.lowercase_ascii page.title <> "library" | None -> true) in + if allowed && needs_placeholder rows then + outline @ [Placeholder_entry {parent_uuid = parent; journal_heading = None}] + else outline in + List.rev_append (List.rev outline) (List.map (fun row -> Reference_entry row) (reference_entries current)) let page_entry_identifier = function | Outline_entry row -> "outline." ^ retained_row_identifier row | Reference_entry row -> "reference." ^ reference_entry_identifier row + | Placeholder_entry value -> "placeholder." ^ value.parent_uuid let page_entry_outline = function | Outline_entry row -> row @@ -205,10 +257,25 @@ let page_entry_reference = function | Reference_entry row -> row | _ -> invalid_arg "page_entry_reference requires a reference entry" +let journal_section_titles current = + if not (View_base.journal_home_visible_ current) then [] else + let previous = ref None in + page_entries (retained_outliner_rows current) current + |> List.mapi (fun index entry -> + let section = match entry with + | Outline_entry row -> Option.map (fun title -> row.value.page_id, title) row.value.journal_title + | Placeholder_entry {parent_uuid; journal_heading = Some (title, _, _)} -> Some (parent_uuid, title) + | _ -> None in + match section with + | Some (page, title) when !previous <> Some page -> previous := Some page; Some (index, title) + | _ -> None) + |> List.filter_map Fun.id + let first_journal_section_count current = - if first_journal_section_visible_ current then - (List.hd current.Model.outliner_section_markers).end_index - else 0 + match journal_section_titles current with + | [] -> 0 + | [_] -> List.length (page_entries (retained_outliner_rows current) current) + | _ :: (index, _) :: _ -> index let unloaded_entry_id = function | Outline_entry row when not row.value.Model.row_loaded -> Some row.value.row_uuid diff --git a/shared/src/logseq/view_rows.mli b/shared/src/logseq/view_rows.mli index 6124dd4a..19ffa636 100644 --- a/shared/src/logseq/view_rows.mli +++ b/shared/src/logseq/view_rows.mli @@ -33,7 +33,9 @@ val reference_entries : Model.logseq_model -> reference_entry list val reference_entry_identifier : reference_entry -> string val reference_entry_row : reference_entry -> Model.outline_row +type placeholder = { parent_uuid : string; journal_heading : (string * int * bool) option } type page_entry = Outline_entry of retained_row | Reference_entry of reference_entry + | Placeholder_entry of placeholder val page_entries : retained_row list -> Model.logseq_model -> page_entry list val page_entry_identifier : page_entry -> string val page_entry_outline : page_entry -> retained_row @@ -46,3 +48,5 @@ val unloaded_entry_id : page_entry -> string option val page_entry_ready : page_entry -> bool val page_entry_editor_ : page_entry -> bool + +val journal_section_titles : Model.logseq_model -> (int * string) list diff --git a/shared/src/logseq/view_screens.ml b/shared/src/logseq/view_screens.ml index 944a9547..3270a278 100644 --- a/shared/src/logseq/view_screens.ml +++ b/shared/src/logseq/view_screens.ml @@ -311,11 +311,6 @@ let root_outliner_view (context : Lui_ui.ui_context) model_source visible_source []); ]; View_outliner.page_loading_view model_source; - if_ - ~test:(Signal.map View_base.main_can_add_first_block_ - model_source) - (box ~padding_horizontal:8 - [ View_outliner.add_first_block_button model_source send ]); box ~height:120 []; ])); ]) diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index fb7c46df..fd8dd14d 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -354,6 +354,7 @@ let empty_core_projection () = search_has_more = false; search_results = []; node_routes = []; + empty_journals = []; journal_outliner_rows = []; projection_outliner_editing = None; projection_outliner_autocomplete = None; @@ -425,6 +426,8 @@ let journal_outline_row uuid page_id title journal_title journal_day depth = { Model.row_uuid = uuid; row_loaded = true; + row_list_number = None; + row_display_type = None; row_title = title; markup_json = "[]"; query_json = ""; youtube_target_url = None; @@ -2035,7 +2038,7 @@ let authentication_screen_uses_the_product_name_on_every_host () = let outliner_editor_extension_contract_is_pinned () = check_eq ~msg:"the native editor registry must match the LG wire schema" (Lui_extension.fingerprint (View.outliner_editor_schema ())) - "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" let outliner_block_content_extension_contract_is_pinned () = check_eq ~msg:"the rich block renderer must match the LG wire schema" @@ -2759,6 +2762,8 @@ let outline_row uuid title = { Model.row_uuid = uuid; row_loaded = true; + row_list_number = None; + row_display_type = None; row_title = title; markup_json = "[]"; query_json = ""; youtube_target_url = None; @@ -3189,7 +3194,7 @@ let selected_sidebar_pages_render_their_outliner_and_related_content () = in let add_first = descendant_with_identifier renderer outliner - "button.outliner.add-first-block" + "button.outliner.placeholder.page-a" in let content = descendant_with_identifier renderer outliner "outliner.block.content" @@ -3220,8 +3225,8 @@ let selected_sidebar_pages_render_their_outliner_and_related_content () = ~msg:"selected page titles preserve the combined main horizontal inset" (property_int renderer page_title_layout Lui_protocol.PaddingHorizontal) 16; - check ~msg:"non-empty selected pages hide the add-first-block action" - (add_first = -1) + check ~msg:"non-empty selected pages retain a trailing placeholder" + (add_first <> -1) let flashcard_presentation_and_review_state_are_owned_by_lg () = let card = @@ -6862,7 +6867,7 @@ let empty_node_routes_add_the_first_block_through_the_core () = in let add_button = descendant_with_identifier renderer screen - "button.outliner.add-first-block" + "button.outliner.placeholder.page-a" in dispatch application (Lui_protocol.Press add_button); flush application; @@ -7323,7 +7328,7 @@ let journal_home_keeps_first_day_viewport_and_later_block_items_flat () = let metadata = Lui_protocol.data_attrs_decode (property_string renderer first_section Lui_protocol.DataAttrs) in check_eq ~msg:"the native collection fills the viewport after the first journal" - (List.assoc "data-first-section-count" metadata) "1"; + (List.assoc "data-first-section-count" metadata) "2"; check_eq ~msg:"viewport retention leaves room for native chrome" (List.assoc "data-first-section-inset" metadata) "136"; check_eq ~msg:"later journal blocks use their intrinsic height" @@ -7437,9 +7442,9 @@ let journal_rows_stay_flat_while_retaining_the_first_viewport () = let metadata = Lui_protocol.data_attrs_decode (property_string renderer first_section Lui_protocol.DataAttrs) in check_eq ~msg:"the first journal's two rows determine its viewport fill boundary" - (List.assoc "data-first-section-count" metadata) "2"; + (List.assoc "data-first-section-count" metadata) "3"; check_eq ~msg:"journal dates remain available outside the mounted block window" - (List.assoc_opt "data-section-title-0" metadata, List.assoc_opt "data-section-title-2" metadata) + (List.assoc_opt "data-section-title-0" metadata, List.assoc_opt "data-section-title-3" metadata) (Some "August 27th", Some "August 28th"); check ~msg:"all journal rows use the same bounded source" (List.for_all (fun entry -> @@ -8398,17 +8403,17 @@ let selected_page_waits_for_blocks_before_showing_empty_state () = flush application; let renderer = Lui_app.runtime application in let root = main_root renderer application in - check_eq ~msg:"unresolved pages never pretend to be empty" - (descendant_with_identifier renderer root "button.outliner.add-first-block") (-1); + check_eq ~msg:"only loaded cached pages offer a trailing placeholder" + (descendant_with_identifier renderer root "button.outliner.placeholder.page-a" <> -1) cached; check_eq ~msg:"only uncached pages show their loading state" (descendant_with_identifier renderer root "status.page-loading" <> -1) (not cached); let selected = { sidebar with Model.selected_page = Some page } in send application (apply_core_snapshot None selected [] false "" [] [] None None [] [] [] false []); flush application; - check ~msg:"confirmed empty pages expose Add first block" + check ~msg:"confirmed empty pages expose an editable placeholder" (descendant_with_identifier renderer (main_root renderer application) - "button.outliner.add-first-block" <> -1); + "button.outliner.placeholder.page-a" <> -1); check_eq ~msg:"the authoritative empty response finishes loading" (descendant_with_identifier renderer (main_root renderer application) "status.page-loading") (-1) @@ -9287,9 +9292,7 @@ let outliner_editor_toolbar_and_autocomplete_use_core_owned_state () = descendant_extension_containing_identifier renderer application chrome "liquid-glass" "toolbar.outliner.autocomplete" in - let autocomplete_column = - List.nth (children renderer autocomplete_bar) 0 - in + let autocomplete_column = autocomplete_bar in let candidate_button = descendant_with_identifier renderer autocomplete_column "button.outliner.autocomplete.0" @@ -9317,9 +9320,9 @@ let outliner_editor_toolbar_and_autocomplete_use_core_owned_state () = (property_string renderer candidate_button Lui_protocol.AccessibilityIdentifier) "button.outliner.autocomplete.0"; - check_eq ~msg:"autocomplete uses main's vertically scrolling surface" + check_eq ~msg:"autocomplete provides a shared header above its scrolling candidates" (node renderer autocomplete_bar) - (Some Lui_protocol.Scroll); + (Some Lui_protocol.Column); check_eq ~msg:"autocomplete keeps main's maximum height" (property_int renderer autocomplete_bar Lui_protocol.MaxHeight) 220; @@ -9466,9 +9469,7 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = descendant_with_identifier renderer editor_container "toolbar.outliner.autocomplete" in - let autocomplete_column = - List.nth (children renderer autocomplete_bar) 0 - in + let autocomplete_column = autocomplete_bar in let candidate_button = descendant_with_identifier renderer autocomplete_column "button.outliner.autocomplete.0" @@ -9484,7 +9485,7 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = "toolbar.outliner.editor" <> -1); check ~msg:"the autocomplete surface shares the visible vertical editor container" - (child_with_identifier renderer editor_container + (descendant_with_identifier renderer editor_container "toolbar.outliner.autocomplete" <> -1); check_eq @@ -9492,8 +9493,8 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = (property_string renderer autocomplete_column Lui_protocol.CrossAlignment) "stretch"; - check_eq ~msg:"short result lists align candidates with the bottom toolbar" - (property_string renderer autocomplete_column Lui_protocol.MainAlignment) "end"; + check_eq ~msg:"suggestions start below their shared header" + (property_string renderer autocomplete_column Lui_protocol.MainAlignment) "start"; check_eq ~msg:"candidate buttons fill their bounded horizontal row" (property_float renderer candidate_button Lui_protocol.GrowValue) 1.0; check_eq diff --git a/shared/test/logseq/drive/empty-node-add-block.drive b/shared/test/logseq/drive/empty-node-add-block.drive index 7acd6963..042d0609 100644 --- a/shared/test/logseq/drive/empty-node-add-block.drive +++ b/shared/test/logseq/drive/empty-node-add-block.drive @@ -13,6 +13,6 @@ ext ext:native-search-presentation native-search-presentation query-changed '{"q wait prop:accessibility-identifier=search.result.page-1 press prop:accessibility-identifier=search.result.page-1 wait prop:accessibility-identifier=screen.node -wait prop:accessibility-identifier=button.outliner.add-first-block -press prop:accessibility-identifier=button.outliner.add-first-block +wait prop:accessibility-identifier=button.outliner.placeholder.page-1 +press prop:accessibility-identifier=button.outliner.placeholder.page-1 wait text:"New block" diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml new file mode 100644 index 00000000..7afa9d13 --- /dev/null +++ b/shared/test/logseq/editor_command_test.ml @@ -0,0 +1,385 @@ +open Test_util +module Ds = Datascript +module J = Yojson.Basic +module Q = Query_block_test +module R = Result_view_test +let member = Q.member +let items = Q.items +let text = R.text +let put = Q.put +let result response = + check_eq ~msg:(J.to_string response) (member "ok" response) (`Bool true); + member "result" response +let event session kind fields = result (Rpc_test.dispatch_json session "outlinerEvent" + (J.to_string (`Assoc (("type", `String kind) :: fields)))) +let snapshot session = result (J.from_string (Rpc_session.snapshot_visible session)) +let state response = member "outlinerState" response +let draft response = member "editing" (state response) +let choices response = items "outlinerAutocompleteCandidates" response +let type_at session title caret = event session "textChanged" + ["title", `String title; "caretUTF16Offset", `Int caret] +let start session title = + ignore (event session "tapBlock" ["uuid", `String Q.alpha]); + type_at session title (Outliner_state.utf16_length title) +let choose session value = event session "chooseAutocomplete" ["value", `String value] +let command session label = + let response = snapshot session in + match List.find_opt (fun row -> member "label" row = `String label) (choices response) with + | Some row -> choose session (text (member "value" row)) + | None -> fail ("Missing command: " ^ label ^ " in " ^ J.to_string (`List (choices response))) +let run session label = ignore (start session "/"); command session label +let value runtime uuid attr = + let db = Graph_runtime.db runtime in + Option.bind (Ds.entid db "block/uuid" (Ds.Uuid uuid)) (fun eid -> Graph_read.value db eid attr) +let save session = ignore (event session "saveEditing" []) +let submit session value = event session "submitAutocomplete" ["value", `String value] +let rebase runtime conn tx = + ignore (Ds.transact_conn conn tx); + Graph_runtime.rebase runtime (Graph_runtime.state runtime).server_t [] [] + +let pasted_markers_do_not_open_completion () = R.with_runtime (fun session _ _ -> + List.iter (fun title -> + ignore (event session "tapBlock" ["uuid", `String Q.alpha]); + let response = event session "textChanged" ["title", `String title; + "caretUTF16Offset", `Int (Outliner_state.utf16_length title); "inputOrigin", `String "paste"] in + check_eq ~msg:("paste stays literal: " ^ title) (member "autocomplete" (state response)) `Null; + let response = event session "caretMoved" ["caretUTF16Offset", `Int (Outliner_state.utf16_length title)] in + check_eq ~msg:"selection changes do not reopen pasted syntax" (member "autocomplete" (state response)) `Null; + let response = type_at session (title ^ "x") (Outliner_state.utf16_length title + 1) in + check_eq ~msg:"typing ordinary text after a paste does not activate pasted syntax" + (member "autocomplete" (state response)) `Null; + let typed = title ^ "x /" in + let response = type_at session typed (Outliner_state.utf16_length typed) in + check_eq ~msg:"a freshly typed trigger starts a new completion" + (member "kind" (member "autocomplete" (state response))) (`String "command")) + ["Pasted text /"; "🙂 /"; "Pasted [["; "Pasted #"; "/Heading 1"]) + +let catalog_search_and_literal_boundaries () = R.with_runtime (fun session _ _ -> + let response = start session "/" in + check_eq ~msg:"slash uses the shared completion state" + (member "kind" (member "autocomplete" (state response))) (`String "command"); + let labels = List.map (fun row -> text (member "label" row)) (choices response) in + List.iter (fun label -> check ~msg:("built-in command missing: " ^ label) (List.mem label labels)) + ["Node embed"; "Link"; "Image link"; "Underline"; "Code block"; "Quote"; "Math block"; + "Normal text"; "Clear heading"; "Heading 1"; "Heading 6"; "Deadline"; "Scheduled"; + "Tomorrow"; "Yesterday"; "Today"; "Current time"; "Date picker"; "Number list"; + "Number children"; "Calculator"; "Template"; "Embed HTML"; "Embed Video URL"; + "Embed YouTube timestamp"; "Embed Twitter tweet"; "Cloze"; "No priority"]; + List.iter (fun label -> check ~msg:("excluded command: " ^ label) (not (List.mem label labels))) + ["Query"; "Advanced Query"; "Query function"; "Add property"; "Add comment"; + "Upload an asset"; "Node reference"]; + let response = start session "/h6" in + check ~msg:"command search is fuzzy" (List.exists (fun row -> member "label" row = `String "Heading 6") (choices response)); + List.iter (fun title -> let response = start session title in + check_eq ~msg:("literal marker: " ^ title) (member "autocomplete" (state response)) `Null) + ["https://example.com/a"; "a/b"; "`/head`"; "```\n/head"; "\\/head"; "{{video /head}}"]; + ignore (start session "Keep /head"); + let response = event session "dismissAutocomplete" [] in + check_eq (member "title" (draft response)) (`String "Keep /head"); + check_eq (member "autocomplete" (state response)) `Null; + ignore (choose session "command:heading-1"); + check_eq (member "title" (draft (snapshot session))) (`String "Keep /head")) + +let inline_commands_preserve_caret_and_suffix () = + List.iter (fun (label, inserted, backward) -> R.with_runtime (fun session _ _ -> + ignore (start session "🙂 中文 / suffix"); + ignore (type_at session "🙂 中文 / suffix" 7); + let response = command session label in + check_eq (member "title" (draft response)) (`String ("🙂 中文 " ^ inserted ^ " suffix")); + check_eq (member "caretUTF16Offset" (draft response)) + (`Int (6 + Outliner_state.utf16_length inserted - backward)); + check_eq (member "autocomplete" (state response)) `Null)) + ["Underline", "", 6; "Embed HTML", "@@html: @@", 2; + "Embed Video URL", "{{video }}", 2; "Embed Twitter tweet", "{{tweet }}", 2; + "Cloze", "{{cloze }}", 2] + +let graph_choices_headings_and_block_types () = R.with_runtime (fun session runtime conn -> + let custom = "30000000-0000-4000-8000-000000000001" in + rebase runtime conn (R.entity 100 custom "Critical" @ [ + put custom "block/closed-value-property" (Ds.Ref_to (Ds.Ident "logseq.property/priority")); + put custom "block/order" (Ds.String "a0")]); + ignore (run session "Critical"); save session; + check ~msg:"graph priorities are selectable" (value runtime Q.alpha "logseq.property/priority" <> None); + ignore (run session "No priority"); save session; + check_eq (value runtime Q.alpha "logseq.property/priority") None; + List.iter (fun n -> ignore (run session ("Heading " ^ string_of_int n)); save session; + check_eq (value runtime Q.alpha "logseq.property/heading") (Some (Ds.Int64 (Int64.of_int n)))) [1;2;3;4;5;6]; + ignore (run session "Clear heading"); save session; + check_eq (value runtime Q.alpha "logseq.property/heading") None; + List.iter (fun (label, typ) -> let response = run session label in + let uuid = text (member "uuid" (draft response)) in + save session; + check_eq (value runtime uuid "logseq.property.node/display-type") (Some (Ds.Keyword typ)); + if label = "Calculator" then check_eq (value runtime uuid "logseq.property.code/lang") (Some (Ds.String "calc"))) + ["Quote", "quote"; "Math block", "math"; "Code block", "code"; "Calculator", "code"]; + ignore (run session "Normal text"); save session; + check_eq (value runtime Q.alpha "logseq.property/heading") None; + ignore (run session "Todo"); save session; + check ~msg:"task statuses are graph choices" (value runtime Q.alpha "logseq.property/status" <> None); + List.iter (fun operation -> match Graph_runtime.prepare_sync runtime operation with + | Ok _ -> () | Error message -> fail message) (Graph_runtime.pending_operations runtime)) + +let links_and_dates_use_shared_prompts () = R.with_runtime (fun session runtime _ -> + ignore (run session "Link"); + let response = submit session {|{"url":"https://logseq.com/a b","label":"Logseq"}|} in + check_eq (member "title" (draft response)) (`String "[Logseq](https://logseq.com/a%20b)"); save session; + ignore (run session "Image link"); + let response = submit session {|{"url":"https://logseq.com/pic.png","label":"Picture"}|} in + check_eq (member "title" (draft response)) (`String "![Picture](https://logseq.com/pic.png)"); + ignore (run session "Date picker"); + let response = submit session "2026-10-23" in + check ~msg:"date selection inserts a journal reference" (String_kit.starts_with ~prefix:"[[" (text (member "title" (draft response)))); + List.iter (fun label -> ignore (run session label); let response = snapshot session in + check ~msg:label (String.length (text (member "title" (draft response))) > 0)) ["Today"; "Yesterday"; "Tomorrow"; "Current time"]; + List.iter (fun (label, attr) -> ignore (run session label); + ignore (submit session "1792719000000"); save session; + let prop = Node_property_test.property attr (Node_property_test.result (Node_property_test.read session Q.alpha)) in + check_eq (member "editorValue" prop) (`String "1792719000000")) + ["Deadline", "logseq.property/deadline"; "Scheduled", "logseq.property/scheduled"]; + ignore (run session "Embed YouTube timestamp"); + let response = submit session "01:32" in + check_eq (member "title" (draft response)) (`String "{{youtube-timestamp 92}}"); + ignore (run session "Link"); + let before = List.length (Graph_runtime.pending_operations runtime) in + let bad = Rpc_test.dispatch_json session "outlinerEvent" {|{"type":"submitAutocomplete","value":"{\"url\":\"javascript:alert(1)\"}"}|} in + check_eq (member "ok" bad) (`Bool false); + check_eq (List.length (Graph_runtime.pending_operations runtime)) before; + check ~msg:"invalid input leaves the prompt open" (member "autocomplete" (state (snapshot session)) <> `Null)) + +let embeds_delete_only_the_wrapper () = R.with_runtime (fun session runtime conn -> + let child = "30000000-0000-4000-8000-000000000002" in + rebase runtime conn (R.entity 101 child "Source child" @ [ + put child "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put child "block/parent" (Ds.Ref_to (Q.ref_uuid Q.beta)); put child "block/order" (Ds.String "a0")]); + ignore (run session "Node embed"); + let response = event session "searchAutocomplete" ["query", `String "Beta"] in + check ~msg:"embed search uses shared node candidates" (List.exists (fun row -> member "value" row = `String Q.beta) (choices response)); + ignore (choose session Q.beta); save session; + let db = Graph_runtime.db runtime in + let target = Option.get (Ds.entid db "block/uuid" (Ds.Uuid Q.beta)) in + check_eq (value runtime Q.alpha "block/link") (Some (Ds.Ref target)); + ignore (event session "longPressBlock" ["uuid", `String Q.alpha]); + ignore (event session "toolbar" ["action", `String "delete"]); + ignore (event session "confirmDelete" []); + check_eq (value runtime Q.alpha "block/title") None; + check_eq (value runtime Q.beta "block/title") (Some (Ds.String "Beta result")); + check_eq (value runtime child "block/title") (Some (Ds.String "Source child")); + List.iter (fun operation -> match Graph_runtime.prepare_sync runtime operation with + | Ok _ -> () | Error message -> fail message) (Graph_runtime.pending_operations runtime)) + +let templates_clone_without_mutating_the_source () = R.with_runtime (fun session runtime conn -> + let child = "30000000-0000-4000-8000-000000000004" in + let grandchild = "30000000-0000-4000-8000-000000000005" in + rebase runtime conn ([put Q.beta "block/tags" (Ds.Ref_to (Ds.Ident "logseq.class/Template"))] + @ R.entity 103 child "Meeting <%current page%>" + @ [put child "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put child "block/parent" (Ds.Ref_to (Q.ref_uuid Q.beta)); put child "block/order" (Ds.String "a0"); + put child "logseq.property/heading" (Ds.Int64 2L)] + @ R.entity 104 grandchild "Nested note" + @ [put grandchild "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put grandchild "block/parent" (Ds.Ref_to (Q.ref_uuid child)); put grandchild "block/order" (Ds.String "a0")]); + ignore (run session "Template"); + ignore (event session "searchAutocomplete" ["query", `String "Beta"]); + ignore (choose session Q.beta); save session; + let inserted = value runtime Q.alpha "block/title" in + check ~msg:"templates insert their children and resolve target-page variables" + (match inserted with Some (Ds.String title) -> String_kit.starts_with ~prefix:"Meeting [[" title | _ -> false); + check_eq (value runtime Q.alpha "logseq.property/heading") (Some (Ds.Int64 2L)); + let children = Graph_runtime.blocks_for_page runtime Q.page |> List.filter (fun (b : Cache_model.block) -> + b.parent_id = Some Q.alpha) in + check ~msg:"nested template children have new identities" (List.exists (fun (b : Cache_model.block) -> + b.title = "Nested note" && b.uuid <> grandchild) children); + check_eq (value runtime Q.beta "block/title") (Some (Ds.String "Beta result")); + check_eq ~msg:"template application does not create a live link" (value runtime Q.alpha "block/link") None) + +let command_output_has_native_presentation () = R.with_runtime (fun session runtime _ -> + let block () = List.find (fun block -> member "uuid" block = `String Q.alpha) (items "blocks" (snapshot session)) in + ignore (run session "Heading 2"); save session; + check ~msg:"heading formatting reaches the renderer" + (String_kit.includes ~sub:"heading-2" (J.to_string (member "markup" (block ())))); + ignore (run session "Number list"); save session; + check_eq ~msg:"ordered list numbers reach the native bullet slot" (member "listNumber" (block ())) (`Int 1); + ignore (run session "Calculator"); + ignore (type_at session "2 + 3 * 4" 9); save session; + let markup = J.to_string (member "markup" (block ())) in + check ~msg:"calculator blocks show their evaluated result" (String_kit.includes ~sub:"14" markup); + check ~msg:"calculator blocks retain their language" (String_kit.includes ~sub:"calc" markup); + ignore (run session "Node embed"); + ignore (choose session Q.beta); + let markup = J.to_string (member "markup" (block ())) in + check ~msg:"embedded nodes render their source content" (String_kit.includes ~sub:"Beta result" markup); + check_eq ~msg:"rendered embed identity stays the wrapper UUID" (member "uuid" (block ())) (`String Q.alpha)) + +let embedding_in_text_preserves_the_original_block () = R.with_runtime (fun session runtime _ -> + ignore (start session "Keep this /"); + ignore (command session "Node embed"); + ignore (choose session Q.beta); + let db = Graph_runtime.db runtime in + check_eq (value runtime Q.alpha "block/title") (Some (Ds.String "Keep this ")); + check_eq (value runtime Q.alpha "block/link") None; + check ~msg:"a nonempty target gets a separate embedded sibling" (Seq.exists (fun (d : Ds.datom) -> + Graph_read.ref_eids db d.e "block/link" <> []) (Ds.Db.datoms db Ds.Aevt ~a:"block/link" ()))) + +let html_images_and_underline_render_as_content () = + let parsed source = Markup.parse [] [] source |> Markup.to_yojson |> J.to_string in + List.iter (fun (source, expected) -> check ~msg:(source ^ " renders its content") + (String_kit.includes ~sub:expected (parsed source))) [ + "@@html: Hello@@", "\"type\":\"html\""; + "![Picture](https://example.com/a.png)", "\"type\":\"image\""; + "Before underlined after", "\"style\":\"underline\"" ] + +let numbered_lists_toggle_without_changing_source_text () = R.with_runtime (fun session runtime conn -> + let child = "30000000-0000-4000-8000-000000000003" in + rebase runtime conn (R.entity 102 child "Numbered child" @ [ + put child "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put child "block/parent" (Ds.Ref_to (Q.ref_uuid Q.alpha)); put child "block/order" (Ds.String "a0")]); + List.iter (fun (label, uuid) -> + ignore (run session label); save session; + check ~msg:label (value runtime uuid "logseq.property/order-list-type" <> None); + ignore (run session label); save session; + check_eq ~msg:(label ^ " toggles off") (value runtime uuid "logseq.property/order-list-type") None) + ["Number list", Q.alpha; "Number children", child]) + +let copied_urls_include_the_graph_identity () = R.with_runtime (fun session _ _ -> + ignore (event session "longPressBlock" ["uuid", `String Q.alpha]); + let response = event session "toolbar" ["action", `String "copyURL"] in + let commands = items "outlinerCommands" response in + check ~msg:"copied links identify the graph and block" + (List.exists (fun command -> member "type" command = `String "setClipboardText" + && member "text" command = `String ("logseq://graph/plain-1?block-id=" ^ Q.alpha)) commands)) + +let return_selects_completion_and_code_keeps_line_breaks () = R.with_runtime (fun session runtime _ -> + ignore (start session "/h1"); + ignore (event session "returnPressed" []); + check_eq ~msg:"Return selects the highlighted first command" (value runtime Q.alpha "logseq.property/heading") (Some (Ds.Int64 1L)); + ignore (run session "Code block"); + ignore (event session "returnPressed" ["title", `String "let x = 1"; "caretUTF16Offset", `Int 9]); + let response = snapshot session in + check_eq ~msg:"a code block keeps Return inside the same block" + (member "uuid" (draft response)) (`String Q.alpha); + check_eq (member "title" (draft response)) (`String "let x = 1\n")) + +let code_in_text_creates_a_separate_block () = R.with_runtime (fun session runtime _ -> + ignore (start session "Keep this /"); + ignore (command session "Code block"); + check_eq ~msg:"a code command preserves nonempty prose" (value runtime Q.alpha "block/title") (Some (Ds.String "Keep this ")); + check_eq (value runtime Q.alpha "logseq.property.node/display-type") None; + check ~msg:"the code editor gets its own sibling" (List.exists (fun (b : Cache_model.block) -> + b.uuid <> Q.alpha && b.display_type = Some "code") (Graph_runtime.blocks_for_page runtime Q.page))) + +let shared_panel_for_commands_pages_and_tags () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + List.iter (fun title -> ignore (start session title); + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + check ~msg:title (find "toolbar.outliner.autocomplete" <> -1); + check ~msg:"every completion offers explicit dismissal" (find "button.outliner.autocomplete.dismiss" <> -1); + App_test.dispatch app (Lui_protocol.Press (find "button.outliner.autocomplete.dismiss")); + App_test.flush app; + check ~msg:"dismissal preserves editing" ((App.model app).Model.outliner_editing <> None))) + ["/"; "[[Al"; "#Pro"])) [App_test.ios_backend; App_test.kotlin_backend] + +let page_placeholders_are_lazy_and_editable () = + List.iter (fun backend -> + List.iter (fun (title, children, expected) -> + let block = `Assoc ["uuid", `String "last"; "title", `String title; + "pageId", `String Q.page; "parentId", `String Q.page] in + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "graph"; + "selectedPage", `Assoc ["uuid", `String Q.page; "title", `String "A page"]; + "outlinerRows", `List (if title = "" then [] else + [`Assoc ["block", block; "depth", `Int 0; "hasChildren", `Bool children]])]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + let placeholder = find ("button.outliner.placeholder." ^ Q.page) in + check_eq ~msg:("placeholder for " ^ title) (placeholder <> -1) expected; + check_eq ~msg:"reading never creates a block" (App.model app).pending_effects []; + if expected then begin + App_test.dispatch app (Lui_protocol.Press placeholder); App_test.flush app; + check ~msg:"one tap starts block creation and editing" + (List.exists (function Model.AddRootBlockEffect (_, uuid) -> uuid = Q.page | _ -> false) + (App.model app).pending_effects) + end)) ["", false, true; "Last text", false, true; + "", false, false; "", true, true] + ) [App_test.ios_backend; App_test.kotlin_backend]; + R.with_runtime (fun session runtime _ -> + let before = Graph_runtime.blocks_for_page runtime Q.page in + let response = event session "addRootBlock" ["uuid", `String Q.page] in + let uuid = text (member "uuid" (draft response)) in + check ~msg:"the newly inserted block is immediately focused" (uuid <> ""); + let after = Graph_runtime.blocks_for_page runtime Q.page in + check_eq (List.length after) (List.length before + 1); + check_eq (List.find (fun (b : Cache_model.block) -> b.uuid = uuid) after).title "") + +let empty_journal_pages_keep_their_heading () = + List.iter (fun backend -> + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "graph"; "outlinerRows", `List []; + "emptyJournals", `List [`Assoc ["uuid", `String Q.page; "title", `String "Today"; + "journalDay", `Int 20261022]]]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + check ~msg:"an existing day remains visible even with no blocks" + (find ("button.journal." ^ Q.page) <> -1); + let placeholder = find ("button.outliner.placeholder." ^ Q.page) in + check ~msg:"an empty day offers the same editable placeholder" (placeholder <> -1); + check_eq ~msg:"rendering an empty journal has no writes" (App.model app).pending_effects []; + App_test.dispatch app (Lui_protocol.Press placeholder); App_test.flush app; + check ~msg:"the empty day is the insertion destination" + (List.exists (function Model.AddRootBlockEffect (_, uuid) -> uuid = Q.page | _ -> false) + (App.model app).pending_effects))) [App_test.ios_backend; App_test.kotlin_backend] + +let zoom_placeholder_creates_a_child_in_the_same_page () = R.with_runtime (fun session runtime _ -> + let response = event session "addRootBlock" ["uuid", `String Q.alpha] in + let uuid = text (member "uuid" (draft response)) in + check ~msg:"zoom insertion focuses the new child" (uuid <> ""); + let child = List.find (fun (block : Cache_model.block) -> block.uuid = uuid) + (Graph_runtime.blocks_for_page runtime Q.page) in + check_eq child.parent_id (Some Q.alpha); + check_eq child.page_id Q.page) + +let repeated_placeholder_taps_create_one_editor () = + let model = Model.update (Model.initial ()) (Model.AddRootBlock "page") in + let repeated = Model.update model (Model.AddRootBlock "page") in + check_eq ~msg:"rapid taps cannot insert multiple empty blocks" repeated.pending_effects model.pending_effects + +let empty_journals_are_projected_without_writes () = R.with_runtime (fun _ runtime conn -> + let uuid = "50000000-0000-4000-8000-000000000001" in + let day = Cache_model.journal_day_for_ms (Session_types.now_ms ()) in + rebase runtime conn (R.entity 200 uuid "Today" @ [put uuid "block/name" (Ds.String "today"); + put uuid "block/journal-day" (Ds.Int64 (Int64.of_int day))]); + let pending = Graph_runtime.pending_operations runtime in + let session = Rpc_session.create_session {Rpc_session.default_options with + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); + graph_blocks = Some (fun () -> Some (Graph_runtime.blocks runtime)); + empty_journals = Some (fun () -> Graph_runtime.empty_journals runtime)} in + let response = snapshot session in + check ~msg:"the current empty journal is projected independently of its blocks" + (List.exists (fun page -> member "uuid" page = `String uuid) (items "emptyJournals" response)); + check_eq (Graph_runtime.pending_operations runtime) pending) + +let cases = [ + case "zoom placeholder creates a child in its page" zoom_placeholder_creates_a_child_in_the_same_page; + case "rapid placeholder taps create one editor" repeated_placeholder_taps_create_one_editor; + case "empty journals project without writes" empty_journals_are_projected_without_writes; + case "page placeholders are lazy and editable" page_placeholders_are_lazy_and_editable; + case "empty journal headings stay visible" empty_journal_pages_keep_their_heading; + case "Return selects completion and code keeps line breaks" return_selects_completion_and_code_keeps_line_breaks; + case "code in prose creates a separate block" code_in_text_creates_a_separate_block; + case "HTML images and underline render as content" html_images_and_underline_render_as_content; + case "command output has native presentation" command_output_has_native_presentation; + case "embedding in text preserves the original block" embedding_in_text_preserves_the_original_block; + case "copied URLs include graph identity" copied_urls_include_the_graph_identity; + case "pasted markers stay literal" pasted_markers_do_not_open_completion; + case "catalog search and literal boundaries" catalog_search_and_literal_boundaries; + case "inline commands preserve caret and suffix" inline_commands_preserve_caret_and_suffix; + case "graph choices headings and block types" graph_choices_headings_and_block_types; + case "links and dates use shared prompts" links_and_dates_use_shared_prompts; + case "embeds delete only the wrapper" embeds_delete_only_the_wrapper; + case "templates clone without mutating the source" templates_clone_without_mutating_the_source; + case "numbered lists toggle" numbered_lists_toggle_without_changing_source_text; + case "shared panel for commands pages and tags" shared_panel_for_commands_pages_and_tags; +] diff --git a/shared/test/logseq/graph_runtime_test.ml b/shared/test/logseq/graph_runtime_test.ml index 0fb601ff..8271d834 100644 --- a/shared/test/logseq/graph_runtime_test.ml +++ b/shared/test/logseq/graph_runtime_test.ml @@ -1202,7 +1202,27 @@ let authoritative_today_journal_is_not_recreated () = { Runtime.default_options with auto_create_today = true } in check_eq (Runtime.journal_page_uuid current day) (Some "existing-today"); - check (Runtime.pending_operations current = [])) + check_eq ~msg:"viewing an empty journal never creates persisted filler" + (Runtime.blocks_for_page current "existing-today") []; + ignore (Runtime.blocks current); + check_eq ~msg:"repeated reads preserve the authoritative empty page" + (Runtime.pending_operations current) []) + +let journal_reads_fill_a_missing_today_once () = + List.iter (fun auto_create_today -> with_store (fun path -> + let runtime = Runtime.create_base path 42 (Ds.conn_from_db (Ds.empty_db ~schema ())) + {Runtime.default_options with auto_create_today} in + check_eq (Runtime.journal_page_uuid runtime (today ())) None; + ignore (Runtime.blocks runtime); + if auto_create_today then begin + check ~msg:"a long-lived graph read creates the missing local day" + (Runtime.journal_page_uuid runtime (today ()) <> None); + let count = List.length (Runtime.pending_operations runtime) in + check_eq count 1; + ignore (Runtime.blocks runtime); + check_eq ~msg:"refresh and graph restoration do not duplicate the journal" + (List.length (Runtime.pending_operations runtime)) count + end else check_eq (Runtime.pending_operations runtime) [])) [true; false] let accepted_journal_discards_its_entire_overlay () = with_store (fun path -> @@ -1916,6 +1936,7 @@ let cases = today_journal_is_canonical_atomic_and_not_duplicated_on_reopen; case "authoritative today journal is not recreated" authoritative_today_journal_is_not_recreated; + case "journal reads fill a missing today once" journal_reads_fill_a_missing_today_once; case "accepted journal discards its entire overlay" accepted_journal_discards_its_entire_overlay; case "persisted offline pages reopen for every transport state" diff --git a/shared/test/logseq/outliner_state_test.ml b/shared/test/logseq/outliner_state_test.ml index 159e2e62..542fffeb 100644 --- a/shared/test/logseq/outliner_state_test.ml +++ b/shared/test/logseq/outliner_state_test.ml @@ -804,8 +804,8 @@ let autocomplete_token_parsing_preserves_current_line_rules () = ("before\nstatus::", Some (request State.Property "status")); ("[[Page]]", None); ("((Block", None); - ("/query", None); - ("text\n/query", None); + ("/query", Some (request State.Command "query")); + ("text\n/query", Some (request State.Command "query")); ("plain text", None); ]; check_eq diff --git a/shared/test/logseq/shared_capture_test.ml b/shared/test/logseq/shared_capture_test.ml index 9bc66a8f..8be65efb 100644 --- a/shared/test/logseq/shared_capture_test.ml +++ b/shared/test/logseq/shared_capture_test.ml @@ -73,6 +73,10 @@ let graph_deep_links_resolve_graphs_pages_and_blocks () = check_eq (U.member "deepLinkQuickAction" (result "/Plain" [])) (`String "journal"); check_eq (U.member "deepLinkNodeUuid" (result "/Plain" ["page", "Workspace"])) (`String Query_block_test.page); check_eq (U.member "deepLinkNodeUuid" (result "/plain-1" ["block-id", Query_block_test.alpha])) (`String Query_block_test.alpha); + check_eq ~msg:"a block-only URL resolves in the selected graph" + (U.member "deepLinkNodeUuid" (result "/" ["block-id", Query_block_test.alpha])) (`String Query_block_test.alpha); + check_eq ~msg:"an explicit graph ID query takes precedence over the path name" + (U.member "deepLinkGraphId" (result "/Plain" ["graph-id", "other"; "block-id", Query_block_test.alpha])) (`String "other"); check_eq (U.member "deepLinkNodeUuid" (result "/Plain" ["page", "Workspace"; "block-id", Query_block_test.beta])) (`String Query_block_test.beta); let other = result "/Other 中文 +" ["page", "Workspace"] in check_eq ~msg:"a graph switch must finish before resolving nodes in that graph" diff --git a/shared/test/logseq/test_main.ml b/shared/test/logseq/test_main.ml index d0194d55..59603609 100644 --- a/shared/test/logseq/test_main.ml +++ b/shared/test/logseq/test_main.ml @@ -39,6 +39,7 @@ let () = "graph_read", Graph_read_test.cases; "mobile_graph", Mobile_graph_test.cases; "outliner_state", Outliner_state_test.cases; + "editor_commands", Editor_command_test.cases; "pending_projection", Pending_projection_test.cases; "graph_runtime", Graph_runtime_test.cases; "rpc", Rpc_test.cases; From dd7745e1f0b88b6eee19c8236e44b86c70619abf Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:20:53 +0800 Subject: [PATCH 02/25] Resolve referenced display titles and select saved views with native menus --- shared/src/logseq/core/graph_read.ml | 18 +++++++- shared/src/logseq/core/graph_read.mli | 2 + shared/src/logseq/core/markup.ml | 14 +++++++ shared/src/logseq/core/markup.mli | 2 + shared/src/logseq/core/result_view.ml | 1 + shared/src/logseq/core/rpc_encode.ml | 1 + shared/src/logseq/view_query.ml | 19 +++++---- shared/test/logseq/editor_command_test.ml | 50 +++++++++++++++++++++++ shared/test/logseq/rpc_test.ml | 1 + 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/shared/src/logseq/core/graph_read.ml b/shared/src/logseq/core/graph_read.ml index 99adf58e..c8ce1af6 100644 --- a/shared/src/logseq/core/graph_read.ml +++ b/shared/src/logseq/core/graph_read.ml @@ -125,11 +125,25 @@ let default_task_status_eid db = | Some _ -> configured | None -> Ds.entid db "db/ident" (Ds.Keyword "logseq.property/status.todo") +let display_title decrypt_title db title = + let rec resolve seen depth title = + let summaries = Ref_text.page_names title |> List.sort_uniq String.compare + |> List.filter_map (fun uuid -> + if not (Ref_text.is_uuid uuid) then None else + match Ds.entid db "block/uuid" (Ds.Uuid uuid) with + | None -> None + | Some eid -> Option.map (fun source -> + let title = if depth >= 16 || List.mem uuid seen then "…" + else resolve (uuid :: seen) (depth + 1) source in + {Model.uuid; title}) (protected_string decrypt_title (value db eid "block/title"))) in + Markup.plain_text (Markup.parse summaries summaries title) + in resolve [] 0 title + let entity_summary decrypt_title db eid = let uuid = uuid_for_eid db eid in let title = protected_string decrypt_title (value db eid "block/title") in match (uuid, title) with - | Some uuid, Some title -> Some { Model.uuid; title } + | Some uuid, Some title -> Some { Model.uuid; title = display_title decrypt_title db title } | _ -> None let entity_summaries decrypt_title db eid attr = @@ -367,7 +381,7 @@ let page_summary decrypt_title db eid = && not (library_entity db eid) && not (S.starts_with ~prefix:"$$$" name) && not (page_is_hidden db eid) - then Some { Model.uuid; title } + then Some { Model.uuid; title = display_title decrypt_title db title } else None | _ -> None diff --git a/shared/src/logseq/core/graph_read.mli b/shared/src/logseq/core/graph_read.mli index 0aa0a224..84ebdb56 100644 --- a/shared/src/logseq/core/graph_read.mli +++ b/shared/src/logseq/core/graph_read.mli @@ -154,3 +154,5 @@ val blocks : val node_icon : Datascript.db -> string -> Yojson.Basic.t option val property_choices : (string -> (string, string) result) -> Datascript.db -> string -> Cache_model.status list + +val display_title : (string -> (string, string) result) -> Datascript.db -> string -> string diff --git a/shared/src/logseq/core/markup.ml b/shared/src/logseq/core/markup.ml index 3da3544b..107d334d 100644 --- a/shared/src/logseq/core/markup.ml +++ b/shared/src/logseq/core/markup.ml @@ -400,3 +400,17 @@ let parse references tags source = (parse_inline references tags (S.trim (String.sub source 1 (String.length source - 1)))); ] else parse_inline references tags source + +let plain_text nodes = + let result = Buffer.create 64 in + let rec append = function + | Markup_text value | Markup_code value | Markup_math (value, _) + | Markup_cloze value | Markup_code_block (_, value) -> Buffer.add_string result value + | Markup_node_ref (_, label) -> Buffer.add_string result label + | Markup_tag_ref (_, label) -> Buffer.add_char result '#'; Buffer.add_string result label + | Markup_emphasis (_, nodes) | Markup_quote nodes | Markup_link (_, nodes) -> List.iter append nodes + | Markup_image (_, label) -> Buffer.add_string result label + | Markup_video _ | Markup_iframe _ | Markup_html _ | Markup_youtube_timestamp _ -> () + in + List.iter append nodes; + Buffer.contents result diff --git a/shared/src/logseq/core/markup.mli b/shared/src/logseq/core/markup.mli index ee29953d..d6048eab 100644 --- a/shared/src/logseq/core/markup.mli +++ b/shared/src/logseq/core/markup.mli @@ -31,3 +31,5 @@ val parse : Cache_model.entity_summary list -> string -> markup_node list + +val plain_text : markup_node list -> string diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 815a9c72..e4f1b313 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -72,6 +72,7 @@ let property_display db p value = let t = Unix.localtime (millis /. 1000.) in Printf.sprintf "%04d-%02d-%02d %02d:%02d" (t.Unix.tm_year + 1900) (t.tm_mon + 1) t.tm_mday t.tm_hour t.tm_min | _ -> "" + else if p.attr = "block/title" then G.display_title (fun text -> Ok text) db (display (content db value)) else display (content db value) let public_attr attr = attr = "block/title" || String.starts_with ~prefix:"user.property/" attr diff --git a/shared/src/logseq/core/rpc_encode.ml b/shared/src/logseq/core/rpc_encode.ml index aba28d78..2d4991d4 100644 --- a/shared/src/logseq/core/rpc_encode.ml +++ b/shared/src/logseq/core/rpc_encode.ml @@ -79,6 +79,7 @@ let block_json (block : Model.block) = Wire.json_object ([ ("uuid", `String block.uuid) ; ("title", `String block.title) + ; ("displayTitle", `String (Markup.plain_text (Markup.parse block.references block.tags block.title))) ; ("pageId", `String block.page_id) ; ("createdAt", `Int block.created_at) ; ("updatedAt", `Int block.updated_at) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index d75fd53c..eb6b1538 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -25,7 +25,9 @@ let decode raw = error = (match member "error" json with Some (`String text) -> Some text | _ -> None); rows = List.mapi (fun index json -> match member "block" json with | Some block_json -> let block = decode_block 0 block_json in { - index; uuid = Some block.row.row_uuid; title = block.row.row_title; block = Some block; json} + index; uuid = Some block.row.row_uuid; + title = (match member "displayTitle" block_json with Some (`String title) -> title | _ -> block.row.row_title); + block = Some block; json} | 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} @@ -541,12 +543,15 @@ let collection ~render_block model_source raw_source send : t = (column ~gap:8 ~cross:`stretch [ row ~gap:0 ~cross:`center ~accessibility_identifier:("view.toolbar." ^ owner) [ (if feature = "query-result" then results_toggle () else - tabs ~grow:1.0 ~style_class:"scrollable" ~accessibility_identifier:("view.tabs." ^ owner) [ - keyed ~source:views ~key:(fun view -> view.id) ~cmp:String.compare ~mount:(fun source -> - let tab = Signal.sample source in - button ~text_signal:(own (Signal.map (fun tab -> tab.name) source)) - ~selected:(tab.id = view.id) ~accessibility_identifier:("view.tab." ^ tab.id) - ~on_press:(fun _ -> Signal.set active tab.id) [])]); + row ~grow:1.0 ~cross:`center [ + menu ~text_signal:(own (Signal.map (fun view -> view.name) view_source)) + ~label:"Views" ~accessibility_identifier:("view.select." ^ owner) [ + keyed ~source:views ~key:(fun view -> view.id) ~cmp:String.compare ~mount:(fun source -> + let saved = Signal.sample source in + menu_item ~text_signal:(own (Signal.map (fun view -> view.name) source)) + ~selected:(saved.id = view.id) ~accessibility_identifier:("view.tab." ^ saved.id) + ~on_press:(fun _ -> Signal.set active saved.id) [])]; + box ~grow:1.0 []]); button ~icon:(`app "view-filter") ~label:"Filters" ~variant:`ghost ~width:40 ~height:44 ~accessibility_identifier:("view.filter." ^ view.id) ~on_press:(fun _ -> Signal.set settings "filter") []; button ~icon:(`app "view-sort") ~label:"Sort" ~variant:`ghost ~width:40 ~height:44 diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 7afa9d13..2d95262a 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -361,7 +361,57 @@ let empty_journals_are_projected_without_writes () = R.with_runtime (fun _ runti (List.exists (fun page -> member "uuid" page = `String uuid) (items "emptyJournals" response)); check_eq (Graph_runtime.pending_operations runtime) pending) +let display_titles_resolve_references_without_changing_source () = R.with_runtime (fun session runtime conn -> + let nested = "60000000-0000-4000-8000-000000000001" in + let cycle = "60000000-0000-4000-8000-000000000002" in + let favorite = "60000000-0000-4000-8000-000000000004" in + let source = "Read [[" ^ nested ^ "]]" in + rebase runtime conn (R.entity 201 nested ("[[" ^ Q.beta ^ "]] notes") @ + R.entity 202 cycle ("[[" ^ cycle ^ "]]") @ + R.entity 204 favorite "" @ [ + put favorite "block/page" (Ds.Ref (Option.get (Graph_read.favorite_page_eid (Graph_runtime.db runtime)))); + put favorite "block/link" (Ds.Ref_to (Q.ref_uuid Q.page)); + put Q.page "block/title" (Ds.String source); + put Q.alpha "block/title" (Ds.String source); + put Q.alpha "block/refs" (Ds.Ref_to (Q.ref_uuid nested))]); + let db = Graph_runtime.db runtime in + let page = fst (Option.get (Graph_read.node_destination (fun x -> Ok x) db Q.page)) in + check_eq ~msg:"scene titles resolve nested UUID references" page.title "Read Beta result notes"; + let sidebar = Graph_read.sidebar_pages (fun x -> Ok x) db in + check_eq ~msg:"favorites use the same display title" (List.hd sidebar.favorites).title page.title; + let table = R.find_view R.table_view (R.read session) in + let row = List.find (fun row -> member "uuid" (member "block" row) = `String Q.alpha) (items "rows" table) in + let name = List.find (fun p -> member "attr" p = `String "block/title") (items "properties" row) in + check_eq ~msg:"table names resolve UUID references" (member "text" name) (`String page.title); + check_eq ~msg:"gallery names use a resolved display title" + (member "displayTitle" (member "block" row)) (`String page.title); + check_eq ~msg:"editing and syncing retain the original reference syntax" + (member "title" (member "block" row)) (`String source); + let cycle_id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid cycle)) in + let cycle_summary = Option.get (Graph_read.entity_summary (fun x -> Ok x) db cycle_id) in + check ~msg:"cyclic title references remain bounded" (String.length cycle_summary.title < 100)) + +let saved_views_use_an_intrinsic_native_select () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + let views = R.read ~owner:Q.project ~feature:"class-objects" session in + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "graph"; + "selectedPage", `Assoc ["uuid", `String Q.project; "title", `String "Project"]; + "selectedPageIsTag", `Bool true; "resultViews", views; "outlinerRows", `List []]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let root = Lui_app.root_node app in + let find id = App_test.descendant_with_identifier host root id in + check_eq ~msg:"view selection no longer mounts a tab bar" (find ("view.tabs." ^ Q.project)) (-1); + let select = find ("view.select." ^ Q.project) in + check ~msg:"view names use one native selection menu" (select <> -1); + check ~msg:"the selection trigger keeps its intrinsic width" + (App_test.property_float host select Lui_protocol.GrowValue <= 0.0)))) + [App_test.ios_backend; App_test.kotlin_backend] + let cases = [ + case "display titles resolve UUID references" display_titles_resolve_references_without_changing_source; + case "views use an intrinsic native select" saved_views_use_an_intrinsic_native_select; case "zoom placeholder creates a child in its page" zoom_placeholder_creates_a_child_in_the_same_page; case "rapid placeholder taps create one editor" repeated_placeholder_taps_create_one_editor; case "empty journals project without writes" empty_journals_are_projected_without_writes; diff --git a/shared/test/logseq/rpc_test.ml b/shared/test/logseq/rpc_test.ml index cb788b6a..d78b448a 100644 --- a/shared/test/logseq/rpc_test.ml +++ b/shared/test/logseq/rpc_test.ml @@ -6177,6 +6177,7 @@ let block_json_publishes_resolved_markup_and_omits_absent_fields () = [ "uuid"; "title"; + "displayTitle"; "pageId"; "createdAt"; "updatedAt"; From 1e31f507bd6cc64ba1eebb75d5d1f9c64d58ad09 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:25:01 +0800 Subject: [PATCH 03/25] Match master slash command groups and priority order --- shared/src/logseq/core/editor_command.ml | 16 +++++++++++++++- shared/src/logseq/core/editor_command.mli | 2 ++ shared/src/logseq/core/rpc_encode.ml | 11 +++++++---- shared/src/logseq/model.ml | 1 + shared/src/logseq/model.mli | 1 + shared/src/logseq/response_snapshot.ml | 2 +- shared/src/logseq/view_outliner.ml | 7 ++++++- shared/test/logseq/app_test.ml | 12 ++++++------ shared/test/logseq/editor_command_test.ml | 23 +++++++++++++++++++++++ 9 files changed, 62 insertions(+), 13 deletions(-) diff --git a/shared/src/logseq/core/editor_command.ml b/shared/src/logseq/core/editor_command.ml index 7ce2a954..2a7bad38 100644 --- a/shared/src/logseq/core/editor_command.ml +++ b/shared/src/logseq/core/editor_command.ml @@ -36,8 +36,9 @@ let catalog db = (Properties ["logseq.property/heading", Some (Ops.Int_value n)])) @ choices "logseq.property/status" @ [entry "deadline" "Deadline" (Pick Deadline); entry "scheduled" "Scheduled" (Pick Scheduled)] + @ [entry "no-priority" "No priority" (Choice ("logseq.property/priority", None))] @ choices "logseq.property/priority" - @ [entry "no-priority" "No priority" (Choice ("logseq.property/priority", None)); + @ [ entry "tomorrow" "Tomorrow" (Journal 1); entry "yesterday" "Yesterday" (Journal (-1)); entry "today" "Today" (Journal 0); entry "time" "Current time" Time; entry "date" "Date picker" (Pick Date); entry "number" "Number list" (Number false); entry "number-children" "Number children" (Number true); @@ -48,6 +49,19 @@ let catalog db = entry "tweet" "Embed Twitter tweet" (Insert ("{{tweet }}", 2)); entry "cloze" "Cloze" (Insert ("{{cloze }}", 2))] +let group value = + if not (String.starts_with ~prefix:"command:" value) then None else + let id = String.sub value 8 (String.length value - 8) in + Some (if id = "embed" then "BASIC" + else if List.mem id ["link"; "image"; "underline"; "code"; "quote"; "math"] then "FORMAT" + else if List.mem id ["normal"; "clear-heading"] || String.starts_with ~prefix:"heading-" id then "Heading" + else if String.starts_with ~prefix:"logseq.property/status:" id then "TASK STATUS" + else if List.mem id ["deadline"; "scheduled"] then "TASK DATE" + else if id = "no-priority" || String.starts_with ~prefix:"logseq.property/priority:" id then "PRIORITY" + else if List.mem id ["tomorrow"; "yesterday"; "today"; "time"; "date"] then "TIME & DATE" + else if List.mem id ["number"; "number-children"] then "LIST TYPE" + else "ADVANCED") + let candidates db query = let query = String.lowercase_ascii (String.trim query) in let entries = catalog db in diff --git a/shared/src/logseq/core/editor_command.mli b/shared/src/logseq/core/editor_command.mli index bc0828d5..78418723 100644 --- a/shared/src/logseq/core/editor_command.mli +++ b/shared/src/logseq/core/editor_command.mli @@ -23,3 +23,5 @@ val journal_reference : Datascript.db option -> int -> string val date_ms : string -> (int, string) result val timestamp : string -> (int, string) result val link : image:bool -> string -> (string, string) result + +val group : string -> string option diff --git a/shared/src/logseq/core/rpc_encode.ml b/shared/src/logseq/core/rpc_encode.ml index 2d4991d4..3c7e316d 100644 --- a/shared/src/logseq/core/rpc_encode.ml +++ b/shared/src/logseq/core/rpc_encode.ml @@ -315,13 +315,16 @@ let outliner_candidates_json context state = Wire.json_list (match Outliner.autocomplete state with | Some request -> + let groups = Hashtbl.create 9 in List.map (fun (candidate : Outliner.outliner_candidate) -> + let group = if request.kind <> Outliner.Command || String.trim request.query <> "" then None + else Option.bind (Editor_command.group candidate.value) (fun group -> + if Hashtbl.mem groups group then None + else (Hashtbl.add groups group (); Some group)) in Wire.json_object - [ - ("label", `String candidate.label) - ; ("value", `String candidate.value) - ]) + (["label", `String candidate.label; "value", `String candidate.value] + @ match group with Some group -> ["group", `String group] | None -> [])) (Outliner.autocomplete_candidates context request) | None -> []) diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index 81bcd362..db50bf29 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -183,6 +183,7 @@ type outliner_autocomplete = type outliner_autocomplete_candidate = { candidate_index : int + ; candidate_group : string option ; candidate_label : string ; candidate_value : string } diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index 445963d1..ab49c678 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -180,6 +180,7 @@ type outliner_autocomplete = type outliner_autocomplete_candidate = { candidate_index : int + ; candidate_group : string option ; candidate_label : string ; candidate_value : string } diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index 0acb3e96..126ecf36 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -325,7 +325,7 @@ let autocomplete_candidate input = let fields = object_fields input in match string_member "label" fields, string_member "value" fields with | Some candidate_label, Some candidate_value -> - Some { Model.candidate_index = 0; candidate_label; candidate_value } + Some { Model.candidate_index = 0; candidate_group = string_member "group" fields; candidate_label; candidate_value } | _ -> None let autocomplete_candidates_member fields = diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index d2d00ed9..67c50c00 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -755,7 +755,12 @@ let outliner_selection_toolbar (context : Lui_ui.ui_context) model_source send : let outliner_autocomplete_row (context : Lui_ui.ui_context) candidate_source send : t = let candidate = Signal.sample candidate_source in - row ~grow:0.0 ~cross:`stretch [ + column ~gap:0 ~grow:0.0 ~cross:`stretch [ + dyn (fun (candidate : Model.outliner_autocomplete_candidate) -> match candidate.candidate_group with + | None -> box [] + | Some group -> text ~value:group ~font_size:"12" ~foreground:"muted-foreground" + ~padding_horizontal:10 ~padding_vertical:5 + ~accessibility_identifier:("outliner.autocomplete.group." ^ group) []) candidate_source; View_base.with_label_signal (Signal.map View_base.outliner_autocomplete_label candidate_source) (View_base.with_string_prop_signal AccessibilityIdentifier diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index fd8dd14d..94513971 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -4906,7 +4906,7 @@ let pending_outliner_text_keeps_the_latest_optimistic_edit () = (empty_core_projection ()) with Model.projection_outliner_editing = Some stale; projection_outliner_autocomplete_candidates = [ - { Model.candidate_index = 0; candidate_label = "Old result"; + { Model.candidate_index = 0; candidate_group = None; candidate_label = "Old result"; candidate_value = "old-result" } ]; } in @@ -4940,7 +4940,7 @@ let reference_autocomplete_remains_mounted_while_typing () = let application = App.create (ios_backend ()) in start application; let editing = { Model.editing_uuid = "editing"; editing_title = before; caret_utf16_offset = caret } in - let candidate index label value = { Model.candidate_index = index; candidate_label = label; candidate_value = value } in + let candidate index label value = { Model.candidate_index = index; candidate_group = None; candidate_label = label; candidate_value = value } in let candidates = [candidate 0 "Project" "project"; candidate 1 "Progress" "progress"] in let projection = { (empty_core_projection ()) with selected_graph_id = Some "test-graph"; @@ -5039,7 +5039,7 @@ let outliner_typing_retains_reference_candidates () = in let candidate = { - Model.candidate_index = 0; + Model.candidate_index = 0; candidate_group = None; candidate_label = "Card"; candidate_value = "tag-card"; } @@ -5074,7 +5074,7 @@ let completion_requests_native_caret_selection () = let projection = { (empty_core_projection ()) with selected_graph_id = Some "graph"; projection_outliner_editing = Some editing; projection_outliner_autocomplete = Some { Model.autocomplete_kind = Model.NodeAutocomplete; autocomplete_query = "wow" }; - projection_outliner_autocomplete_candidates = [{ Model.candidate_index = 0; candidate_label = "wow"; candidate_value = "page" }]; + projection_outliner_autocomplete_candidates = [{ Model.candidate_index = 0; candidate_group = None; candidate_label = "wow"; candidate_value = "page" }]; outliner_rows = [outline_row "editing" "[[wow]]"] } in send application (Model.ApplyCoreSnapshot projection); flush application; @@ -9267,7 +9267,7 @@ let outliner_editor_toolbar_and_autocomplete_use_core_owned_state () = in let candidate = { - Model.candidate_index = 10; + Model.candidate_index = 10; candidate_group = None; candidate_label = "Project Alpha"; candidate_value = "page-a"; } @@ -9447,7 +9447,7 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = in let candidate = { - Model.candidate_index = 0; + Model.candidate_index = 0; candidate_group = None; candidate_label = "Project"; candidate_value = "tag-a"; } diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 2d95262a..fa8474b1 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -409,7 +409,30 @@ let saved_views_use_an_intrinsic_native_select () = (App_test.property_float host select Lui_protocol.GrowValue <= 0.0)))) [App_test.ios_backend; App_test.kotlin_backend] +let command_groups_follow_master_order () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + let response = start session "/" in + let commands = choices response in + let groups = List.filter_map (fun candidate -> match member "group" candidate with + | `String title -> Some title | _ -> None) commands in + check_eq ~msg:"master group order survives command exclusions" groups + ["BASIC"; "FORMAT"; "Heading"; "TASK STATUS"; "TASK DATE"; "PRIORITY"; "TIME & DATE"; "LIST TYPE"; "ADVANCED"]; + let labels = List.map (fun candidate -> text (member "label" candidate)) commands in + let before a b = + check ~msg:(a ^ " precedes " ^ b) (Option.get (List.find_index ((=) a) labels) < Option.get (List.find_index ((=) b) labels)) in + before "Link" "Image link"; before "Image link" "Underline"; before "Normal text" "Heading 1"; + before "No priority" "Low"; before "Calculator" "Template"; before "Embed Twitter tweet" "Cloze"; + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + List.iter (fun group -> check ~msg:("native group header " ^ group) + (App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.autocomplete.group." ^ group) <> -1)) groups); + let response = start session "/head" in + check ~msg:"master search uses relevance without group headers" + (List.for_all (fun candidate -> member "group" candidate = `Null) (choices response)))) + [App_test.ios_backend; App_test.kotlin_backend] + let cases = [ + case "command groups follow master order" command_groups_follow_master_order; case "display titles resolve UUID references" display_titles_resolve_references_without_changing_source; case "views use an intrinsic native select" saved_views_use_an_intrinsic_native_select; case "zoom placeholder creates a child in its page" zoom_placeholder_creates_a_child_in_the_same_page; From 4d6f4ce493a3a6da66410c60a6cc5f44a29e39c7 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:33:03 +0800 Subject: [PATCH 04/25] Match parent Enter placement and show task dates with shared properties --- .../logseq/app/AndroidEditorCompletionTest.kt | 3 +- apple/Tests/UI/editor-completion.swift | 5 +- shared/src/logseq/core/node_presentation.ml | 15 ++++- shared/src/logseq/core/outliner_effects.ml | 35 +++++++----- shared/src/logseq/core/outliner_state.ml | 12 +++- shared/src/logseq/core/outliner_state.mli | 1 + shared/src/logseq/core/result_view.ml | 4 +- shared/src/logseq/core/result_view.mli | 2 +- shared/src/logseq/core/rpc_session.ml | 3 +- shared/src/logseq/model.ml | 1 + shared/src/logseq/model.mli | 1 + shared/src/logseq/response_snapshot.ml | 4 +- shared/src/logseq/view_outliner.ml | 18 ++++-- shared/src/logseq/view_property.ml | 6 +- shared/src/logseq/view_property.mli | 2 +- shared/test/logseq/app_test.ml | 4 +- shared/test/logseq/editor_command_test.ml | 55 +++++++++++++++++++ shared/test/logseq/outliner_effects_test.ml | 2 +- shared/test/logseq/outliner_state_test.ml | 1 + 19 files changed, 137 insertions(+), 37 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt index 8ba4b4e1..875200af 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt @@ -48,7 +48,7 @@ class AndroidEditorCompletionTest { .put("selectedBlockIds", org.json.JSONArray()).put("autocomplete", if (kind == null) JSONObject.NULL else JSONObject().put("kind", kind).put("query", ""))) .put("outlinerAutocompleteCandidates", org.json.JSONArray().apply { - if (kind != null) put(JSONObject().put("label", "Project").put("value", if (kind == "command") "command:embed" else "project")) + if (kind != null) put(JSONObject().put("label", "Project").put("value", if (kind == "command") "command:embed" else "project").put("group", if (kind == "command") "BASIC" else JSONObject.NULL)) })).toString() private fun start(title: String, kind: String?) { @@ -71,6 +71,7 @@ class AndroidEditorCompletionTest { for ((kind, title) in listOf("command" to "/", "node" to "[[Pro", "tag" to "#Pro")) { ui.runOnIdle { bridge.applySnapshot(snapshot(kind, title)).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } ui.onNodeWithTag("toolbar.outliner.autocomplete").assertIsDisplayed() + if (kind == "command") ui.onNodeWithTag("outliner.autocomplete.group.BASIC").assertIsDisplayed() ui.onNodeWithTag("button.outliner.autocomplete.dismiss").assertIsDisplayed() ui.onNodeWithTag("button.outliner.autocomplete.0").performClick() ui.runOnIdle { diff --git a/apple/Tests/UI/editor-completion.swift b/apple/Tests/UI/editor-completion.swift index f42b4a3f..f457589c 100644 --- a/apple/Tests/UI/editor-completion.swift +++ b/apple/Tests/UI/editor-completion.swift @@ -23,9 +23,11 @@ import UIKit func snapshot(_ title: String, caret: Int, kind: String?, label: String, value: String) throws -> String { var state: [String: Any] = ["editing": ["uuid": "editing", "title": title, "caretUTF16Offset": caret], "selectedBlockIds": []] if let kind { state["autocomplete"] = ["kind": kind, "query": ""] } + var candidate: [String: Any] = ["label": label, "value": value] + if kind == "command" { candidate["group"] = "FORMAT" } let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": [["depth": 0, "block": ["uuid": "editing", "title": title, "pageId": "page"]]], - "outlinerState": state, "outlinerAutocompleteCandidates": kind == nil ? [] : [["label": label, "value": value]]] + "outlinerState": state, "outlinerAutocompleteCandidates": kind == nil ? [] : [candidate]] return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) } for (kind, before, after, caret, label, value) in [ @@ -53,6 +55,7 @@ import UIKit func node(_ identifier: String) -> Int? { all.first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } } + if kind == "command", node("outliner.autocomplete.group.FORMAT") == nil { throw failure("Command group heading is missing") } guard node("button.outliner.autocomplete.dismiss") != nil, let candidate = node("button.outliner.autocomplete.0") else { throw failure("Missing shared panel for \(kind)") } try backend.performPress(node: candidate) diff --git a/shared/src/logseq/core/node_presentation.ml b/shared/src/logseq/core/node_presentation.ml index ede6a8aa..68f6e130 100644 --- a/shared/src/logseq/core/node_presentation.ml +++ b/shared/src/logseq/core/node_presentation.ml @@ -49,6 +49,19 @@ let calculator source = let decorate db = let numbers = Hashtbl.create 8 in + let dates = Hashtbl.create 16 in + let task_dates uuid eid = match Hashtbl.find_opt dates uuid with + | Some value -> value + | None -> + let properties = List.filter_map (fun attr -> + match Read.value db eid attr with + | None -> None + | Some _ -> + let property = Result_view.property_json ~include_candidates:false db ~owner:uuid ~attr ~encode_block:(fun _ -> `Null) in + (match property with `Assoc fields when List.assoc_opt "text" fields <> Some (`String "") -> Some property | _ -> None)) + ["logseq.property/scheduled"; "logseq.property/deadline"] in + let value = if properties = [] then `Null else `Assoc ["ownerUuid", `String uuid; "properties", `List properties] in + Hashtbl.add dates uuid value; value in let list_number eid = let numbered eid = match Read.ref_eids db eid "logseq.property/order-list-type" with | value :: _ -> Read.string_value (Read.value db value "block/title") = Some "number" @@ -96,5 +109,5 @@ let decorate db = || Read.value db eid "logseq.property/heading" <> None || Read.value db eid "logseq.property.code/lang" <> None in let fields = if decorated then ("markup", M.to_yojson (markup [] 16 eid)) :: List.remove_assoc "markup" fields else fields in - `Assoc ((match list_number eid with Some n -> ["listNumber", `Int n] | None -> []) @ fields) + `Assoc (("taskDates", task_dates uuid eid) :: (match list_number eid with Some n -> ["listNumber", `Int n] | None -> []) @ fields) | _ -> encoded diff --git a/shared/src/logseq/core/outliner_effects.ml b/shared/src/logseq/core/outliner_effects.ml index dc606a53..8c2fb270 100644 --- a/shared/src/logseq/core/outliner_effects.ml +++ b/shared/src/logseq/core/outliner_effects.ml @@ -97,22 +97,29 @@ let command base_t now fresh_uuid context cmd = | State.Split_at value -> (match find_block context value.State.split_uuid with | Some block -> - let* new_order = - Order.between block.order (next_order context block) - in + let parent_uuid = Option.value ~default:block.page_id block.parent_id in + let lower, upper, parent_uuid = match value.split_placement with + | State.Inside -> None, (match sorted_siblings context (Some block.uuid) with + | first :: _ -> first.order | [] -> None), block.uuid + | State.Before -> + let rec previous order = function + | [] -> order | (sibling : Cache_model.block) :: _ when sibling.uuid = block.uuid -> order + | sibling :: rest -> previous sibling.order rest in + previous None (sorted_siblings context (Some parent_uuid)), block.order, parent_uuid + | State.After -> block.order, next_order context block, parent_uuid in + let* new_order = Order.between lower upper in let operation_id = fresh_uuid () in let new_uuid = fresh_uuid () in - let intent = - Ops.Split_block - { - uuid = value.split_uuid; - expected_title = value.split_expected_title; - before = value.before; - after = value.after; - new_uuid; - new_order; - created_at = now (); - } + let intent = match value.split_placement with + | State.After -> Ops.Split_block { + uuid = value.split_uuid; expected_title = value.split_expected_title; + before = value.before; after = value.after; new_uuid; new_order; created_at = now ()} + | State.Inside | State.Before -> + let title, inserted = if value.split_placement = State.Before then value.after, value.before + else value.before, value.after in + Ops.Atomic [Ops.Save_title {uuid = block.uuid; expected_title = value.split_expected_title; title}; + Ops.Insert_block {uuid = new_uuid; title = inserted; page_uuid = block.page_id; + parent_uuid; order = new_order; created_at = now ()}] in Ok (result diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index fdbd20fa..354ee9db 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -116,6 +116,7 @@ type outliner_haptic = Selection | Impact type outliner_split = { split_uuid : string + ; split_placement : outliner_placement ; split_expected_title : string ; before : string ; after : string @@ -1008,7 +1009,7 @@ let leave_interaction context (state : outliner_state) = { state with editing = None; selected = Sset.empty; autocomplete = None } (commit_effect context state.editing) -let split_editing (state : outliner_state) (editing : editor_draft) = +let split_editing context (state : outliner_state) (editing : editor_draft) = let index = byte_index_of_utf16 editing.title editing.caret in step { state with editing = None; autocomplete = None } @@ -1016,6 +1017,9 @@ let split_editing (state : outliner_state) (editing : editor_draft) = Split_at { split_uuid = editing.uuid; + split_placement = (if index = 0 && not (String_kit.is_blank editing.title) then Before + else if not (Sset.mem editing.uuid state.collapsed) + && sorted_siblings context (Some editing.uuid) <> [] then Inside else After); split_expected_title = editing.expected_title; before = String.sub editing.title 0 index; after = @@ -1043,8 +1047,8 @@ let split_or_outdent (context : outliner_context) (state : outliner_state) step { state with editing = Some editing; autocomplete = None } (commit_effect context (Some editing) @ [ Reparent_blocks moves ]) - | None -> split_editing state editing - else split_editing state editing + | None -> split_editing context state editing + else split_editing context state editing let start_editing context (state : outliner_state) block caret = { @@ -1594,6 +1598,8 @@ let update context (state : outliner_state) message = | Ops.Set_property property -> property.uuid = block.uuid && property.attr = "logseq.property.node/display-type" && property.value = Some (Ops.Keyword_value "code") | _ -> false) rest -> staged_editing context state block.uuid block.title false + | Ops.Atomic [Ops.Save_title _; Ops.Insert_block block] -> + staged_editing context state block.uuid block.title true | Ops.Split_block value -> staged_editing context state value.new_uuid value.after true | Ops.Insert_block value -> diff --git a/shared/src/logseq/core/outliner_state.mli b/shared/src/logseq/core/outliner_state.mli index 1afe1a29..5411a3c6 100644 --- a/shared/src/logseq/core/outliner_state.mli +++ b/shared/src/logseq/core/outliner_state.mli @@ -113,6 +113,7 @@ type outliner_haptic = Selection | Impact type outliner_split = { split_uuid : string + ; split_placement : outliner_placement ; split_expected_title : string ; before : string ; after : string diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index e4f1b313..14435e0e 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -396,7 +396,7 @@ let edit_cell db ~now ~fresh_uuid owner feature _view json = then invalid_arg "This object is no longer in the view"; edit_property db ~now ~fresh_uuid json -let property_json db ~owner ~attr ~encode_block = +let property_json ?(include_candidates = true) db ~owner ~attr ~encode_block = let p = property db attr in let id = eid db owner in let assigned = attr_values db id attr in @@ -406,7 +406,7 @@ let property_json db ~owner ~attr ~encode_block = (G.block (fun value -> Ok value) db id))) assigned in let metadata = match column_json db [] p with `Assoc fields -> fields | _ -> [] in let text = String.concat ", " (List.map (property_display db p) assigned) in - let candidates = if text_layout then [] else + let candidates = if text_layout || not include_candidates then [] else Ds.Db.datoms db Ds.Aevt ~a:attr () |> Seq.map (fun (d : Ds.datom) -> d.v) |> List.of_seq in let candidates = List.sort_uniq compare (assigned @ candidates) in let filter_values = List.map (fun value -> `Assoc [ diff --git a/shared/src/logseq/core/result_view.mli b/shared/src/logseq/core/result_view.mli index eca14818..18721c9e 100644 --- a/shared/src/logseq/core/result_view.mli +++ b/shared/src/logseq/core/result_view.mli @@ -10,7 +10,7 @@ val mutation : Datascript.db -> now:int -> fresh_uuid:(unit -> string) -> Yojson.Basic.t -> (Pending_ops.pending_intent, string) result -val property_json : Datascript.db -> owner:string -> attr:string -> +val property_json : ?include_candidates:bool -> Datascript.db -> owner:string -> attr:string -> encode_block:(Cache_model.block -> Yojson.Basic.t) -> Yojson.Basic.t val edit_property : ?text_value:bool -> ?allow_builtin:bool -> Datascript.db -> now:int -> fresh_uuid:(unit -> string) -> diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index cad36cbe..132ce5a5 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -93,6 +93,7 @@ let query_db session = | None -> Some (state session).model.db let result_block_encoder db = + let presentation = Node_presentation.decorate db in let pages = Hashtbl.create 4 in let encode_tree block = let children = match Hashtbl.find_opt pages block.Model.page_id with @@ -112,7 +113,7 @@ let result_block_encoder db = let descendants = Option.value ~default:[] (Hashtbl.find_opt children block.uuid) |> List.filter (fun (child : Model.block) -> not (Outliner.Sset.mem child.uuid ancestors)) in let ancestors = Outliner.Sset.add block.uuid ancestors in - match Rpc.block_json block with + 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 diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index db50bf29..b0e929c2 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -119,6 +119,7 @@ type outline_row = { row_uuid : string ; row_loaded : bool ; row_title : string + ; row_task_dates_json : string ; row_list_number : int option ; row_display_type : string option ; markup_json : string diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index ab49c678..cb5931e3 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -116,6 +116,7 @@ type outline_row = { row_uuid : string ; row_loaded : bool ; row_title : string + ; row_task_dates_json : string ; row_list_number : int option ; row_display_type : string option ; markup_json : string diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index 126ecf36..4924fe35 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -239,7 +239,9 @@ let outline_row_from_block youtube_target_url opens_as_page depth match string_member "uuid" fields, string_member "title" fields with | Some row_uuid, Some row_title -> Some - { Model.row_uuid; row_title; row_list_number = int_member "listNumber" fields; + { Model.row_uuid; row_title; + row_task_dates_json = (match List.assoc_opt "taskDates" fields with Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); + row_list_number = int_member "listNumber" fields; row_display_type = string_member "displayType" fields; row_loaded = not (bool_member "isShell" fields); query_json = (match List.assoc_opt "query" fields with | Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 67c50c00..e57d647b 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -574,6 +574,12 @@ let outliner_row_body (context : Lui_ui.ui_context) model_source retained_row_source row_source send search_open; ])) +let outliner_task_dates context model_source row_source send = + View_base.with_int_prop_signal PaddingLeft + (Signal.map (fun (row : Model.outline_row) -> (if Lui_ui.host context = KotlinHost then 12 else 20) + row.depth * 22) row_source) + (box ~padding_horizontal:8 [View_property.view ~compact:true ~render_block:(fun _ _ -> box []) + model_source (Signal.map (fun (row : Model.outline_row) -> row.row_task_dates_json) row_source) send]) + let render_result_block context model_source row_source send = let retained = Signal.map2 View_rows.make_retained_outline_row model_source row_source in column ~gap:0 [ @@ -582,7 +588,8 @@ let render_result_block context model_source row_source send = if_ ~test:(Signal.map View_base.outliner_row_fallback_breadcrumb_ row_source) (text ~value_signal:(Signal.map View_base.outliner_row_breadcrumb row_source) ~style_class:"caption" ~foreground:"muted-foreground" []); - outliner_row_body context model_source retained row_source send] + outliner_row_body context model_source retained row_source send; + outliner_task_dates context model_source row_source send] let node_properties ?(root = false) context model_source send = View_property.view ~render_block:(render_result_block context model_source) model_source @@ -593,9 +600,10 @@ let outliner_row context model_source retained_row_source row_source send : t = let body = outliner_row_body context model_source retained_row_source row_source send in let render_block = render_result_block context model_source in let bullet_inset = if Lui_ui.host context = KotlinHost then 12 else 20 in - dyn (fun (has_query, has_properties) -> - if not has_query && not has_properties then body else - column ~gap:0 ([body] @ + dyn (fun (has_query, has_properties, has_dates) -> + if not has_query && not has_properties && not has_dates then body else + let dates = if has_properties || not has_dates then [] else [outliner_task_dates context model_source row_source send] in + column ~gap:0 ([body] @ dates @ (if has_properties then [box ~margin_left:16 [node_properties context model_source send]] else []) @ (if not has_query then [] else [ View_base.with_int_prop_signal PaddingLeft @@ -607,7 +615,7 @@ let outliner_row context model_source retained_row_source row_source send : t = row.query_json <> "", (match View_base.active_node_projection model with | Some route -> route.node_uuid = row.row_uuid && route.node_uuid <> route.node_page_uuid && View_property.has_properties route.node_properties_json - | None -> false)) model_source row_source) + | None -> false), View_property.has_properties row.row_task_dates_json) model_source row_source) let journal_heading model_source row_source send children : t = fun context parent -> diff --git a/shared/src/logseq/view_property.ml b/shared/src/logseq/view_property.ml index 9d91a16f..0659413b 100644 --- a/shared/src/logseq/view_property.ml +++ b/shared/src/logseq/view_property.ml @@ -13,7 +13,7 @@ let adaptive_row stacked children : t = fun context parent -> List.iter (fun child -> ignore (child context (Some node))) children; node -let view ~render_block model_source raw_source send : t = +let view ?(compact = false) ~render_block model_source raw_source send : t = fun context parent -> let own source = Signal.own_signal context.ui_scope source in let source = own (Signal.map decode raw_source) in @@ -25,7 +25,7 @@ let view ~render_block model_source raw_source send : t = let all_properties = own (Signal.map (fun json -> list (field "properties" json)) source) in let properties = own (Signal.map2 (fun values expanded -> if expanded then values else List.filteri (fun index _ -> index < 3) values) all_properties (Signal.value expanded)) in - View_base.with_string_prop Lui_protocol.FontSize "16" (column ~gap:2 ~padding_horizontal:16 ~margin_bottom:12 ~cross:`stretch + View_base.with_string_prop Lui_protocol.FontSize (if compact then "14" else "16") (column ~gap:(if compact then 0 else 2) ~padding_horizontal:(if compact then 0 else 16) ~margin_bottom:(if compact then 0 else 12) ~cross:`stretch ~accessibility_identifier:("properties." ^ owner) [ keyed ~source:properties ~key:(fun property -> text (field "attr" property)) ~cmp:String.compare ~mount:(fun property_source -> fun context parent -> @@ -66,7 +66,7 @@ let view ~render_block model_source raw_source send : t = ~foreground:"muted-foreground" ~style_class:"callout" ~text_alignment:`start ~accessibility_identifier:("property.label." ^ owner ^ "." ^ attr) [] in let value = button ~variant:`ghost ~foreground:"muted-foreground" ~text_alignment:`start - ~min_height:32 ~padding_horizontal:0 ~padding_vertical:0 ~grow:1.0 + ~min_height:(if compact then 28 else 32) ~padding_horizontal:0 ~padding_vertical:0 ~grow:1.0 ~text_signal:(own (Signal.map (fun p -> let value = text (field "text" p) in if value = "" then "Empty" else value) property_source)) ~label:(text (field "title" first)) ~disabled:(field "editable" first <> `Bool true) ~accessibility_identifier:("property.value." ^ owner ^ "." ^ attr) ~on_press:(fun _ -> edit ()) [] in diff --git a/shared/src/logseq/view_property.mli b/shared/src/logseq/view_property.mli index 7fa4fe57..0cf65340 100644 --- a/shared/src/logseq/view_property.mli +++ b/shared/src/logseq/view_property.mli @@ -1,3 +1,3 @@ -val view : render_block:(Model.outline_row Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t) -> +val view : ?compact:bool -> render_block:(Model.outline_row Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t) -> Model.logseq_model Signal.signal -> string Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val has_properties : string -> bool diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 94513971..de8781dd 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -426,7 +426,7 @@ let journal_outline_row uuid page_id title journal_title journal_day depth = { Model.row_uuid = uuid; row_loaded = true; - row_list_number = None; + row_task_dates_json = ""; row_list_number = None; row_display_type = None; row_title = title; markup_json = "[]"; query_json = ""; @@ -2762,7 +2762,7 @@ let outline_row uuid title = { Model.row_uuid = uuid; row_loaded = true; - row_list_number = None; + row_task_dates_json = ""; row_list_number = None; row_display_type = None; row_title = title; markup_json = "[]"; query_json = ""; diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index fa8474b1..5ca8514f 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -431,7 +431,62 @@ let command_groups_follow_master_order () = (List.for_all (fun candidate -> member "group" candidate = `Null) (choices response)))) [App_test.ios_backend; App_test.kotlin_backend] +let enter_on_a_parent_matches_master_placement () = + List.iter (fun (collapsed, caret, expected_parent, expected_title) -> R.with_runtime (fun session runtime conn -> + let child = "70000000-0000-4000-8000-000000000001" in + rebase runtime conn (R.entity 220 child "Existing child" @ [ + put child "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put child "block/parent" (Ds.Ref_to (Q.ref_uuid Q.alpha)); put child "block/order" (Ds.String "a0")]); + if collapsed then ignore (event session "toggleCollapsed" ["uuid", `String Q.alpha]); + ignore (event session "tapBlock" ["uuid", `String Q.alpha]); + ignore (type_at session "Hello" caret); + let response = event session "returnPressed" [] in + let uuid = text (member "uuid" (draft response)) in + let blocks = Graph_runtime.blocks_for_page runtime Q.page in + let created = List.find (fun (b : Cache_model.block) -> b.uuid = uuid) blocks in + check_eq ~msg:"Enter chooses the same parent as master" created.parent_id (Some expected_parent); + check_eq created.title expected_title; + let existing = List.find (fun (b : Cache_model.block) -> b.uuid = child) blocks in + check_eq ~msg:"existing children keep their identity and parent" existing.parent_id (Some Q.alpha); + if expected_parent = Q.alpha then check ~msg:"the new child comes before existing children" (created.order < existing.order); + if caret = 0 then begin + let parent = List.find (fun (b : Cache_model.block) -> b.uuid = Q.alpha) blocks in + check_eq ~msg:"Enter at the start preserves the original block" parent.title "Hello"; + check ~msg:"Enter at the start inserts above the parent" (created.order < parent.order) + end; + List.iter (fun operation -> match Graph_runtime.prepare_sync runtime operation with + | Ok _ -> () | Error message -> fail message) (Graph_runtime.pending_operations runtime))) + [false, 5, Q.alpha, ""; false, 2, Q.alpha, "llo"; true, 5, Q.page, ""; false, 0, Q.page, ""] + +let task_dates_are_visible_and_editable_in_the_outline () = + List.iter (fun backend -> R.with_runtime (fun session runtime _ -> + List.iter (fun label -> ignore (run session label); ignore (submit session "1792719000000"); save session) + ["Scheduled"; "Deadline"]; + let response = snapshot session in + let block = List.find (fun block -> member "uuid" block = `String Q.alpha) (items "blocks" response) in + let dates = items "properties" (member "taskDates" block) in + check_eq ~msg:"ordinary outline rows include populated task dates" (List.length dates) 2; + List.iter (fun date -> let value = text (member "text" date) in + check ~msg:"dates are local labels rather than numeric timestamps" (value <> "" && not (String_kit.includes ~sub:"1792719000000" value))) dates; + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + List.iter (fun attr -> + let value = find ("property.value." ^ Q.alpha ^ "." ^ attr) in + check ~msg:"task date is visible before zooming in" (value <> -1); + App_test.dispatch app (Lui_protocol.Press value); App_test.flush app; + check ~msg:"task date opens the existing native date editor" + (find ("view.edit.sheet.property." ^ Q.alpha ^ "." ^ attr) <> -1); + let cancel = find ("view.edit.cancel.property." ^ Q.alpha ^ "." ^ attr) in + check ~msg:"date editor has Cancel" (cancel <> -1); + App_test.dispatch app (Lui_protocol.Press cancel); App_test.flush app) + ["logseq.property/scheduled"; "logseq.property/deadline"]); + check ~msg:"displaying task dates does not add extra semantic writes" (List.length (Graph_runtime.pending_operations runtime) < 10))) + [App_test.ios_backend; App_test.kotlin_backend] + let cases = [ + case "Enter on a parent matches master placement" enter_on_a_parent_matches_master_placement; + case "task dates are visible and editable in the outline" task_dates_are_visible_and_editable_in_the_outline; case "command groups follow master order" command_groups_follow_master_order; case "display titles resolve UUID references" display_titles_resolve_references_without_changing_source; case "views use an intrinsic native select" saved_views_use_an_intrinsic_native_select; diff --git a/shared/test/logseq/outliner_effects_test.ml b/shared/test/logseq/outliner_effects_test.ml index 1f1f4f26..658dea5f 100644 --- a/shared/test/logseq/outliner_effects_test.ml +++ b/shared/test/logseq/outliner_effects_test.ml @@ -62,7 +62,7 @@ let only_operation (result : Effects.outliner_effects) = let split uuid before after : State.outliner_command = State.Split_at - { split_uuid = uuid; split_expected_title = "Second"; before; after } + { split_uuid = uuid; split_placement = State.After; split_expected_title = "Second"; before; after } let split_generates_between_sibling_order_and_focus () = let result = diff --git a/shared/test/logseq/outliner_state_test.ml b/shared/test/logseq/outliner_state_test.ml index 542fffeb..090cc459 100644 --- a/shared/test/logseq/outliner_state_test.ml +++ b/shared/test/logseq/outliner_state_test.ml @@ -60,6 +60,7 @@ let split uuid expected before after = State.Split_at { split_uuid = uuid; + split_placement = State.After; split_expected_title = expected; before; after; From a4f7c43ee31162070c207e73c85f70a2c9bb59a3 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:55:05 +0800 Subject: [PATCH 05/25] Align editor boundaries and compact task properties with native controls --- .../com/logseq/app/runtime/LogseqIcons.kt | 1 + .../app/ui/extensions/PropertyExtensions.kt | 5 ++- .../Sources/Logseq/LGPropertyExtension.swift | 2 +- apple/Sources/Logseq/LGRenderer.swift | 1 + .../Sources/Logseq/OutlinerRowExtension.swift | 3 +- apple/Sources/Logseq/OutlinerTextRow.swift | 6 +-- .../deadline.imageset/Contents.json | 1 + .../deadline.imageset/deadline.svg | 1 + apple/Tests/UI/outliner-row.swift | 7 ++++ docs/agent-guide/014-editor-commands.md | 8 ++++ shared/src/logseq/core/editor_command.ml | 9 ++--- shared/src/logseq/core/outliner_state.ml | 8 +++- shared/src/logseq/view_outliner.ml | 9 +++-- shared/src/logseq/view_property.ml | 28 ++++++++++++- shared/test/logseq/editor_command_test.ml | 40 ++++++++++++++++--- shared/test/logseq/rpc_test.ml | 14 +++++-- 16 files changed, 115 insertions(+), 28 deletions(-) create mode 100644 apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/Contents.json create mode 100644 apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/deadline.svg diff --git a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqIcons.kt b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqIcons.kt index ac3f3cad..eddbd685 100644 --- a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqIcons.kt +++ b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqIcons.kt @@ -144,6 +144,7 @@ internal val logseqAppIcons: Map = mapOf( "arrow-up" to Icons.Filled.ArrowUpward, "arrow-down" to Icons.Filled.ArrowDownward, "calendar" to Icons.Outlined.DateRange, + "deadline" to Icons.Outlined.Flag, "chevron-down" to Icons.Filled.KeyboardArrowDown, "chevron-up" to Icons.Filled.KeyboardArrowUp, "chevron-right" to Icons.AutoMirrored.Filled.KeyboardArrowRight, diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt index ed28248d..5ac95f20 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt @@ -58,9 +58,10 @@ private fun PropertyRow(context: LuiExtensionContext) { Layout(content = { context.Children() }, modifier = Modifier.fillMaxWidth()) { children, constraints -> if (children.size != 2) return@Layout layout(0, 0) {} val width = constraints.maxWidth - val keyWidth = minOf(148.dp.roundToPx(), (width * 0.46f).toInt()) + val intrinsic = children[0].maxIntrinsicWidth(Constraints.Infinity) + val keyWidth = if (intrinsic <= 24.dp.roundToPx()) intrinsic else minOf(144.dp.roundToPx(), (width * 0.44f).toInt()) val gap = 8.dp.roundToPx() - val vertical = stacked || children[0].maxIntrinsicWidth(Constraints.Infinity) > keyWidth || width - keyWidth - gap < 96.dp.roundToPx() + val vertical = stacked || intrinsic > keyWidth || width - keyWidth - gap < 96.dp.roundToPx() val key = children[0].measure(Constraints(maxWidth = if (vertical) width else keyWidth)) val value = children[1].measure(Constraints(maxWidth = if (vertical) width else width - keyWidth - gap)) val rowGap = 2.dp.roundToPx() diff --git a/apple/Sources/Logseq/LGPropertyExtension.swift b/apple/Sources/Logseq/LGPropertyExtension.swift index f58c007c..98ff0d40 100644 --- a/apple/Sources/Logseq/LGPropertyExtension.swift +++ b/apple/Sources/Logseq/LGPropertyExtension.swift @@ -38,8 +38,8 @@ private struct LGPropertyRowLayout: Layout { let stacked: Bool private func metrics(width: CGFloat, subviews: Subviews) -> (Bool, CGFloat, CGSize, CGSize) { - let keyWidth = min(148, width * 0.46) let intrinsic = subviews[0].sizeThatFits(.unspecified) + let keyWidth = intrinsic.width <= 24 ? intrinsic.width : min(144, width * 0.44) let vertical = stacked || intrinsic.width > keyWidth || width - keyWidth - 8 < 96 let key = subviews[0].sizeThatFits(.init(width: vertical ? width : keyWidth, height: nil)) let value = subviews[1].sizeThatFits(.init(width: vertical ? width : width - keyWidth - 8, height: nil)) diff --git a/apple/Sources/Logseq/LGRenderer.swift b/apple/Sources/Logseq/LGRenderer.swift index 55aac02c..d857f445 100644 --- a/apple/Sources/Logseq/LGRenderer.swift +++ b/apple/Sources/Logseq/LGRenderer.swift @@ -69,6 +69,7 @@ enum LGIconPolicy { static var icons: [String: LUIAppleIconSource] { var result: [String: LUIAppleIconSource] = [ "calendar": .assetName("calendar"), + "deadline": .assetName("deadline"), "add": .assetName("plus"), "composer-add": .systemName("plus"), "composer-file": .systemName("doc"), diff --git a/apple/Sources/Logseq/OutlinerRowExtension.swift b/apple/Sources/Logseq/OutlinerRowExtension.swift index 1da43305..90eb3546 100644 --- a/apple/Sources/Logseq/OutlinerRowExtension.swift +++ b/apple/Sources/Logseq/OutlinerRowExtension.swift @@ -259,7 +259,8 @@ struct OutlinerRowPayload: Decodable, Equatable { Button { emit("collapse") } label: { Image(systemName: row.collapsed ? "arrowtriangle.right.fill" : "arrowtriangle.down.fill") .font(.system(size: 10)).foregroundStyle(.secondary) - .frame(width: 28, height: 24) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(row.collapsed ? "Expand block" : "Collapse block") diff --git a/apple/Sources/Logseq/OutlinerTextRow.swift b/apple/Sources/Logseq/OutlinerTextRow.swift index 1e1ac9c0..e308b180 100644 --- a/apple/Sources/Logseq/OutlinerTextRow.swift +++ b/apple/Sources/Logseq/OutlinerTextRow.swift @@ -222,7 +222,7 @@ struct OutlinerTextRow: UIViewRepresentable { guard let row else { return 0 } let inset = CGFloat(max(0, row.depth)) * 22 let statusWidth: CGFloat = row.status == nil ? 0 : OutlinerLayoutMetrics.statusIconSize + OutlinerLayoutMetrics.statusContentSpacing - let trailing: CGFloat = row.hasChildren ? 28 : 0 + let trailing: CGFloat = row.hasChildren ? 44 : 0 let priorityWidth: CGFloat = row.priority == nil ? 0 : 25 let iconLeading: CGFloat = row.status != nil ? OutlinerLayoutMetrics.statusLeading : (row.priority != nil ? 20 : 26) let leading = inset + iconLeading + statusWidth + priorityWidth @@ -234,7 +234,7 @@ struct OutlinerTextRow: UIViewRepresentable { status.frame = CGRect(x: inset + iconLeading, y: 2, width: OutlinerLayoutMetrics.statusIconSize, height: 24) priority.frame = CGRect(x: inset + iconLeading + statusWidth, y: 2, width: 20, height: 24) title.frame = CGRect(x: leading, y: 2, width: bodyWidth, height: titleHeight) - disclosure.frame = CGRect(x: width - trailing, y: 2, width: 28, height: 24) + disclosure.frame = CGRect(x: width - trailing, y: 0, width: 44, height: 44) } if !tags.isEmpty { var x = leading @@ -250,7 +250,7 @@ struct OutlinerTextRow: UIViewRepresentable { if apply { failure.frame = CGRect(x: leading, y: height, width: bodyWidth, height: size.height) } height += size.height + 2 } - return height + return row.hasChildren ? max(44, height) : height } override func sizeThatFits(_ size: CGSize) -> CGSize { CGSize(width: size.width, height: layout(width: size.width, apply: false)) } override func layoutSubviews() { diff --git a/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/Contents.json b/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/Contents.json new file mode 100644 index 00000000..2724c9c2 --- /dev/null +++ b/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"deadline.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} diff --git a/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/deadline.svg b/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/deadline.svg new file mode 100644 index 00000000..6ac83e76 --- /dev/null +++ b/apple/Sources/Logseq/Resources/Icons.xcassets/deadline.imageset/deadline.svg @@ -0,0 +1 @@ + diff --git a/apple/Tests/UI/outliner-row.swift b/apple/Tests/UI/outliner-row.swift index d0391761..a73a4f2f 100644 --- a/apple/Tests/UI/outliner-row.swift +++ b/apple/Tests/UI/outliner-row.swift @@ -54,6 +54,13 @@ import UIKit element("outliner.sync-failed.row") != nil else { throw NSError(domain: "OutlinerRowCheck", code: 1, userInfo: [NSLocalizedDescriptionKey: "Native row controls must actually render"]) } + let disclosure = element("button.outliner.collapse.row") as! UIButton + precondition(disclosure.bounds.width >= 44 && disclosure.bounds.height >= 44, + "Disclosure must have a full 44-point touch target") + let nearEdge = CGPoint(x: disclosure.bounds.minX + 2, y: disclosure.bounds.maxY - 2) + let pointInRow = disclosure.convert(nearEdge, to: disclosure.superview) + precondition(disclosure.superview?.hitTest(pointInRow, with: nil) === disclosure, + "Tapping the edge of the disclosure target must not edit the title") var missing: [String] = [] for identifier in ["button.outliner.zoom.row.Visible title", "button.block-task-status"] { guard let button = element(identifier) as? UIButton else { fatalError("Missing glyph control") } diff --git a/docs/agent-guide/014-editor-commands.md b/docs/agent-guide/014-editor-commands.md index 2646c8f0..346a8d66 100644 --- a/docs/agent-guide/014-editor-commands.md +++ b/docs/agent-guide/014-editor-commands.md @@ -77,3 +77,11 @@ YouTube timestamp insertion needs the active player position when available and - iOS native runtime check passed for shared command/reference/tag completion, keyboard and caret retention, and real clipboard paste. - Android app and instrumentation APKs built; both native completion and clipboard-paste checks passed. - No graph reset or uninstall is needed for the device update. + +## Follow-up parity fixes + +- Backspace keeps the native UTF-16 caret at the boundary between merged titles. +- Heading levels precede Clear heading, and graph priorities precede No priority. The duplicate Normal text command is removed in mobile and upstream PR 13826. +- Native disclosure controls expose a 44-point touch target, including its edges. +- Placeholder blocks use the regular bullet asset with reduced opacity. +- The shared properties renderer uses compact task-date icons and local readable datetime labels. diff --git a/shared/src/logseq/core/editor_command.ml b/shared/src/logseq/core/editor_command.ml index 2a7bad38..7864affa 100644 --- a/shared/src/logseq/core/editor_command.ml +++ b/shared/src/logseq/core/editor_command.ml @@ -29,15 +29,14 @@ let catalog db = entry "image" "Image link" (Pick Image); entry "underline" "Underline" (Insert ("", 6)); entry "code" "Code block" (Code None); entry "quote" "Quote" (Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "quote")]); - entry "math" "Math block" (Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "math")]); - entry "normal" "Normal text" (Properties ["logseq.property/heading", None]); - entry "clear-heading" "Clear heading" (Properties ["logseq.property/heading", None])] + entry "math" "Math block" (Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "math")])] @ List.init 6 (fun i -> let n = i + 1 in entry ("heading-" ^ string_of_int n) ("Heading " ^ string_of_int n) (Properties ["logseq.property/heading", Some (Ops.Int_value n)])) + @ [entry "clear-heading" "Clear heading" (Properties ["logseq.property/heading", None])] @ choices "logseq.property/status" @ [entry "deadline" "Deadline" (Pick Deadline); entry "scheduled" "Scheduled" (Pick Scheduled)] - @ [entry "no-priority" "No priority" (Choice ("logseq.property/priority", None))] @ choices "logseq.property/priority" + @ [entry "no-priority" "No priority" (Choice ("logseq.property/priority", None))] @ [ entry "tomorrow" "Tomorrow" (Journal 1); entry "yesterday" "Yesterday" (Journal (-1)); entry "today" "Today" (Journal 0); entry "time" "Current time" Time; entry "date" "Date picker" (Pick Date); @@ -54,7 +53,7 @@ let group value = let id = String.sub value 8 (String.length value - 8) in Some (if id = "embed" then "BASIC" else if List.mem id ["link"; "image"; "underline"; "code"; "quote"; "math"] then "FORMAT" - else if List.mem id ["normal"; "clear-heading"] || String.starts_with ~prefix:"heading-" id then "Heading" + else if id = "clear-heading" || String.starts_with ~prefix:"heading-" id then "Heading" else if String.starts_with ~prefix:"logseq.property/status:" id then "TASK STATUS" else if List.mem id ["deadline"; "scheduled"] then "TASK DATE" else if id = "no-priority" || String.starts_with ~prefix:"logseq.property/priority:" id then "PRIORITY" diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index 354ee9db..e8bc1216 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -1606,7 +1606,13 @@ let update context (state : outliner_state) message = staged_editing context state value.uuid value.title false | Ops.Merge_backward value -> (match find_block context value.previous_uuid with - | Some block -> staged_editing context state block.uuid block.title false + | Some block -> + let previous = {block with title = value.expected_previous_title} in + let caret = utf16_length (display_block_title context previous) in + let next, commands = staged_editing context state block.uuid block.title false in + let editing = Option.map (fun (draft : editor_draft) -> + {draft with caret = min caret (utf16_length draft.title)}) next.editing in + ({next with editing}, commands) | None -> step state []) | Ops.Save_title value -> (match state.editing with diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index e57d647b..8df294af 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -67,7 +67,7 @@ let outliner_collapse_button (context : Lui_ui.ui_context) row_source send : View_base.outliner_collapse_icon_name current_row.is_collapsed) row_source) - (button ~variant:`ghost ~width:28 ~height:28 + (button ~variant:`ghost ~width:44 ~height:44 ~accessibility_identifier: (View_base.outliner_collapse_identifier current_row) ~on_press:(fun _ -> @@ -86,7 +86,7 @@ let outliner_collapse_button (context : Lui_ui.ui_context) row_source send : else "app:disclosure-down") row_source) (button ~size:`icon ~style_class:"body-line" - ~foreground:"secondary" ~variant:`ghost ~width:28 ~height:24 + ~foreground:"secondary" ~variant:`ghost ~width:44 ~height:44 ~accessibility_identifier: (View_base.outliner_collapse_identifier current_row) ~on_press:(fun _ -> @@ -1046,10 +1046,11 @@ let page_loading_view model_source : t = let placeholder_block context (value : View_rows.placeholder) send : t = let placeholder : t = fun mount_context parent -> - let node = box ~height:44 ~padding_horizontal:8 + let node = box ~height:44 ~accessibility_identifier:("button.outliner.placeholder." ^ value.parent_uuid) [row ~cross:`center ~gap:0 - [text ~width:24 ~foreground:"muted-foreground" ~value:"•" []; + [box ~opacity:0.35 [icon ~name:(`app "outliner-bullet") ~width:24 ~height:24 ~foreground:"muted-foreground" + ~accessibility_identifier:("outliner.placeholder.bullet." ^ value.parent_uuid) []]; box ~grow:1.0 []]] mount_context parent in Lui_ui.string_property mount_context node AccessibilityLabel "Add block"; Lui_ui.bool_property mount_context node PressEnabled true; diff --git a/shared/src/logseq/view_property.ml b/shared/src/logseq/view_property.ml index 0659413b..7be34284 100644 --- a/shared/src/logseq/view_property.ml +++ b/shared/src/logseq/view_property.ml @@ -6,6 +6,24 @@ let list = function `List values -> values | _ -> [] let decode raw = try J.from_string raw with _ -> `Null let has_properties raw = list (field "properties" (decode raw)) <> [] +let display_value property = + let raw = text (field "text" property) in + if field "kind" property <> `String "datetime" then raw else + match float_of_string_opt (text (field "editorValue" property)) with + | Some millis when Float.is_finite millis && abs_float millis <= 8640000000000000. -> + let value = Unix.localtime (millis /. 1000.) in + let now = Unix.localtime (Unix.gettimeofday ()) in + let day t = t.Unix.tm_year, t.tm_mon, t.tm_mday in + let noon, _ = Unix.mktime {now with tm_hour = 12; tm_min = 0; tm_sec = 0} in + let date = if day value = day now then "Today" + else if day value = day (Unix.localtime (noon +. 86400.)) then "Tomorrow" + else if day value = day (Unix.localtime (noon -. 86400.)) then "Yesterday" + else let months = [|"Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; "Jul"; "Aug"; "Sep"; "Oct"; "Nov"; "Dec"|] in + Printf.sprintf "%s %d%s" months.(value.tm_mon) value.tm_mday + (if value.tm_year = now.tm_year then "" else ", " ^ string_of_int (value.tm_year + 1900)) in + Printf.sprintf "%s, %02d:%02d" date value.tm_hour value.tm_min + | _ -> raw + let adaptive_row stacked children : t = fun context parent -> let node = Lui_ui.extension context "native-property-row" in attach context parent node; @@ -62,12 +80,18 @@ let view ?(compact = false) ~render_block model_source raw_source send : t = let send_block = function | Model.BeginOutlinerEdit uuid -> edit ~value_uuid:uuid (); true | action -> send action in - let label = Lui_elements.text ~value_signal:(own (Signal.map (fun p -> text (field "title" p)) property_source)) + let task_icon = match attr with "logseq.property/scheduled" -> Some "calendar" + | "logseq.property/deadline" -> Some "deadline" | _ -> None in + let label = match task_icon with + | Some name -> icon ~name:(`app name) ~width:20 ~height:20 ~foreground:"muted-foreground" + ~tooltip:(text (field "title" first)) + ~accessibility_identifier:("property.label." ^ owner ^ "." ^ attr) [] + | None -> Lui_elements.text ~value_signal:(own (Signal.map (fun p -> text (field "title" p)) property_source)) ~foreground:"muted-foreground" ~style_class:"callout" ~text_alignment:`start ~accessibility_identifier:("property.label." ^ owner ^ "." ^ attr) [] in let value = button ~variant:`ghost ~foreground:"muted-foreground" ~text_alignment:`start ~min_height:(if compact then 28 else 32) ~padding_horizontal:0 ~padding_vertical:0 ~grow:1.0 - ~text_signal:(own (Signal.map (fun p -> let value = text (field "text" p) in if value = "" then "Empty" else value) property_source)) + ~text_signal:(own (Signal.map (fun p -> let value = display_value p in if value = "" then "Empty" else value) property_source)) ~label:(text (field "title" first)) ~disabled:(field "editable" first <> `Bool true) ~accessibility_identifier:("property.value." ^ owner ^ "." ^ attr) ~on_press:(fun _ -> edit ()) [] in let checkbox_value = checkbox ~foreground:"muted-foreground" ~width:32 ~height:32 diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 5ca8514f..67a3ff84 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -61,13 +61,13 @@ let catalog_search_and_literal_boundaries () = R.with_runtime (fun session _ _ - let labels = List.map (fun row -> text (member "label" row)) (choices response) in List.iter (fun label -> check ~msg:("built-in command missing: " ^ label) (List.mem label labels)) ["Node embed"; "Link"; "Image link"; "Underline"; "Code block"; "Quote"; "Math block"; - "Normal text"; "Clear heading"; "Heading 1"; "Heading 6"; "Deadline"; "Scheduled"; + "Clear heading"; "Heading 1"; "Heading 6"; "Deadline"; "Scheduled"; "Tomorrow"; "Yesterday"; "Today"; "Current time"; "Date picker"; "Number list"; "Number children"; "Calculator"; "Template"; "Embed HTML"; "Embed Video URL"; "Embed YouTube timestamp"; "Embed Twitter tweet"; "Cloze"; "No priority"]; List.iter (fun label -> check ~msg:("excluded command: " ^ label) (not (List.mem label labels))) ["Query"; "Advanced Query"; "Query function"; "Add property"; "Add comment"; - "Upload an asset"; "Node reference"]; + "Upload an asset"; "Node reference"; "Normal text"]; let response = start session "/h6" in check ~msg:"command search is fuzzy" (List.exists (fun row -> member "label" row = `String "Heading 6") (choices response)); List.iter (fun title -> let response = start session title in @@ -112,7 +112,7 @@ let graph_choices_headings_and_block_types () = R.with_runtime (fun session runt check_eq (value runtime uuid "logseq.property.node/display-type") (Some (Ds.Keyword typ)); if label = "Calculator" then check_eq (value runtime uuid "logseq.property.code/lang") (Some (Ds.String "calc"))) ["Quote", "quote"; "Math block", "math"; "Code block", "code"; "Calculator", "code"]; - ignore (run session "Normal text"); save session; + ignore (run session "Clear heading"); save session; check_eq (value runtime Q.alpha "logseq.property/heading") None; ignore (run session "Todo"); save session; check ~msg:"task statuses are graph choices" (value runtime Q.alpha "logseq.property/status" <> None); @@ -420,8 +420,8 @@ let command_groups_follow_master_order () = let labels = List.map (fun candidate -> text (member "label" candidate)) commands in let before a b = check ~msg:(a ^ " precedes " ^ b) (Option.get (List.find_index ((=) a) labels) < Option.get (List.find_index ((=) b) labels)) in - before "Link" "Image link"; before "Image link" "Underline"; before "Normal text" "Heading 1"; - before "No priority" "Low"; before "Calculator" "Template"; before "Embed Twitter tweet" "Cloze"; + before "Link" "Image link"; before "Image link" "Underline"; before "Heading 6" "Clear heading"; + before "Low" "No priority"; before "Calculator" "Template"; before "Embed Twitter tweet" "Cloze"; Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> let host = Lui_app.runtime app in List.iter (fun group -> check ~msg:("native group header " ^ group) @@ -458,6 +458,30 @@ let enter_on_a_parent_matches_master_placement () = | Ok _ -> () | Error message -> fail message) (Graph_runtime.pending_operations runtime))) [false, 5, Q.alpha, ""; false, 2, Q.alpha, "llo"; true, 5, Q.page, ""; false, 0, Q.page, ""] +let backspace_at_start_keeps_the_caret_at_the_merge_boundary () = + List.iter (fun (previous_title, nested, collapsed) -> R.with_runtime (fun session runtime conn -> + let descendant = "70000000-0000-4000-8000-000000000002" in + rebase runtime conn ([put Q.alpha "block/title" (Ds.String previous_title); + put Q.beta "block/title" (Ds.String "Hello")] + @ (if nested then [put Q.beta "block/parent" (Ds.Ref_to (Q.ref_uuid Q.alpha))] else []) + @ R.entity 221 descendant "Preserved child" @ [ + put descendant "block/page" (Ds.Ref_to (Q.ref_uuid Q.page)); + put descendant "block/parent" (Ds.Ref_to (Q.ref_uuid Q.beta)); + put descendant "block/order" (Ds.String "a0")]); + if collapsed then ignore (event session "toggleCollapsed" ["uuid", `String Q.beta]); + ignore (event session "tapBlock" ["uuid", `String Q.beta]); + ignore (type_at session "Hello" 0); + let response = event session "backspacePressed" ["title", `String "Hello"; "selectionLength", `Int 0] in + check_eq ~msg:"Backspace edits the surviving previous block" (member "uuid" (draft response)) (`String Q.alpha); + check_eq ~msg:"merged text is preserved" (member "title" (draft response)) (`String (previous_title ^ "Hello")); + check_eq ~msg:"caret stays between the previous title and Hello, in native UTF-16 units" + (member "caretUTF16Offset" (draft response)) (`Int (Outliner_state.utf16_length previous_title)); + let blocks = Graph_runtime.blocks_for_page runtime Q.page in + check ~msg:"only the merged source is removed" (not (List.exists (fun (b : Cache_model.block) -> b.uuid = Q.beta) blocks)); + check_eq ~msg:"source children survive under the merged block" + (List.find (fun (b : Cache_model.block) -> b.uuid = descendant) blocks).parent_id (Some Q.alpha))) + ["World", false, false; "中文🙂", false, true; "Parent", true, false] + let task_dates_are_visible_and_editable_in_the_outline () = List.iter (fun backend -> R.with_runtime (fun session runtime _ -> List.iter (fun label -> ignore (run session label); ignore (submit session "1792719000000"); save session) @@ -474,6 +498,11 @@ let task_dates_are_visible_and_editable_in_the_outline () = List.iter (fun attr -> let value = find ("property.value." ^ Q.alpha ^ "." ^ attr) in check ~msg:"task date is visible before zooming in" (value <> -1); + let label = find ("property.label." ^ Q.alpha ^ "." ^ attr) in + check_eq ~msg:"task date keys use compact icons" (App_test.property_string host label Lui_protocol.IconName) + ("app:" ^ if attr = "logseq.property/scheduled" then "calendar" else "deadline"); + check ~msg:"date text uses a readable month rather than a numeric date" + (not (String_kit.includes ~sub:"2026-10-" (App_test.property_string host value Lui_protocol.TextValue))); App_test.dispatch app (Lui_protocol.Press value); App_test.flush app; check ~msg:"task date opens the existing native date editor" (find ("view.edit.sheet.property." ^ Q.alpha ^ "." ^ attr) <> -1); @@ -485,6 +514,7 @@ let task_dates_are_visible_and_editable_in_the_outline () = [App_test.ios_backend; App_test.kotlin_backend] let cases = [ + case "Backspace keeps the caret at the merge boundary" backspace_at_start_keeps_the_caret_at_the_merge_boundary; case "Enter on a parent matches master placement" enter_on_a_parent_matches_master_placement; case "task dates are visible and editable in the outline" task_dates_are_visible_and_editable_in_the_outline; case "command groups follow master order" command_groups_follow_master_order; diff --git a/shared/test/logseq/rpc_test.ml b/shared/test/logseq/rpc_test.ml index d78b448a..d673c7b5 100644 --- a/shared/test/logseq/rpc_test.ml +++ b/shared/test/logseq/rpc_test.ml @@ -3206,7 +3206,7 @@ let split_block_does_not_inherit_task_or_asset_metadata () = [ "tags"; "references"; "breadcrumbs" ]; check_eq (member "isAsset" block) (`Bool false) -let journal_split_reads_one_page_and_inserts_after_source_subtree () = +let journal_split_reads_one_page_and_inserts_the_first_child () = let page : Model.entity_summary = { uuid = "journal-today"; title = "Today" } in @@ -3296,6 +3296,12 @@ let journal_split_reads_one_page_and_inserts_after_source_subtree () = updated_at = change.created_at; }; ]) + | Ops.Atomic [Ops.Save_title change; Ops.Insert_block inserted] -> + today_blocks := List.map (fun (block : Model.block) -> + if block.uuid = change.uuid then {block with title = change.title} else block) !today_blocks + @ [{source with uuid = inserted.uuid; title = inserted.title; + parent_id = Some inserted.parent_uuid; page_id = inserted.page_uuid; + order = Some inserted.order; created_at = inserted.created_at; updated_at = inserted.created_at}] | _ -> ignore !today_blocks); Ok ()); prepare_operation = Some prepare_operation; @@ -3313,7 +3319,7 @@ let journal_split_reads_one_page_and_inserts_after_source_subtree () = check_eq !page_reads 1; check_eq (List.length splices) 1; let splice = List.hd splices in - check_eq (member "afterBlockId" splice) (`String child.uuid); + check_eq (member "afterBlockId" splice) (`String source.uuid); check_eq (List.length (json_items "rows" splice)) 1 let graph_switching_keeps_optimistic_models_isolated () = @@ -6704,8 +6710,8 @@ let cases = editing_status_uses_live_properties_instead_of_stale_overlay; case "split block does not inherit task or asset metadata" split_block_does_not_inherit_task_or_asset_metadata; - case "journal split reads one page and inserts after source subtree" - journal_split_reads_one_page_and_inserts_after_source_subtree; + case "journal split reads one page and inserts the first child" + journal_split_reads_one_page_and_inserts_the_first_child; case "graph switching keeps optimistic models isolated" graph_switching_keeps_optimistic_models_isolated; case "collapse finishes editor and saves title exactly once" From 3794df1dbd827cd6ab2ef50d2c51fa84145febcb Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 22 Oct 2026 23:55:51 +0800 Subject: [PATCH 06/25] Use compact typed native filters with master operator semantics --- apple/Tests/UI/query-block-results.swift | 4 +- docs/agent-guide/015-native-view-filters.md | 84 ++++++ shared/src/logseq/core/result_view.ml | 9 + shared/src/logseq/view_query.ml | 267 +++++++++++++------- shared/test/logseq/result_view_test.ml | 12 +- shared/test/logseq/test_main.ml | 1 + shared/test/logseq/view_filter_test.ml | 144 +++++++++++ 7 files changed, 420 insertions(+), 101 deletions(-) create mode 100644 docs/agent-guide/015-native-view-filters.md create mode 100644 shared/test/logseq/view_filter_test.ml diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index 4ce81db7..8693993d 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -48,7 +48,7 @@ import Vision "error": queryError as Any? ?? NSNull()] if includeViews { let columns: [[String: Any]] = [ - ["attr": "block/title", "title": "Name", "kind": "string", "editable": true], + ["attr": "block/title", "title": "Name", "kind": "default", "editable": true, "filterOperators": ["is", "is-not", "text-contains", "text-not-contains"]], ["attr": "user.property/Score", "title": "Score", "kind": "number", "editable": true, "ref": true, "filterValues": (1...3).map { value in ["title": "\(value)", "match": [String(format: "30000000-0000-4000-8000-%012d", value)]] @@ -240,7 +240,7 @@ import Vision try backend.performPress(node: find("view.filter.add.table")!) try await verifySettingsSheet("query-filter-dark.png") let settingsText = try await capture("query-view-settings.png") - guard settingsText.contains("Filters") && settingsText.contains("Filter 1") else { + guard settingsText.contains("Filters") && settingsText.contains("Name") && settingsText.contains("Contains") else { throw failure("Filter and sort controls must render visibly: \(settingsText)") } try backend.performPress(node: find("view.settings.cancel.table")!) diff --git a/docs/agent-guide/015-native-view-filters.md b/docs/agent-guide/015-native-view-filters.md new file mode 100644 index 00000000..316cf173 --- /dev/null +++ b/docs/agent-guide/015-native-view-filters.md @@ -0,0 +1,84 @@ +# Native View Filters Implementation Plan + +Goal: Make view filters compact and give every master property/operator combination a suitable native input. + +Architecture: Publish the authoritative operator list with property metadata in Result_view. +Keep master filter values and matching in Dev_view, and adapt the shared native sheet around those values. +Reuse the native calendar extension for date and datetime entry. + +Tech Stack: OCaml, LUI, SwiftUI, Jetpack Compose. + +Related: Builds on 012-result-views.md, 013-node-properties.md, and 014-editor-commands.md. + +## Problem statement + +The current sheet spends three or more rows on one condition and renders timestamp filters as raw text inputs. +The UI also offers operators that master does not support for several reference types. +A common task such as showing objects created in the last month should require selecting Created and 1 month ago. + +## Testing Plan + +Add RPC tests for the exact operator lists for every property type and built-in timestamps. +Add shared native UI tests that change property/operator, select relative timestamps, use calendar dates, select checkbox states, search reference values, enter numeric ranges, remove a condition, and save the master-compatible payload. +Verify that a saved relative month value stays relative on subsequent reads and matches real objects around the boundary. +Verify local errors, Cancel, All/Any mode, and multiple conditions preserve unrelated view settings. + +NOTE: I will write all tests before I add any implementation behavior. + +## Audit + +The authority is origin/master at 05c3ae73e08498e9e20f1b11c3eac5525e5e588d. +The operator and value transition rules live in src/main/frontend/worker/handler/view.cljs. +Relative timestamp presets and calendar interaction live in src/main/frontend/components/views.cljs. +The common filter engine lives in deps/db/src/logseq/db/common/view.cljs and its mobile port shared/src/logseq/core/dev_view.ml. + +| Property | Master operators | Native input | +| --- | --- | --- | +| Text, URL, node | Is, Is not, Contains, Does not contain | Searchable values for equality; text input for contains | +| Number | Is, Is not, Greater than, Less than, At least, At most, Between | Existing value choices for equality; decimal input and two bounds otherwise | +| Checkbox | Is, Is not | Checked, Unchecked, Empty; one selection | +| Date | Is, Is not, Before, After | Existing day choices for equality; native day picker for comparisons | +| Datetime | Is, Is not, Before, After | Existing values for equality; relative preset or native datetime picker for comparisons | +| Created, Updated | Before, After | Relative preset or native datetime picker | +| Page, entity, class, property and other types | Is, Is not | Searchable existing value choices and Empty | + +## Implementation sequence + +1. Add failing tests to shared/test/logseq/view_filter_test.ml and native UI checks. +2. Publish filterOperators and timestamp metadata from shared/src/logseq/core/result_view.ml. +3. Replace the repeated Filter N/property/operator rows in shared/src/logseq/view_query.ml with one compact property/operator/remove row and one value row. +4. Add the exact master relative presets and a Created in the last month shortcut that stores after with 1 month ago. +5. Reset incompatible values when changing property or operator using the master transition rules. +6. Reuse temporal_picker for custom dates and retain the selected date with local-time display. +7. Use a searchable selection surface for equality values and clearly distinguish Empty from no selection. +8. Run focused and complete core gates, verify both native sheets, commit independently, and reinstall the phone without resetting graph data. + +## Testing Details + +Tests drive the actual shared sheet, inspect its submitted effect, execute it against Graph_runtime, and query the resulting view rows. +Boundary tests use Date_time_util to calculate a calendar month rather than assuming thirty days. +Native checks validate visible compact controls and calendar interaction. + +## Implementation Details + +- Preserve master operator ordering and EDN value shapes. +- Keep relative timestamp strings dynamic; never replace presets with fixed numbers at save time. +- Do not expose Between for timestamps because master only offers Before and After. +- Keep touch targets at least 44 points while removing repeated section headings and redundant controls. +- Keep Cancel and Done in the existing calendar-style themed sheet. +- Keep checkbox values as booleans and reference matches as UUID sets. +- Keep empty-bound numeric ranges supported by the matching engine. +- Hide irrelevant controls as soon as the operator changes. + +## Question + +The scope and the example workflow are confirmed by the user. + +--- + +## Verification + +- The new filter cases first failed for absent operator metadata, missing shortcut and typed controls. +- The complete native core gate passes: 995 tests. +- Filter UI tests run against both shared native host profiles, execute submitted mutations, and verify matching rows and preserved UUID sets. +- The native iOS query-results check covers filter/sort sheet presentation, dark palette, local errors, and cancellation. diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 14435e0e..b6fe2954 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -175,6 +175,15 @@ let column_json db rows p = "journalDay", (match ref_id db value with Some id -> plain (get db id "block/journal-day") | None -> `Null)]) values in `Assoc ["attr", `String p.attr; "title", `String p.title; "kind", `String p.typ; "many", `Bool p.many; "ref", `Bool p.ref_; "editable", `Bool (editable p); + "filterTimestamp", `Bool (List.mem p.attr ["block/created-at"; "block/updated-at"]); + "filterOperators", `List (List.map (fun op -> `String op) + (if List.mem p.attr ["block/created-at"; "block/updated-at"] then ["before"; "after"] + else ["is"; "is-not"] @ (match p.typ with + | "datetime" -> ["before"; "after"] + | "default" | "url" | "node" | "string" -> ["text-contains"; "text-not-contains"] + | "date" -> ["date-before"; "date-after"] + | "number" -> ["number-gt"; "number-lt"; "number-gte"; "number-lte"; "between"] + | _ -> []))); "closed", `Bool (p.choices <> []); "canCreate", `Bool (p.choices = [] && p.typ <> "checkbox" && p.typ <> "property"); "filterValues", `List filter_values; diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index eb6b1538..54db9ba7 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -141,10 +141,7 @@ let operator_title = function | "number-gte" -> "At least" | "number-lt" -> "Less than" | "number-lte" -> "At most" | "between" -> "Between" | "date-before" | "before" -> "Before" | "date-after" | "after" -> "After" | value -> value -let operators kind = ["is"; "is-not"] @ (match kind with - | "number" -> ["number-gt"; "number-gte"; "number-lt"; "number-lte"; "between"] - | "date" -> ["date-before"; "date-after"] | "datetime" -> ["before"; "after"; "between"] - | "checkbox" -> [] | _ -> ["text-contains"; "text-not-contains"]) +let operators col = jlist (jfield "filterOperators" col) |> List.map jtext let choices view = jlist (jfield "columns" view.data) let property_choices view = match jlist (jfield "properties" view.data) with [] -> choices view | values -> values let view_column view attr = Option.value ~default:(`Assoc ["attr", `String attr; "title", `String attr; "kind", `String "default"]) @@ -172,6 +169,44 @@ let filter_value_title col value = match value with | Some choice -> jtext (jfield "title" choice) | None -> text_value value) values) | value -> input_value value +let temporal_picker kind id source set : t = fun context parent -> + let node = Lui_ui.extension context "native-property-date" in + attach context parent node; + Lui_ui.extension_property context node "kind" (Lui_protocol.StringValue kind); + Lui_ui.extension_property context node "identifier" (Lui_protocol.StringValue ("view.edit.date." ^ id)); + Lui_ui.extension_property_signal context node "value" (Signal.map (fun value -> Lui_protocol.StringValue value) source); + Lui_ui.on_event context node (function + | Lui_protocol.ExtensionEvent (_, _, "change", fields) -> + (match Lui_protocol.String_map.find_opt "value" fields with Some (Lui_protocol.StringValue value) -> set value | _ -> ()) + | _ -> ()); + node + +let temporal_default kind = + let now = Unix.gettimeofday () in + if kind = "datetime" then Printf.sprintf "%.0f" (floor (now /. 60.) *. 60000.) + else let t = Unix.localtime now in Printf.sprintf "%04d-%02d-%02d" (t.Unix.tm_year + 1900) (t.tm_mon + 1) t.tm_mday + +let filter_transition op value = + let number = function `Int _ | `Float _ -> true | _ -> false in + let keep = match op with + | "is" | "is-not" -> (match value with `List _ -> true | _ -> false) + | "text-contains" | "text-not-contains" -> (match value with `String _ -> true | _ -> false) + | "between" -> (match value with `List values -> List.for_all (fun value -> value = `Null || number value) values | _ -> false) + | _ -> number value in + if keep then value else `Null +let filter_error (draft : filter_draft) = + let number = function `Null | `Int _ | `Float _ -> true | _ -> false in + if draft.op = "between" then + let values = jlist draft.value in + if not (List.for_all number values) then Some "Enter a number or leave a bound empty." + else (match values with + | [lo; hi] when lo <> `Null && hi <> `Null -> + let n = function `Int n -> float_of_int n | `Float n -> n | _ -> 0. in + if n lo > n hi then Some "Minimum must not exceed maximum." else None + | _ -> None) + else if List.mem draft.op ["number-gt"; "number-lt"; "number-gte"; "number-lte"] && not (number draft.value) + then Some "Enter a valid number." else None + let settings_sheet mode owner feature view_source model_source send close : t = fun context parent -> let initial = Signal.sample view_source in @@ -195,80 +230,129 @@ let settings_sheet mode owner feature view_source model_source send close : t = ~mount:(fun col -> let col = Signal.sample col in menu_item ~text:(jtext (jfield "title" col)) ~selected_signal:(own (Signal.map ((=) (jtext (jfield "attr" col))) value)) ~on_press:(fun _ -> choose (jtext (jfield "attr" col))) [])] in - let filter_row source : t = + let filter_row source : t = fun context parent -> + let own value = Signal.own_signal context.ui_scope value in + let state value = Signal.state context.ui_scheduler value in let first : filter_draft = Signal.sample source in - let attr_source = own (Signal.map (fun (value : filter_draft) -> value.attr) source) in - let col = own (Signal.map2 view_column view_source attr_source) in - let matches = own (Signal.map (fun (value : filter_draft) -> List.mem value.op ["is"; "is-not"]) source) in - let between = own (Signal.map (fun (value : filter_draft) -> value.op = "between") source) in - let bound index label = text_field ~grow:1.0 ~placeholder:label ~label + let id part = "view.filter." ^ part ^ "." ^ initial.id ^ "." ^ string_of_int first.key in + let attr = own (Signal.map (fun (draft : filter_draft) -> draft.attr) source) in + let col = own (Signal.map2 view_column view_source attr) in + let change f = update_filter first.key f in + let set_value value = change (fun draft -> {draft with value}) in + let selecting = state false in + let selection = state `Null in + let query = state "" in + let custom = state false in + let numeric text = match float_of_string_opt (String.trim text) with + | Some n when Float.is_finite n -> `Float n + | _ -> if String.trim text = "" then `Null else `String text in + let number_input part label index = text_field ~grow:1.0 ~placeholder:label ~label + ~accessibility_identifier:(id part) ~text_signal:(own (Signal.map (fun (draft : filter_draft) -> - Option.fold ~none:"" ~some:text_value (List.nth_opt (jlist draft.value) index)) source)) - ~on_input:(local_input (fun text -> update_filter first.key (fun draft -> - let scalar text = match float_of_string_opt (String.trim text) with - | Some value when Float.is_finite value -> `Float value - | _ -> if String.trim text = "" then `Null else `String text in - {draft with value = `List (List.init 2 (fun position -> - if position = index then scalar text else Option.value ~default:`Null (List.nth_opt (jlist draft.value) position)))}))) [] in - list_section ~header:(text ~value_signal:(own (Signal.map (fun values -> - let rec position index = function - | [] -> index | (value : filter_draft) :: _ when value.key = first.key -> index - | _ :: rest -> position (index + 1) rest in - "Filter " ^ string_of_int (position 1 values)) (Signal.value filters))) []) [ - list_item [row ~main:`space_between ~cross:`center [ - choose_property (own (Signal.map (fun col -> jtext (jfield "title" col)) col)) attr_source - (fun attr -> update_filter first.key (fun value -> {value with attr; op = "is"; value = `String "empty"})); - button ~variant:`ghost ~text:"Remove" ~label:"Remove filter" - ~on_press:(fun _ -> Signal.set filters (List.filter (fun (value : filter_draft) -> value.key <> first.key) (Signal.sample (Signal.value filters)))) []]]; - list_item [menu ~text_signal:(own (Signal.map (fun (value : filter_draft) -> operator_title value.op) source)) ~label:"Filter operator" [ - keyed ~source:(own (Signal.map (fun col -> operators (jtext (jfield "kind" col))) col)) ~key:Fun.id ~cmp:String.compare - ~mount:(fun op -> let op = Signal.sample op in menu_item ~text:(operator_title op) - ~on_press:(fun _ -> update_filter first.key (fun value -> {value with op})) [])]]; - if_ ~test:(own (Signal.map2 (fun matches between -> not matches && not between) matches between)) - (list_item [text_field ~text_signal:(own (Signal.map2 (fun col (value : filter_draft) -> filter_value_title col value.value) col source)) - ~placeholder:"Value" ~label:"Filter value" ~accessibility_identifier:("view.filter.value." ^ initial.id ^ "." ^ string_of_int first.key) - ~on_input:(local_input (fun text -> update_filter first.key (fun value -> - let kind = jtext (jfield "kind" (Signal.sample col)) in - let scalar text = match float_of_string_opt text with Some n when Float.is_finite n -> `Float n | _ -> `String text in - let matched = if value.op = "between" then - let values = String.split_on_char ',' text in - `List (List.map (fun value -> if String.trim value = "" then `Null else scalar (String.trim value)) values) - else if (value.op = "is" || value.op = "is-not") && text = "empty" then `String "empty" - else if kind = "checkbox" && (text = "true" || text = "false") then `Bool (text = "true") - else if value.op = "is" || value.op = "is-not" then - let choices = jlist (jfield "filterValues" (Signal.sample col)) in - `List (List.concat_map (fun title -> - match List.find_opt (fun choice -> jfield "title" choice = `String title) choices with - | Some choice -> jlist (jfield "match" choice) - | None -> [if kind = "number" && not (jbool (jfield "ref" (Signal.sample col))) then scalar title else `String title]) (csv text)) - else if kind = "date" then (match int_of_string_opt (String.concat "" (String.split_on_char '-' text)) with - | Some day -> `Assoc ["block/journal-day", `Int day] | _ -> `String text) - else if kind = "number" || kind = "datetime" then scalar text else `String text in - {value with value = matched}))) []]); - if_ ~test:between (list_item [row ~gap:12 [bound 0 "Minimum"; bound 1 "Maximum"]]); - if_ ~test:(own (Signal.map (fun (draft : filter_draft) -> List.mem draft.op ["is"; "is-not"; "date-before"; "date-after"]) source)) - (list_item [row ~gap:8 [ - if_ ~test:matches (button ~text:"Empty" ~variant:`ghost ~on_press:(fun _ -> update_filter first.key (fun value -> {value with value = `String "empty"})) []); - menu ~text_signal:(own (Signal.map2 (fun col (draft : filter_draft) -> - let title = filter_value_title col draft.value in if title = "" then "Select values" else title) col source)) - ~label:"Choose filter value" [ - keyed ~source:(own (Signal.map (fun col -> jlist (jfield "filterValues" col)) col)) ~key:(fun value -> Json.to_string value) ~cmp:String.compare - ~mount:(fun value -> let value = Signal.sample value in menu_item ~text:(jtext (jfield "title" value)) - ~selected_signal:(own (Signal.map (fun (draft : filter_draft) -> - let matched = jfield "match" value in - match matched with `Bool _ -> draft.value = matched - | `List values -> values <> [] && List.for_all (fun value -> List.mem value (jlist draft.value)) values - | _ -> false) source)) - ~on_press:(fun _ -> update_filter first.key (fun draft -> {draft with value = - (if draft.op = "date-before" || draft.op = "date-after" then - `Assoc ["block/journal-day", jfield "journalDay" value] - else match jfield "match" value with - | `List values -> let current = jlist draft.value in - `List (if List.for_all (fun value -> List.mem value current) values then - List.filter (fun value -> not (List.mem value values)) current - else current @ List.filter (fun value -> not (List.mem value current)) values) - | value -> value)})) [])]]]) + input_value (match index with None -> draft.value | Some index -> + Option.value ~default:`Null (List.nth_opt (jlist draft.value) index))) source)) + ~on_input:(local_input (fun text -> change (fun draft -> {draft with value = + match index with None -> numeric text | Some index -> + `List (List.init 2 (fun i -> if i = index then numeric text else + Option.value ~default:`Null (List.nth_opt (jlist draft.value) i)))}))) [] in + let choose_values = button ~variant:`ghost ~text_alignment:`start ~min_height:44 ~grow:1.0 + ~text_signal:(own (Signal.map2 (fun col (draft : filter_draft) -> + match draft.value with `Null | `List [] -> "Select values" | `String "empty" -> "Empty" + | `Bool b -> if b then "Checked" else "Unchecked" | value -> filter_value_title col value) col source)) + ~accessibility_identifier:(id "values") ~on_press:(fun _ -> + Signal.set selection (Signal.sample source).value; Signal.set query ""; Signal.set selecting true) [] in + let values_sheet = + let option key label matched = button ~variant:`ghost ~min_height:44 ~text_alignment:`start + ~text:label ~accessibility_identifier:(id "choice" ^ "." ^ key) + ~icon_signal:(own (Signal.map (fun selected -> `app (if selected = matched then "task-done" else "outliner-bullet")) (Signal.value selection))) + ~on_press:(fun _ -> Signal.set selection matched) [] in + let filtered = own (Signal.map2 (fun col query -> + jlist (jfield "filterValues" col) |> List.filter (fun value -> + String_kit.includes ~sub:(String.lowercase_ascii query) (String.lowercase_ascii (jtext (jfield "title" value))))) col (Signal.value query)) in + let cancel = button ~text:"Cancel" ~variant:`ghost ~style_class:"cancellation-action" + ~accessibility_identifier:(id "values.cancel") ~on_press:(fun _ -> Signal.set selecting false) [] in + let done_ = button ~text:"Done" ~variant:`ghost ~style_class:"confirmation-action" + ~accessibility_identifier:(id "values.done") ~on_press:(fun _ -> set_value (Signal.sample (Signal.value selection)); Signal.set selecting false) [] in + sheet ~text_signal:(own (Signal.map (fun col -> jtext (jfield "title" col)) col)) + ~style_class:"navigation-list navigation-inline-title" ~detents:"medium,large" + ~accessibility_identifier:(id "values.sheet") ~on_dismiss:(fun _ -> Signal.set selecting false) [ + list ~grow:1.0 ~style:`inset_grouped [ + list_section [list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [text_field ~placeholder:"Search values" ~label:"Search values" + ~accessibility_identifier:(id "values.search") ~text_signal:(Signal.value query) + ~on_input:(local_input (Signal.set query)) []]]; + list_section [list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [option "empty" "Empty" (`String "empty")]; + list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [option "any" "Any value" `Null]]; + dyn (fun col -> if jfield "kind" col = `String "checkbox" then + list_section [list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [option "true" "Checked" (`Bool true)]; list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [option "false" "Unchecked" (`Bool false)]] + else list_section [ + keyed ~source:(own (Signal.map (List.mapi (fun i value -> i, value)) filtered)) + ~key:(fun (_, value) -> Json.to_string value) ~cmp:String.compare ~mount:(fun value -> + let index, value = Signal.sample value in + let matched = jlist (jfield "match" value) in + list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [button ~text:(jtext (jfield "title" value)) ~variant:`ghost ~min_height:44 ~text_alignment:`start + ~accessibility_identifier:(id "choice" ^ "." ^ string_of_int index) + ~icon_signal:(own (Signal.map (fun selected -> `app (if matched <> [] && + List.for_all (fun value -> List.mem value (jlist selected)) matched then "task-done" else "outliner-bullet")) (Signal.value selection))) + ~on_press:(fun _ -> let current = jlist (Signal.sample (Signal.value selection)) in + Signal.set selection (`List (if List.for_all (fun value -> List.mem value current) matched then + List.filter (fun value -> not (List.mem value matched)) current + else current @ List.filter (fun value -> not (List.mem value current)) matched))) []])]) col]; + (if Lui_ui.host context = KotlinHost then row ~main:`space_between ~padding:12 [cancel; done_] + else toolbar ~style_class:"navigation-actions" [cancel; done_]) + ] in + let temporal kind = + let value = own (Signal.map (fun (draft : filter_draft) -> + if draft.value = `Null then temporal_default kind else input_value draft.value) source) in + temporal_picker kind (id "date") value (fun value -> + if kind = "datetime" then (match int_of_string_opt value with Some value -> set_value (`Int value) | None -> ()) + else match int_of_string_opt (String.concat "" (String.split_on_char '-' value)) with + | Some day -> set_value (`Assoc ["block/journal-day", `Int day]) | None -> ()) in + let timestamp = column ~gap:0 ~cross:`stretch [ + menu ~label:"Date range" ~accessibility_identifier:(id "timestamp") + ~text_signal:(own (Signal.map (fun (draft : filter_draft) -> match draft.value with + | `String label -> label | `Int _ | `Float _ -> "Custom date" | _ -> "Choose date") source)) + (List.map (fun (key, label) -> menu_item ~text:label ~accessibility_identifier:(id "preset" ^ "." ^ key) + ~on_press:(fun _ -> Signal.set custom false; set_value (`String label)) []) + ["day", "1 day ago"; "three-days", "3 days ago"; "week", "1 week ago"; + "month", "1 month ago"; "three-months", "3 months ago"; "year", "1 year ago"] @ + [menu_item ~text:"Custom date…" ~accessibility_identifier:(id "custom") + ~on_press:(fun _ -> Signal.set custom true; let current = (Signal.sample source).value in + match current with `Int _ | `Float _ -> () | _ -> + set_value (`Int (int_of_string (temporal_default "datetime")))) []]); + if_ ~test:(own (Signal.map2 (fun custom (draft : filter_draft) -> custom || + match draft.value with `Int _ | `Float _ -> true | _ -> false) (Signal.value custom) source)) (temporal "datetime") ] in + (list_section [ + list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [row ~gap:4 ~cross:`center ~accessibility_identifier:(id "row") [ + box ~grow:1.0 [menu ~text_signal:(own (Signal.map (fun col -> jtext (jfield "title" col)) col)) ~label:"Property" [ + keyed ~source:(own (Signal.map property_choices view_source)) ~key:(fun col -> jtext (jfield "attr" col)) ~cmp:String.compare + ~mount:(fun col -> let col = Signal.sample col in let attr = jtext (jfield "attr" col) in + menu_item ~text:(jtext (jfield "title" col)) ~accessibility_identifier:(id "property" ^ "." ^ attr) + ~on_press:(fun _ -> Signal.set custom false; change (fun draft -> + {draft with attr; op = (if jbool (jfield "filterTimestamp" col) then "after" else "is"); value = `Null})) [])]]; + menu ~text_signal:(own (Signal.map (fun (draft : filter_draft) -> operator_title draft.op) source)) ~label:"Operator" [ + keyed ~source:(own (Signal.map operators col)) ~key:Fun.id ~cmp:String.compare + ~mount:(fun op -> let op = Signal.sample op in menu_item ~text:(operator_title op) + ~accessibility_identifier:(id "operator" ^ "." ^ op) + ~on_press:(fun _ -> Signal.set custom false; change (fun draft -> {draft with op; value = filter_transition op draft.value})) [])]; + button ~icon:(`app "close") ~variant:`ghost ~width:44 ~height:44 ~label:"Remove filter" + ~accessibility_identifier:(id "remove") ~on_press:(fun _ -> Signal.set filters + (List.filter (fun (draft : filter_draft) -> draft.key <> first.key) (Signal.sample (Signal.value filters)))) []]]; + list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [dyn (fun op -> match op with + | "is" | "is-not" -> choose_values + | "between" -> row ~gap:8 [number_input "minimum" "Minimum" (Some 0); number_input "maximum" "Maximum" (Some 1)] + | "number-gt" | "number-lt" | "number-gte" | "number-lte" -> number_input "value" "Number" None + | "date-before" | "date-after" -> temporal "date" + | "before" | "after" -> timestamp + | _ -> text_field ~label:"Filter value" ~placeholder:"Text" ~accessibility_identifier:(id "value") + ~text_signal:(own (Signal.map (fun (draft : filter_draft) -> input_value draft.value) source)) + ~on_input:(local_input (fun value -> set_value (`String value))) []) + (own (Signal.map (fun (draft : filter_draft) -> draft.op) source))]; + if_ ~test:(own (Signal.map (fun draft -> filter_error draft <> None) source)) + (list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [text ~foreground:"destructive" ~accessibility_identifier:(id "error") + ~value_signal:(own (Signal.map (fun draft -> Option.value ~default:"" (filter_error draft)) source)) []]); + if_ ~test:(Signal.value selecting) (list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [values_sheet]); + ]) context parent in let sort_row source : t = let first : sort_draft = Signal.sample source in let attr_source = own (Signal.map (fun (value : sort_draft) -> value.attr) source) in @@ -305,7 +389,8 @@ let settings_sheet mode owner feature view_source model_source send close : t = let cancel = button ~text:"Cancel" ~variant:`ghost ~style_class:"cancellation-action" ~accessibility_identifier:("view.settings.cancel." ^ initial.id) ~on_press:(fun _ -> close ()) [] in let save = button ~text:"Done" ~variant:`ghost ~style_class:"confirmation-action" - ~disabled_signal:(waiting model_source initial.id) + ~disabled_signal:(own (Signal.map2 (fun waiting filters -> waiting || (mode = "filter" && List.exists (fun draft -> filter_error draft <> None) filters)) + (waiting model_source initial.id) (Signal.value filters))) ~accessibility_identifier:("view.settings.save." ^ initial.id) ~on_press:save [] in (sheet ~text:(if mode = "filter" then "Filters" else "Sort") ~style_class:"navigation-list navigation-inline-title" ~detents:"medium,large" ~accessibility_identifier:("view.settings.sheet." ^ initial.id) @@ -313,10 +398,15 @@ let settings_sheet mode owner feature view_source model_source send close : t = list ~grow:1.0 ~style:`inset_grouped ~accessibility_identifier:("view." ^ mode ^ ".list." ^ initial.id) (action_error_section model_source initial.id :: (if mode = "filter" then [ - list_section ~header:(text ~value:"Match" []) [list_item [ - tabs (List.map (fun value -> button ~text:(if value then "Any" else "All") - ~selected_signal:(own (Signal.map ((=) value) (Signal.value or_))) - ~on_press:(fun _ -> Signal.set or_ value) []) [false; true])]]; + list_section [list_item ~padding_vertical:0 ~padding_horizontal:16 ~min_height:44 [menu ~label:"Match conditions" + ~text_signal:(own (Signal.map (fun any -> if any then "Match any condition" else "Match all conditions") (Signal.value or_))) + (List.map (fun any -> menu_item ~text:(if any then "Any condition" else "All conditions") + ~accessibility_identifier:("view.filter.match." ^ initial.id ^ "." ^ string_of_bool any) + ~selected_signal:(own (Signal.map ((=) any) (Signal.value or_))) ~on_press:(fun _ -> Signal.set or_ any) []) [false; true])]]; + if_ ~test:(own (Signal.map ((=) []) (Signal.value filters))) + (list_section [list_item [button ~text:"Created in the last month" ~icon:(`app "calendar") ~variant:`ghost + ~accessibility_identifier:("view.filter.recent." ^ initial.id) ~on_press:(fun _ -> + Signal.set filters [{key = next (); attr = "block/created-at"; op = "after"; value = `String "1 month ago"}]) []]]); keyed ~source:(Signal.value filters) ~key:(fun (value : filter_draft) -> value.key) ~cmp:Int.compare ~mount:filter_row; list_section [list_item [button ~text:"Add filter" ~variant:`ghost ~accessibility_identifier:("view.filter.add." ^ initial.id) ~on_press:(fun _ -> Signal.set filters (Signal.sample (Signal.value filters) @ [{key = next (); attr = "block/title"; op = "text-contains"; value = `String ""}])) []]]; @@ -336,23 +426,6 @@ let rec raw_selection_values raw = | "" -> [] | _ -> [text_value (jfield "value" raw)] -let temporal_picker kind id source set : t = fun context parent -> - let node = Lui_ui.extension context "native-property-date" in - attach context parent node; - Lui_ui.extension_property context node "kind" (Lui_protocol.StringValue kind); - Lui_ui.extension_property context node "identifier" (Lui_protocol.StringValue ("view.edit.date." ^ id)); - Lui_ui.extension_property_signal context node "value" (Signal.map (fun value -> Lui_protocol.StringValue value) source); - Lui_ui.on_event context node (function - | Lui_protocol.ExtensionEvent (_, _, "change", fields) -> - (match Lui_protocol.String_map.find_opt "value" fields with Some (Lui_protocol.StringValue value) -> set value | _ -> ()) - | _ -> ()); - node - -let temporal_default kind = - let now = Unix.gettimeofday () in - if kind = "datetime" then Printf.sprintf "%.0f" (floor (now /. 60.) *. 60000.) - else let t = Unix.localtime now in Printf.sprintf "%04d-%02d-%02d" (t.Unix.tm_year + 1900) (t.tm_mon + 1) t.tm_mday - let property_editor ~id ~column:col ~submit:save model_source property close : t = fun context parent -> let view = {id; name = ""; typ = ""; data = `Null} in diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index e9029a86..324c4c87 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -722,8 +722,16 @@ let native_filter_choices_preserve_master_sets () = press (find ("view.filter.add." ^ table_view)); let list = find ("view.filter.list." ^ table_view) in press (choice list "Score"); - press (choice list "10"); - press (choice list "20"); + press (find ("view.filter.values." ^ table_view ^ ".2")); + let pick label = + let rec find_choice node = + if App_test.node host node = Some Lui_protocol.Button + && App_test.property_string host node Lui_protocol.TextValue = label then node + else List.fold_left (fun found child -> if found <> -1 then found else find_choice child) + (-1) (App_test.children host node) in + press (find_choice (find ("view.filter.values.sheet." ^ table_view ^ ".2"))) in + pick "10"; pick "20"; + press (find ("view.filter.values.done." ^ table_view ^ ".2")); press (find ("view.settings.save." ^ table_view)); let pending = List.find (function Model.ResultViewEffect _ -> true | _ -> false) (App.model app).pending_effects in let _, body = Result.get_ok (Core_effect.request ~now:(fun () -> 1) ~fresh_id:(fun () -> "unused") diff --git a/shared/test/logseq/test_main.ml b/shared/test/logseq/test_main.ml index 59603609..0491683d 100644 --- a/shared/test/logseq/test_main.ml +++ b/shared/test/logseq/test_main.ml @@ -23,6 +23,7 @@ let () = "markup", Markup_test.cases; "query_block", Query_block_test.cases; "result_view", Result_view_test.cases; + "view_filters", View_filter_test.cases; "node_property", Node_property_test.cases; "flashcards", Flashcards_test.cases; "mobile_session", Mobile_session_test.cases; diff --git a/shared/test/logseq/view_filter_test.ml b/shared/test/logseq/view_filter_test.ml new file mode 100644 index 00000000..29d8a87a --- /dev/null +++ b/shared/test/logseq/view_filter_test.ml @@ -0,0 +1,144 @@ +open Test_util +module R = Result_view_test +module Q = Query_block_test +module J = Yojson.Basic +module Ds = Datascript +let member = Q.member +let items = Q.items +let text = R.text +let input app id value = + let host = Lui_app.runtime app in + let node = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + check ~msg:id (node <> -1); + App_test.dispatch app (Lui_protocol.TextChanged (node, value)); App_test.flush app +let find app id = App_test.descendant_with_identifier (Lui_app.runtime app) (Lui_app.root_node app) id +let click app id = + let node = find app id in check ~msg:id (node <> -1); + App_test.dispatch app (Lui_protocol.Press node); App_test.flush app +let prefix part key = "view.filter." ^ part ^ "." ^ R.list_view ^ "." ^ string_of_int key +let set_filters session clauses = + let response = R.action session (R.base_action "save" R.list_view @ ["config", `Assoc [ + "filters", `Assoc ["or", `Bool false; "clauses", `List clauses]]]) in + check_eq ~msg:(J.to_string response) (member "ok" response) (`Bool true) +let clause attr op value = `List [`String attr; `String op; value] +let with_sheet backend session clauses f = + set_filters session clauses; + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + click app ("view.filter." ^ R.list_view); f app) +let pending app = + let pending_effect = List.find (function Model.ResultViewEffect _ -> true | _ -> false) (App.model app).pending_effects in + J.from_string (Native_bridge.encode_effect pending_effect) |> member "text" |> text |> J.from_string +let clauses app = pending app |> member "config" |> member "filters" |> items "clauses" +let save app = click app ("view.settings.save." ^ R.list_view) +let apply session app = + let response = R.action session (match pending app with `Assoc fields -> fields | _ -> []) in + check_eq ~msg:(J.to_string response) (member "ok" response) (`Bool true) + +let operators_match_every_master_property_type () = R.with_runtime (fun session runtime conn -> + let types = ["default"; "url"; "node"; "date"; "datetime"; "number"; "checkbox"; "page"; "entity"; "class"; "property"; "keyword"] in + let tx = List.mapi (fun index typ -> + let uuid = Printf.sprintf "80000000-0000-4000-8000-%012d" index in + R.entity (400 + index) uuid typ @ [Q.put uuid "db/ident" (Ds.Keyword ("user.property/filter-" ^ typ)); + Q.put uuid "logseq.property/type" (Ds.Keyword typ)]) types + |> List.fold_left (fun acc tx -> List.rev_append tx acc) [] |> List.rev in + ignore (Ds.transact_conn conn tx); Graph_runtime.rebase runtime 43 [] []; + let view = R.find_view R.list_view (R.read session) in + let property attr = List.find (fun p -> member "attr" p = `String attr) (items "properties" view) in + List.iter (fun typ -> + let expected = ["is"; "is-not"] @ (match typ with + | "default" | "url" | "node" -> ["text-contains"; "text-not-contains"] + | "date" -> ["date-before"; "date-after"] + | "datetime" -> ["before"; "after"] + | "number" -> ["number-gt"; "number-lt"; "number-gte"; "number-lte"; "between"] + | _ -> []) in + check_eq ~msg:("master operators for " ^ typ) (items "filterOperators" (property ("user.property/filter-" ^ typ))) + (List.map (fun value -> `String value) expected)) types; + List.iter (fun attr -> + check_eq ~msg:"built-in timestamps expose only Before and After" (items "filterOperators" (property attr)) [`String "before"; `String "after"]; + check_eq (member "filterTimestamp" (property attr)) (`Bool true)) ["block/created-at"; "block/updated-at"]) + +let recent_objects_shortcut_persists_a_relative_calendar_month () = + List.iter (fun backend -> R.with_runtime (fun session runtime conn -> + let now = Dev_query.Date_time_util.time_ms () in + let month = Dev_query.Date_time_util.minus Months 1 now in + ignore (Ds.transact_conn conn [Q.put R.alpha "block/created-at" (Ds.Int64 (Int64.sub now 86400000L)); + Q.put R.beta "block/created-at" (Ds.Int64 (Int64.sub month 86400000L)); + Q.put R.gamma "block/created-at" (Ds.Int64 (Int64.sub now 172800000L))]); + Graph_runtime.rebase runtime 43 [] []; + with_sheet backend session [] (fun app -> + click app ("view.filter.recent." ^ R.list_view); + check ~msg:"the common recent-objects filter has a direct shortcut" (find app (prefix "timestamp" 1) <> -1); + save app; + check_eq ~msg:"relative dates stay dynamic in the saved master filter" (clauses app) + [clause "block/created-at" "after" (`String "1 month ago")]; + apply session app; + let actual = R.find_view R.list_view (R.read session) |> R.ids |> List.sort compare in + check_eq actual (List.sort compare [`String R.alpha; `String R.gamma]); + let saved = R.find_view R.list_view (R.read session) |> member "filters" |> items "filters" in + check_eq saved [clause "block/created-at" "after" (`String "1 month ago")]))) + [App_test.ios_backend; App_test.kotlin_backend] + +let native_inputs_follow_property_and_operator () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + with_sheet backend session [clause R.score "between" (`List [`Null; `Null])] (fun app -> + check ~msg:"one compact row owns property and operator" (find app (prefix "row" 0) <> -1); + input app (prefix "minimum" 0) "bad"; + check ~msg:"invalid numbers have a local recovery hint" (find app (prefix "error" 0) <> -1); + check_eq ~msg:"invalid numeric drafts cannot be saved" + (App_test.property_bool (Lui_app.runtime app) (find app ("view.settings.save." ^ R.list_view)) Lui_protocol.Enabled) false; + input app (prefix "minimum" 0) "15"; input app (prefix "maximum" 0) "25"; + save app; apply session app; + R.expected_ids [R.alpha] (R.find_view R.list_view (R.read session))); + with_sheet backend session [clause R.ready "is" `Null] (fun app -> + click app (prefix "values" 0); + click app (prefix "choice" 0 ^ ".false"); click app (prefix "values.done" 0); + save app; check_eq (clauses app) [clause R.ready "is" (`Bool false)]); + with_sheet backend session [clause R.summary "is" `Null] (fun app -> + click app (prefix "values" 0); + input app (prefix "values.search" 0) "search"; + let option = prefix "choice" 0 ^ ".0" in + check ~msg:"reference/text choices can be searched" (find app option <> -1); + click app option; click app (prefix "values.done" 0); + save app; + check_eq (clauses app) [clause R.summary "is" (`List [`String R.text_a])]); + with_sheet backend session [clause R.due "date-after" `Null] (fun app -> + let node = App_test.extension_node app "native-property-date" in + check ~msg:"date comparisons use the native day picker" (node <> -1); + Alcotest.(check string) "native day picker kind" "date" + (match App_test.extension_property app node "kind" with Some (Lui_protocol.StringValue value) -> value | _ -> "missing"); + App_test.dispatch app (Lui_protocol.ExtensionEvent (node, "native-property-date", "change", + Lui_protocol.String_map.singleton "value" (Lui_protocol.StringValue "2026-10-23"))); + App_test.flush app; save app; + check_eq (clauses app) [clause R.due "date-after" (`Assoc ["block/journal-day", `Int 20261023])]); + with_sheet backend session [clause "block/created-at" "after" `Null] (fun app -> + click app (prefix "preset" 0 ^ ".month"); + click app (prefix "operator" 0 ^ ".before"); + check ~msg:"changing temporal operator resets an incompatible relative value" (find app (prefix "timestamp" 0) <> -1); + click app (prefix "custom" 0); + let node = App_test.extension_node app "native-property-date" in + check ~msg:"custom timestamp uses a datetime picker" (node <> -1); + check_eq (App_test.extension_property app node "kind") (Some (Lui_protocol.StringValue "datetime")); + App_test.dispatch app (Lui_protocol.ExtensionEvent (node, "native-property-date", "change", + Lui_protocol.String_map.singleton "value" (Lui_protocol.StringValue "1792719000000"))); + App_test.flush app; save app; + check_eq (clauses app) [clause "block/created-at" "before" (`Int 1792719000000)]))) + [App_test.ios_backend; App_test.kotlin_backend] + +let changing_properties_and_cancelling_preserve_the_saved_view () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + with_sheet backend session [clause R.score "number-gt" (`Int 10)] (fun app -> + click app (prefix "property" 0 ^ "." ^ R.ready); + check_eq ~msg:"old numeric controls disappear for checkboxes" (find app ("view.filter.value." ^ R.list_view ^ ".0")) (-1); + click app (prefix "values" 0); click app (prefix "choice" 0 ^ ".empty"); + click app (prefix "values.done" 0); + click app ("view.settings.cancel." ^ R.list_view); + check ~msg:"Cancel never submits a view mutation" (not (List.exists (function Model.ResultViewEffect _ -> true | _ -> false) (App.model app).pending_effects)); + let saved = R.find_view R.list_view (R.read session) |> member "filters" |> items "filters" in + check_eq saved [clause R.score "number-gt" (`Int 10)]))) [App_test.ios_backend; App_test.kotlin_backend] + +let cases = [ + case "operators match every master property type" operators_match_every_master_property_type; + case "recent objects stay relative to a calendar month" recent_objects_shortcut_persists_a_relative_calendar_month; + case "native filter inputs follow property and operator" native_inputs_follow_property_and_operator; + case "property changes and Cancel preserve saved configuration" changing_properties_and_cancelling_preserve_the_saved_view; +] From 2e75d5cd7b713bd24cc69db0bb6af1439a07363a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 00:14:20 +0800 Subject: [PATCH 07/25] Keep text and placeholder geometry stable across editing --- .../logseq/app/AndroidEditorCompletionTest.kt | 32 ++++-- apple/Sources/Logseq/OutlinerTextRow.swift | 47 +++++++- .../Tests/UI/outliner-editing-position.swift | 106 ++++++++++++++++++ docs/agent-guide/014-editor-commands.md | 8 ++ shared/src/logseq/view_outliner.ml | 11 +- shared/src/logseq/view_query.ml | 5 +- shared/src/logseq/view_rows.ml | 4 +- shared/test/logseq/app_test.ml | 4 +- shared/test/logseq/editor_command_test.ml | 19 ++++ shared/test/logseq/result_view_test.ml | 4 + 10 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 apple/Tests/UI/outliner-editing-position.swift diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt index 875200af..e385af41 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt @@ -73,18 +73,32 @@ class AndroidEditorCompletionTest { ui.onNodeWithTag("toolbar.outliner.autocomplete").assertIsDisplayed() if (kind == "command") ui.onNodeWithTag("outliner.autocomplete.group.BASIC").assertIsDisplayed() ui.onNodeWithTag("button.outliner.autocomplete.dismiss").assertIsDisplayed() - ui.onNodeWithTag("button.outliner.autocomplete.0").performClick() - ui.runOnIdle { - var chosen: JSONObject? = null - repeat(24) { - val encoded = bridge.takeEffect() - if (encoded.isNotEmpty()) { - val effect = JSONObject(encoded).optJSONObject("effect") ?: return@repeat - if (effect.optString("kind") == "choose-outliner-autocomplete") chosen = effect + ui.onNodeWithTag("button.outliner.autocomplete.0").assertIsDisplayed().performClick() + var chosen: JSONObject? = null + val observed = mutableListOf() + try { ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + repeat(24) { + val encoded = bridge.takeEffect() + if (encoded.isNotEmpty()) { + val effect = JSONObject(encoded).optJSONObject("effect") ?: return@repeat + observed.add(effect.toString()) + if (effect.optString("kind") == "choose-outliner-autocomplete") chosen = effect + } + } + } + chosen != null + } } catch (error: Throwable) { + InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()?.let { bitmap -> + val context = InstrumentationRegistry.getInstrumentation().targetContext + java.io.File(context.getExternalFilesDir(null), "editor-command.png").outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } } - assertEquals(if (kind == "command") "command:embed" else "project", chosen?.optString("text")) + throw AssertionError("Command $kind did not dispatch; effects: $observed", error) } + assertEquals(if (kind == "command") "command:embed" else "project", chosen?.optString("text")) + ui.runOnIdle { bridge.resolveEffect(chosen!!.getLong("id"), true, "").takeIf { it.isNotEmpty() }?.let(filter::applyJson) } } } diff --git a/apple/Sources/Logseq/OutlinerTextRow.swift b/apple/Sources/Logseq/OutlinerTextRow.swift index e308b180..0f538622 100644 --- a/apple/Sources/Logseq/OutlinerTextRow.swift +++ b/apple/Sources/Logseq/OutlinerTextRow.swift @@ -32,6 +32,46 @@ struct OutlinerTextRow: UIViewRepresentable { @MainActor private final class OutlinerTitleLabel: UILabel { var activate: (() -> Void)? + private let textStorage = NSTextStorage() + private let textLayout = NSLayoutManager() + private let textContainer = NSTextContainer(size: .zero) + private var contentNeedsLayout = true + + override init(frame: CGRect) { + super.init(frame: frame) + textContainer.lineFragmentPadding = 0 + textStorage.addLayoutManager(textLayout) + textLayout.addTextContainer(textContainer) + } + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + override var attributedText: NSAttributedString? { didSet { contentNeedsLayout = true } } + override var font: UIFont! { didSet { contentNeedsLayout = true } } + + var verticalInset: CGFloat { + OutlinerNativeTextLayoutPolicy.verticalInset( + fontLineHeight: font.lineHeight, minimumLineHeight: OutlinerLayoutMetrics.titleLineHeight) + } + + // UITextView rasterizes the padded glyph origin on the physical pixel grid. + var drawingInset: CGFloat { + floor(verticalInset * contentScaleFactor) / contentScaleFactor + } + + override func drawText(in rect: CGRect) { + if contentNeedsLayout { + let content = NSMutableAttributedString(attributedString: attributedText ?? NSAttributedString(string: text ?? "")) + content.addAttribute(.font, value: font as Any, range: NSRange(location: 0, length: content.length)) + textStorage.setAttributedString(content) + contentNeedsLayout = false + } + textContainer.size = CGSize(width: bounds.width, height: .greatestFiniteMagnitude) + textLayout.ensureLayout(for: textContainer) + let range = textLayout.glyphRange(for: textContainer) + let origin = CGPoint(x: 0, y: drawingInset) + textLayout.drawBackground(forGlyphRange: range, at: origin) + textLayout.drawGlyphs(forGlyphRange: range, at: origin) + } + override func accessibilityActivate() -> Bool { guard let activate else { return false } activate() @@ -227,7 +267,9 @@ struct OutlinerTextRow: UIViewRepresentable { let iconLeading: CGFloat = row.status != nil ? OutlinerLayoutMetrics.statusLeading : (row.priority != nil ? 20 : 26) let leading = inset + iconLeading + statusWidth + priorityWidth let bodyWidth = max(1, width - leading - trailing) - let titleHeight = max(24, ceil(title.sizeThatFits(CGSize(width: bodyWidth, height: .greatestFiniteMagnitude)).height)) + let titleHeight = OutlinerNativeTextMeasurement.size(text: title.attributedText?.string ?? "", + font: title.font, width: bodyWidth, verticalInset: title.verticalInset, + scale: traitCollection.displayScale).height var height = titleHeight + 4 if apply { bullet.frame = CGRect(x: inset, y: 2, width: 24, height: 24) @@ -277,8 +319,7 @@ struct OutlinerTextRow: UIViewRepresentable { storage.addLayoutManager(manager) manager.addTextContainer(container) manager.ensureLayout(for: container) - let rect = manager.usedRect(for: container) - let location = CGPoint(x: point.x, y: point.y - max(0, (title.bounds.height - rect.height) / 2)) + let location = CGPoint(x: point.x, y: point.y - title.drawingInset) let glyph = manager.glyphIndex(for: location, in: container) if glyph < manager.numberOfGlyphs, manager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container).contains(location) { diff --git a/apple/Tests/UI/outliner-editing-position.swift b/apple/Tests/UI/outliner-editing-position.swift new file mode 100644 index 00000000..2d453abe --- /dev/null +++ b/apple/Tests/UI/outliner-editing-position.swift @@ -0,0 +1,106 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class PositionEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerEditingPositionCheck { + @MainActor static func main() async throws { + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: PositionEffects()) + func snapshot(_ title: String, editing: Bool, added: Bool = false) throws -> String { + var result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "outlinerRows": [["depth": 0, "block": ["uuid": "body", "title": title, "pageId": "page", "journalTitle": "Today", "journalDay": 20261023]]]] + if added { result["outlinerRows"] = (result["outlinerRows"] as! [[String: Any]]) + [["depth": 0, "block": ["uuid": "created", "title": "", "pageId": "page", "journalTitle": "Today", "journalDay": 20261023]]] } + result["outlinerState"] = editing + ? ["editing": ["uuid": added ? "created" : "body", "title": added ? "" : title, "caretUTF16Offset": added ? 0 : title.utf16.count]] + : ["selectedBlockIds": []] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + try runtime.applyCoreResponse(snapshot("Hello", editing: false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + func descendants(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(descendants).first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer).ignoresSafeArea(.keyboard)) + window.makeKeyAndVisible() + window.overrideUserInterfaceStyle = .light + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func capture(_ name: String) throws -> UIImage { + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(name + ".png")) + return image + } + func firstInkY(_ image: UIImage, frame: CGRect) -> CGFloat { + let cg = image.cgImage! + let bytes = CFDataGetBytePtr(cg.dataProvider!.data!)! + let scale = image.scale + for y in Int(frame.minY * scale)..= 5 { return CGFloat(y) / scale } + } + fatalError("Missing text pixels") + } + var shifts: [CGFloat] = [] + for (index, title) in ["Hello", "Hello world. A wrapped line of plain text with enough words to occupy several lines on a narrow phone."].enumerated() { + try runtime.applyCoreResponse(snapshot(title, editing: false)) + try await Task.sleep(for: .milliseconds(450)) + window.layoutIfNeeded() + guard let label = views(window).compactMap({ $0 as? UILabel }).first(where: { $0.accessibilityIdentifier == "outliner.block-action.body" }) else { fatalError("Missing display label") } + let displayFrame = label.convert(label.bounds, to: window) + let displayBullet = views(window).first { $0.accessibilityIdentifier?.hasPrefix("button.outliner.zoom.body.") == true }! + let displayBulletFrame = displayBullet.convert(displayBullet.bounds, to: window) + let beforeImage = try capture("display-\(index)") + let displayInk = firstInkY(beforeImage, frame: displayFrame) + try runtime.applyCoreResponse(snapshot(title, editing: true)) + try await Task.sleep(for: .milliseconds(500)) + window.layoutIfNeeded() + guard let editor = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.accessibilityIdentifier == "field.outliner.block.body" }) else { fatalError("Missing editor") } + let editFrame = editor.convert(editor.bounds, to: window) + print("POSITION case=\(index) display=\(displayFrame) editor=\(editFrame) inset=\(editor.textContainerInset) bullet=\(displayBulletFrame)") + precondition(displayFrame == editFrame, "The complete title frame must remain unchanged") + let afterImage = try capture("editing-\(index)") + let editInk = firstInkY(afterImage, frame: editFrame) + print("INK case=\(index) display=\(displayInk) editing=\(editInk) shift=\(editInk - displayInk)") + shifts.append(editInk - displayInk) + } + try runtime.applyCoreResponse(snapshot("Hello", editing: false)) + try await Task.sleep(for: .milliseconds(450)) + window.layoutIfNeeded() + guard let placeholder = find("outliner.placeholder.bullet.page").flatMap({ frames[$0] }) else { fatalError("Missing placeholder frame") } + try runtime.applyCoreResponse(snapshot("Hello", editing: true, added: true)) + try await Task.sleep(for: .milliseconds(500)) + window.layoutIfNeeded() + let created = views(window).compactMap({ $0 as? UITextView }).first { $0.accessibilityIdentifier == "field.outliner.block.created" }! + let createdFrame = created.convert(created.bounds, to: window) + print("PLACEHOLDER before=\(placeholder) createdEditor=\(createdFrame)") + guard find("button.outliner.placeholder.page") == nil, + abs(placeholder.minX - (createdFrame.minX - 26)) <= 0.1, + abs(placeholder.minY - createdFrame.minY) <= 0.1 else { + throw NSError(domain: "EditingPosition", code: 2, userInfo: [NSLocalizedDescriptionKey: "The placeholder bullet must keep its position when it becomes an editor"]) + } + guard shifts.allSatisfy({ abs($0) <= 0.1 }) else { + throw NSError(domain: "EditingPosition", code: 1, userInfo: [NSLocalizedDescriptionKey: "Entering edit mode must preserve the text baseline: \(shifts)"]) + } + print("PASS: single-line and wrapped display text retain their baseline while editing") + } +} diff --git a/docs/agent-guide/014-editor-commands.md b/docs/agent-guide/014-editor-commands.md index 346a8d66..202a5636 100644 --- a/docs/agent-guide/014-editor-commands.md +++ b/docs/agent-guide/014-editor-commands.md @@ -85,3 +85,11 @@ YouTube timestamp insertion needs the active player position when available and - Native disclosure controls expose a 44-point touch target, including its edges. - Placeholder blocks use the regular bullet asset with reduced opacity. - The shared properties renderer uses compact task-date icons and local readable datetime labels. + +## Editing geometry verification + +- Reproduced the plain-text display/editor mismatch with native screenshots: one physical pixel on a single line and a 3.3-point wrapped-height difference. +- Readonly titles now use cached TextKit layout, the editor's measurement, and a pixel-aligned drawing origin. Native checks show zero ink displacement and equal title frames. +- Placeholders are hidden during editing, keep empty journal headings, and share the new empty block's bullet coordinates. +- Android grouped completion buttons retain a fixed height in their vertical scroll container. Native command, page, tag and paste checks pass. +- The full core gate passes 996 cases; the Name cell editor omits Clear value while other properties retain it. diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 8df294af..462ee0ba 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -777,7 +777,7 @@ let outliner_autocomplete_row (context : Lui_ui.ui_context) ~text_signal: (Signal.map View_base.outliner_autocomplete_label candidate_source) ~variant:`ghost - ~grow:1.0 + ~grow:0.0 ~height:44 ~padding_horizontal:10 ~background:"autocomplete-row-background" ~foreground:"foreground" ~corner_radius:8 ~text_alignment:`start @@ -1044,11 +1044,11 @@ let page_loading_view model_source : t = [ spinner ~width:16 ~height:16 []; text ~foreground:"muted-foreground" ~value:"Loading blocks…" [] ]) -let placeholder_block context (value : View_rows.placeholder) send : t = +let placeholder_block context model_source (value : View_rows.placeholder) send : t = let placeholder : t = fun mount_context parent -> - let node = box ~height:44 + let node = box ~height:28 ~accessibility_identifier:("button.outliner.placeholder." ^ value.parent_uuid) - [row ~cross:`center ~gap:0 + [row ~cross:`center ~gap:0 ~padding_vertical:2 [box ~opacity:0.35 [icon ~name:(`app "outliner-bullet") ~width:24 ~height:24 ~foreground:"muted-foreground" ~accessibility_identifier:("outliner.placeholder.bullet." ^ value.parent_uuid) []]; box ~grow:1.0 []]] mount_context parent in @@ -1056,6 +1056,7 @@ let placeholder_block context (value : View_rows.placeholder) send : t = Lui_ui.bool_property mount_context node PressEnabled true; register_press mount_context node (fun _ -> ignore (send (Model.AddRootBlock value.parent_uuid))); node in + let placeholder = if_ ~test:(Signal.map (fun (current : Model.logseq_model) -> current.outliner_editing = None) model_source) placeholder in match value.journal_heading with | None -> box ~padding_horizontal:8 [placeholder] | Some (title, _, divider) -> @@ -1103,7 +1104,7 @@ let outliner_entries context model_source ~source send : t = let retained = Signal.map View_rows.page_entry_outline entry_source in outliner_entry context model_source retained (Signal.map View_rows.retained_row_value retained) send - | View_rows.Placeholder_entry value -> placeholder_block context value send + | View_rows.Placeholder_entry value -> placeholder_block context model_source value send | View_rows.Reference_entry _ -> reference_entry context model_source (Signal.map View_rows.page_entry_reference entry_source) send in diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 54db9ba7..b43832fb 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -522,9 +522,10 @@ let property_editor ~id ~column:col ~submit:save model_source property close : t else submit ~create:true value) []]); ]; ]) @ [ - list_section [list_item [button ~text:"Clear value" ~variant:`ghost ~min_height:44 + if_ ~test:(Signal.constant context.ui_scheduler (attr <> "block/title")) + (list_section [list_item [button ~text:"Clear value" ~variant:`ghost ~min_height:44 ~accessibility_identifier:("view.edit.clear." ^ view.id) ~disabled_signal:(waiting model_source view.id) - ~on_press:(fun _ -> if plain then submit "" else if many then (Signal.set selected []; Signal.set created []) else submit "") []]]; + ~on_press:(fun _ -> if plain then submit "" else if many then (Signal.set selected []; Signal.set created []) else submit "") []]]); action_error_section model_source view.id; ]); (if Lui_ui.host context = KotlinHost then row ~main:`space_between ~padding:12 controls diff --git a/shared/src/logseq/view_rows.ml b/shared/src/logseq/view_rows.ml index 7b135816..a8d20745 100644 --- a/shared/src/logseq/view_rows.ml +++ b/shared/src/logseq/view_rows.ml @@ -221,7 +221,7 @@ let journal_entries rows (current : Model.logseq_model) = let entries = List.mapi (fun index (page, title, day, rows) -> let outline = outline_entries rows in match title, day with - | Some title, Some day when needs_placeholder rows -> + | Some title, Some day when (current.outliner_editing = None && needs_placeholder rows) || rows = [] -> outline @ [Placeholder_entry {parent_uuid = page; journal_heading = if rows = [] then Some (title, day, index > 0) else None}] | _ -> outline) sections in @@ -238,7 +238,7 @@ let page_entries rows current = && View_base.page_content_ready_ current && parent <> "" && (match Model.active_page current with | Some page -> String.lowercase_ascii page.title <> "library" | None -> true) in - if allowed && needs_placeholder rows then + if allowed && current.outliner_editing = None && needs_placeholder rows then outline @ [Placeholder_entry {parent_uuid = parent; journal_heading = None}] else outline in List.rev_append (List.rev outline) diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index de8781dd..71133ea5 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -9495,8 +9495,8 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = "stretch"; check_eq ~msg:"suggestions start below their shared header" (property_string renderer autocomplete_column Lui_protocol.MainAlignment) "start"; - check_eq ~msg:"candidate buttons fill their bounded horizontal row" - (property_float renderer candidate_button Lui_protocol.GrowValue) 1.0; + check_eq ~msg:"candidate buttons keep their fixed height inside grouped suggestions" + (property_float renderer candidate_button Lui_protocol.GrowValue) 0.0; check_eq ~msg:"Kotlin does not flex rows along an unbounded scroll axis" (property_float renderer diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 67a3ff84..485adf15 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -341,6 +341,24 @@ let zoom_placeholder_creates_a_child_in_the_same_page () = R.with_runtime (fun s check_eq child.parent_id (Some Q.alpha); check_eq child.page_id Q.page) +let placeholders_hide_while_editing_and_keep_empty_journal_headings () = + List.iter (fun backend -> + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "graph"; + "outlinerState", `Assoc ["editing", `Assoc ["uuid", `String "editing"; "title", `String "Hello"; "caretUTF16Offset", `Int 5]]; + "outlinerRows", `List [`Assoc ["depth", `Int 0; "block", `Assoc [ + "uuid", `String "editing"; "title", `String "Hello"; "pageId", `String Q.page; + "journalTitle", `String "Today"; "journalDay", `Int 20261023]]]; + "emptyJournals", `List [`Assoc ["uuid", `String "empty-day"; "title", `String "Yesterday"; "journalDay", `Int 20261022]]]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + check ~msg:"the fixture is actually editing" ((App.model app).outliner_editing <> None); + check_eq ~msg:"editing hides the current page placeholder" (find ("button.outliner.placeholder." ^ Q.page)) (-1); + check_eq ~msg:"editing hides placeholders for other empty days" (find "button.outliner.placeholder.empty-day") (-1); + check ~msg:"empty journal headings remain visible while editing" (find "button.journal.empty-day" <> -1))) + [App_test.ios_backend; App_test.kotlin_backend] + let repeated_placeholder_taps_create_one_editor () = let model = Model.update (Model.initial ()) (Model.AddRootBlock "page") in let repeated = Model.update model (Model.AddRootBlock "page") in @@ -514,6 +532,7 @@ let task_dates_are_visible_and_editable_in_the_outline () = [App_test.ios_backend; App_test.kotlin_backend] let cases = [ + case "editing hides placeholders but keeps journal headings" placeholders_hide_while_editing_and_keep_empty_journal_headings; case "Backspace keeps the caret at the merge boundary" backspace_at_start_keeps_the_caret_at_the_merge_boundary; case "Enter on a parent matches master placement" enter_on_a_parent_matches_master_placement; case "task dates are visible and editable in the outline" task_dates_are_visible_and_editable_in_the_outline; diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index 324c4c87..5db17bb7 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -644,7 +644,11 @@ let native_direct_value_selection_and_creation () = let click id = let node = find id in check ~msg:id (node <> -1); App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in click ("view.tab." ^ table_view); + click ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/title"); + check_eq ~msg:"the Name editor does not offer Clear value" (find ("view.edit.clear." ^ table_view)) (-1); + click ("view.edit.cancel." ^ table_view); click ("view.cell." ^ table_view ^ "." ^ alpha ^ "." ^ score); + check ~msg:"other properties retain Clear value" (find ("view.edit.clear." ^ table_view) <> -1); check ~msg:"a cell immediately opens its searchable choices" (find ("view.edit.option." ^ table_view ^ "." ^ value_b) <> -1); App_test.dispatch app (Lui_protocol.TextChanged (find ("view.edit.value." ^ table_view), "35")); From 122e9d9317c3ba1c02712244c6111b2131c92d5e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 00:31:13 +0800 Subject: [PATCH 08/25] Keep outliner rows compact and align task properties with text --- .../logseq/app/AndroidOutlinerSpacingTest.kt | 35 +++++++- apple/Package.resolved | 4 +- apple/Package.swift | 2 +- .../Sources/Logseq/OutlinerRowExtension.swift | 26 +++--- apple/Sources/Logseq/OutlinerTextRow.swift | 13 ++- apple/Tests/UI/container-padding.swift | 33 ++++++++ .../UI/outliner-property-alignment.swift | 79 +++++++++++++++++++ apple/Tests/UI/outliner-row.swift | 25 ++++++ docs/agent-guide/014-editor-commands.md | 8 ++ shared/src/logseq/view_outliner.ml | 9 ++- shared/src/logseq/view_property.ml | 4 +- shared/test/logseq/editor_command_test.ml | 17 ++++ 12 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 apple/Tests/UI/container-padding.swift create mode 100644 apple/Tests/UI/outliner-property-alignment.swift diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt index a198f789..01d33c96 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt @@ -43,13 +43,18 @@ class AndroidOutlinerSpacingTest { @After fun closeBridge() { bridge?.let { ui.runOnIdle { it.close() } } } - private fun snapshot(editing: Boolean = false, priority: Boolean = false): String { + private fun snapshot(editing: Boolean = false, priority: Boolean = false, status: Boolean = false, dates: Boolean = false, hasChildren: Boolean = true): String { fun row(id: String, text: String, depth: Int): JSONObject { val block = JSONObject().put("uuid", id).put("title", text).put("pageId", "spacing-journal") .put("journalTitle", journal).put("journalDay", 20261022) if (priority && id == "parent") block.put("priority", JSONObject() .put("uuid", "priority-high").put("ident", "logseq.property.priority/high").put("title", "High")) - return JSONObject().put("depth", depth).put("hasChildren", depth == 0).put("block", block) + if (status && id == "parent") block.put("status", JSONObject() + .put("uuid", "todo").put("ident", "logseq.property.status/todo").put("title", "Todo")) + if (dates) block.put("taskDates", JSONObject().put("ownerUuid", id).put("properties", JSONArray().put(JSONObject() + .put("attr", "logseq.property/scheduled").put("title", "Scheduled").put("kind", "datetime") + .put("text", "Oct 23, 09:30").put("editorValue", "1792719000000").put("editable", true)))) + return JSONObject().put("depth", depth).put("hasChildren", depth == 0 && hasChildren).put("block", block) } val state = JSONObject() if (editing) state.put("editing", JSONObject().put("uuid", "parent") @@ -120,6 +125,32 @@ class AndroidOutlinerSpacingTest { assertTrue("Removing priority returns to compact spacing", gap() in 0.dp..4.dp) } + + @Test fun disclosureDoesNotAddABlankLineBeforeChildren() { + render() + val collapse = ui.onNodeWithTag("button.outliner.collapse.parent") + collapse.assertTouchWidthIsEqualTo(48.dp).assertTouchHeightIsEqualTo(48.dp) + val before = ui.onNodeWithText("Child note").getUnclippedBoundsInRoot().top - + ui.onNodeWithText("Parent note").getUnclippedBoundsInRoot().top + ui.runOnIdle { filter.applyJson(requireNotNull(bridge).applySnapshot(snapshot(hasChildren = false))) } + val after = ui.onNodeWithText("Child note").getUnclippedBoundsInRoot().top - + ui.onNodeWithText("Parent note").getUnclippedBoundsInRoot().top + assertEquals("A disclosure must not change document row spacing", after.value, before.value, 0.5f) + } + + @Test fun taskDatePropertiesAlignWithBlockText() { + render() + for (variant in 0..3) { + ui.runOnIdle { filter.applyJson(requireNotNull(bridge).applySnapshot( + snapshot(priority = variant and 1 != 0, status = variant and 2 != 0, dates = true))) } + for ((id, text) in listOf("parent" to "Parent note", "child" to "Child note")) { + val label = ui.onNodeWithTag("property.label.$id.logseq.property/scheduled").getUnclippedBoundsInRoot() + val title = ui.onNodeWithText(text).getUnclippedBoundsInRoot() + assertEquals("Task dates follow the text edge for variant $variant, $id", title.left.value, label.left.value, 1f) + } + } + } + @Test fun compactBulletsKeepNativeTouchTargetsAndOneZoomAction() { render() val zoom = ui.onNodeWithTag("button.outliner.zoom.parent.Parent note") diff --git a/apple/Package.resolved b/apple/Package.resolved index 52070174..4bb3a342 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "32eb30f9b81efcfc4e8b6fc33cd363bbec5959dd4b9372a73242073775eacec2", + "originHash" : "e88eba6058ed859fd6eb346249d9660b5b1a4e03cfd00798948ec34608dfd6a7", "pins" : [ { "identity" : "highlightswift", @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "daa22591cca58e733ef8b24c55e4dea5cd301417" + "revision" : "6b25738617d685311f12c7eddbe7c557a6e0adcd" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index e1fccd42..7d9833ee 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: "daa22591cca58e733ef8b24c55e4dea5cd301417"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "6b25738617d685311f12c7eddbe7c557a6e0adcd"), .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/apple/Sources/Logseq/OutlinerRowExtension.swift b/apple/Sources/Logseq/OutlinerRowExtension.swift index 90eb3546..51102b93 100644 --- a/apple/Sources/Logseq/OutlinerRowExtension.swift +++ b/apple/Sources/Logseq/OutlinerRowExtension.swift @@ -256,15 +256,23 @@ struct OutlinerRowPayload: Decodable, Equatable { } .frame(maxWidth: .infinity, alignment: .leading) if row.hasChildren { - Button { emit("collapse") } label: { - Image(systemName: row.collapsed ? "arrowtriangle.right.fill" : "arrowtriangle.down.fill") - .font(.system(size: 10)).foregroundStyle(.secondary) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(row.collapsed ? "Expand block" : "Collapse block") - .accessibilityIdentifier("button.outliner.collapse." + row.uuid) + Color.clear.frame(width: 44, height: 24) + .overlay(alignment: .topTrailing) { + Button { emit("collapse") } label: { + HStack(spacing: 0) { + Spacer(minLength: 0) + Image(systemName: row.collapsed ? "arrowtriangle.right.fill" : "arrowtriangle.down.fill") + .font(.system(size: 10)).foregroundStyle(.secondary) + Color.clear.frame(width: 6) + } + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .offset(y: -10) + .accessibilityLabel(row.collapsed ? "Expand block" : "Collapse block") + .accessibilityIdentifier("button.outliner.collapse." + row.uuid) + } } } .font(.body) diff --git a/apple/Sources/Logseq/OutlinerTextRow.swift b/apple/Sources/Logseq/OutlinerTextRow.swift index 0f538622..bd45f4d9 100644 --- a/apple/Sources/Logseq/OutlinerTextRow.swift +++ b/apple/Sources/Logseq/OutlinerTextRow.swift @@ -111,6 +111,11 @@ struct OutlinerTextRow: UIViewRepresentable { status.showsMenuAsPrimaryAction = true status.preferredMenuElementOrder = .fixed status.accessibilityIdentifier = "button.block-task-status" + disclosure.contentHorizontalAlignment = .right + var disclosureConfiguration = UIButton.Configuration.plain() + disclosureConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6) + disclosureConfiguration.baseForegroundColor = .secondaryLabel + disclosure.configuration = disclosureConfiguration disclosure.addTarget(self, action: #selector(collapse), for: .touchUpInside) failure.text = String(localized: "Sync failed") failure.textColor = .systemRed @@ -276,7 +281,7 @@ struct OutlinerTextRow: UIViewRepresentable { status.frame = CGRect(x: inset + iconLeading, y: 2, width: OutlinerLayoutMetrics.statusIconSize, height: 24) priority.frame = CGRect(x: inset + iconLeading + statusWidth, y: 2, width: 20, height: 24) title.frame = CGRect(x: leading, y: 2, width: bodyWidth, height: titleHeight) - disclosure.frame = CGRect(x: width - trailing, y: 0, width: 44, height: 44) + disclosure.frame = CGRect(x: width - trailing, y: -8, width: 44, height: 44) } if !tags.isEmpty { var x = leading @@ -292,7 +297,11 @@ struct OutlinerTextRow: UIViewRepresentable { if apply { failure.frame = CGRect(x: leading, y: height, width: bodyWidth, height: size.height) } height += size.height + 2 } - return row.hasChildren ? max(44, height) : height + return height + } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + super.point(inside: point, with: event) + || (!disclosure.isHidden && disclosure.frame.contains(point)) } override func sizeThatFits(_ size: CGSize) -> CGSize { CGSize(width: size.width, height: layout(width: size.width, apply: false)) } override func layoutSubviews() { diff --git a/apple/Tests/UI/container-padding.swift b/apple/Tests/UI/container-padding.swift new file mode 100644 index 00000000..a12928d0 --- /dev/null +++ b/apple/Tests/UI/container-padding.swift @@ -0,0 +1,33 @@ +import Foundation +import SwiftUI +import UIKit +import LUIAppleBackend + +@main struct ContainerPaddingCheck { + @MainActor static func main() async throws { + for kind in ["box", "row", "column"] { + for decorated in [false, true] { + let backend = LUIAppleBackend() + var ops: [[String: Any]] = [ + ["op": "create-node", "id": 1, "kind": kind], + ["op": "create-node", "id": 2, "kind": "box"], + ["op": "set-prop", "id": 2, "property": "width", "value": 10], + ["op": "set-prop", "id": 2, "property": "height", "value": 12], + ["op": "insert-child", "parent": 1, "child": 2, "index": 0] + ] + for (property, value) in ["padding": 2, "padding-horizontal": 5, "padding-vertical": 7, + "padding-left": 26, "padding-right": 8, "padding-top": 3, "padding-bottom": 4] { + ops.append(["op": "set-prop", "id": 1, "property": property, "value": value]) + } + if decorated { ops.append(["op": "set-prop", "id": 1, "property": "background", "value": "transparent"]) } + let data = try JSONSerialization.data(withJSONObject: ["generation": 1, "ops": ops]) + try backend.apply(json: String(decoding: data, as: UTF8.self)) + let host = UIHostingController(rootView: LUISwiftUIRoot(backend: backend, rootID: 1).fixedSize()) + let size = host.sizeThatFits(in: CGSize(width: 390, height: 844)) + precondition(abs(size.width - 44) <= 0.5, "\(kind), decorated=\(decorated), width=\(size.width)") + precondition(abs(size.height - 19) <= 0.5, "\(kind), decorated=\(decorated), height=\(size.height)") + } + } + print("PASS: plain and decorated boxes, rows and columns honor edge padding precedence") + } +} diff --git a/apple/Tests/UI/outliner-property-alignment.swift b/apple/Tests/UI/outliner-property-alignment.swift new file mode 100644 index 00000000..83b202f6 --- /dev/null +++ b/apple/Tests/UI/outliner-property-alignment.swift @@ -0,0 +1,79 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class AlignmentEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerPropertyAlignmentCheck { + @MainActor static func main() async throws { + func properties(_ owner: String) -> [String: Any] { + ["ownerUuid": owner, "properties": ["scheduled", "deadline"].map { name in + ["attr": "logseq.property/" + name, "title": name.capitalized, + "kind": "datetime", "text": "Oct 23, 09:30", "editorValue": "1792719000000", + "editable": true, "textLayout": false, "raw": ["type": "number", "value": 1792719000000]] as [String: Any] + }] + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: AlignmentEffects()) + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + func descendants(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(descendants).first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func snapshot(_ variant: Int) throws -> String { + var block: [String: Any] = ["uuid": "body", "title": "Hello", "pageId": "page", "taskDates": properties("body")] + if variant & 1 != 0 { block["status"] = ["uuid": "todo", "title": "Todo", "icon": "app:task-todo"] } + if variant & 2 != 0 { block["priority"] = ["uuid": "high", "title": "High", "icon": "app:priority-high"] } + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "selectedPage": ["uuid": "page", "title": "Properties"], "nodeProperties": properties("page"), + "outlinerRows": [["depth": variant % 2, "hasChildren": true, "isCollapsed": false, "block": block], + ["depth": variant % 2 + 1, "block": ["uuid": "child", "title": "Child", "pageId": "page"]]]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + try runtime.applyCoreResponse(snapshot(0)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + var failures: [String] = [] + for variant in 0..<4 { + try runtime.applyCoreResponse(snapshot(variant)) + try await Task.sleep(for: .milliseconds(500)) + window.layoutIfNeeded() + guard let title = views(window).first(where: { $0.accessibilityIdentifier == "outliner.block-action.body" }) else { fatalError("Missing title") } + let titleFrame = title.convert(title.bounds, to: window) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("alignment-\(variant).png")) + for name in ["scheduled", "deadline"] { + let attr = "logseq.property/" + name + guard let labelID = find("property.label.body." + attr), let key = frames[labelID], + let value = find("property.value.body." + attr).flatMap({ frames[$0] }), + let pageID = find("property.label.page." + attr) else { fatalError("Missing property") } + print("PROPERTY variant=\(variant) title=\(titleFrame) label=\(key) value=\(value)") + if abs(key.minX - titleFrame.minX) > 1 { failures.append("Outliner \(variant) \(name) must align with text") } + if abs(key.width - 20) > 1 || value.minX - key.maxX > 9 { failures.append("Inline dates retain compact icons") } + if backend.model(id: pageID)?.property(.text)?.stringValue != name.capitalized { + failures.append("Page dates must use ordinary text labels: \(name)") + } + } + } + guard failures.isEmpty else { throw NSError(domain: "PropertyAlignment", code: 1, + userInfo: [NSLocalizedDescriptionKey: failures.joined(separator: "; ")]) } + print("PASS: outliner properties align with text for nested, status and priority rows; page dates use labels") + } +} diff --git a/apple/Tests/UI/outliner-row.swift b/apple/Tests/UI/outliner-row.swift index a73a4f2f..5776682a 100644 --- a/apple/Tests/UI/outliner-row.swift +++ b/apple/Tests/UI/outliner-row.swift @@ -116,6 +116,31 @@ import UIKit precondition(statusButton.image(for: .normal) != nil, "Every task status must have a visible glyph") precondition(guide.isHidden == (index % 2 == 0), "Reusing a row must clear stale ancestor guides") } + var compactPayload = payload + compactPayload["status"] = NSNull() + compactPayload["tags"] = [] + compactPayload["syncFailed"] = false + compactPayload["depth"] = 0 + compactPayload["hasChildren"] = false + let leaf = try JSONDecoder().decode(OutlinerRowPayload.self, from: JSONSerialization.data(withJSONObject: compactPayload)) + native.update(leaf, emit: { _, _, _ in }) + let leafHeight = native.sizeThatFits(CGSize(width: 390, height: CGFloat.greatestFiniteMagnitude)).height + compactPayload["hasChildren"] = true + let parent = try JSONDecoder().decode(OutlinerRowPayload.self, from: JSONSerialization.data(withJSONObject: compactPayload)) + native.update(parent, emit: { _, _, _ in }) + let parentHeight = native.sizeThatFits(CGSize(width: 390, height: CGFloat.greatestFiniteMagnitude)).height + native.frame = CGRect(x: 0, y: 0, width: 390, height: parentHeight) + native.layoutIfNeeded() + let arrow = disclosure.imageView! + let arrowFrame = arrow.convert(arrow.bounds, to: native) + print("DISCLOSURE leafHeight=\(leafHeight) parentHeight=\(parentHeight) arrow=\(arrowFrame)") + precondition(abs(parentHeight - leafHeight) <= 0.1, + "The disclosure touch target must not add a blank line between a parent and its child") + precondition(native.bounds.maxX - arrowFrame.maxX <= 8, + "The disclosure glyph must sit near the trailing edge") + let compactEdge = disclosure.convert(CGPoint(x: 2, y: 42), to: native) + precondition(native.hitTest(compactEdge, with: nil) === disclosure, + "A compact parent must retain the full disclosure touch target") print("PASS: visible bullets, all task glyphs, continuous ancestor guides, compact tags, and row reuse") } } diff --git a/docs/agent-guide/014-editor-commands.md b/docs/agent-guide/014-editor-commands.md index 202a5636..fd6f8cd8 100644 --- a/docs/agent-guide/014-editor-commands.md +++ b/docs/agent-guide/014-editor-commands.md @@ -93,3 +93,11 @@ YouTube timestamp insertion needs the active player position when available and - Placeholders are hidden during editing, keep empty journal headings, and share the new empty block's bullet coordinates. - Android grouped completion buttons retain a fixed height in their vertical scroll container. Native command, page, tag and paste checks pass. - The full core gate passes 996 cases; the Name cell editor omits Clear value while other properties retain it. + +## Compact outliner follow-up + +- Reproduced the parent/child gap: the disclosure's 44-point target was participating in row height. It now overlays the first line on iOS; Android uses its compact visual cell with the platform's expanded 48-point touch target. +- The disclosure glyph sits near the trailing edge. A single-line parent and leaf both measure 28.3 points on iOS. +- Task-date properties start at the block text edge, including nesting, task status and priority. The upstream LUI fast-container path ignored individual edge padding; PR https://github.com/logseq/lui/pull/257 fixes the renderer, and the Apple dependency is pinned to that fix. +- Scheduled and Deadline use icons only in compact outliner metadata. Page and zoomed block property panels use ordinary text labels through the same renderer. +- Validation: native iOS disclosure, property alignment and container-padding checks; eight Android spacing/touch-target checks; the complete native core gate passes 996 cases. diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 462ee0ba..44ce1a9e 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -67,7 +67,7 @@ let outliner_collapse_button (context : Lui_ui.ui_context) row_source send : View_base.outliner_collapse_icon_name current_row.is_collapsed) row_source) - (button ~variant:`ghost ~width:44 ~height:44 + (button ~variant:`ghost ~width:24 ~height:24 ~accessibility_identifier: (View_base.outliner_collapse_identifier current_row) ~on_press:(fun _ -> @@ -576,7 +576,12 @@ let outliner_row_body (context : Lui_ui.ui_context) model_source let outliner_task_dates context model_source row_source send = View_base.with_int_prop_signal PaddingLeft - (Signal.map (fun (row : Model.outline_row) -> (if Lui_ui.host context = KotlinHost then 12 else 20) + row.depth * 22) row_source) + (Signal.map (fun (row : Model.outline_row) -> + let status = row.row_status <> None and priority = row.row_priority <> None in + let leading = if Lui_ui.host context = KotlinHost then + 26 + (if status then 31 else 0) + (if priority then 27 else 0) + else (if status then 49 else if priority then 20 else 26) + (if priority then 25 else 0) in + leading + row.depth * 22) row_source) (box ~padding_horizontal:8 [View_property.view ~compact:true ~render_block:(fun _ _ -> box []) model_source (Signal.map (fun (row : Model.outline_row) -> row.row_task_dates_json) row_source) send]) diff --git a/shared/src/logseq/view_property.ml b/shared/src/logseq/view_property.ml index 7be34284..02a9d539 100644 --- a/shared/src/logseq/view_property.ml +++ b/shared/src/logseq/view_property.ml @@ -80,8 +80,8 @@ let view ?(compact = false) ~render_block model_source raw_source send : t = let send_block = function | Model.BeginOutlinerEdit uuid -> edit ~value_uuid:uuid (); true | action -> send action in - let task_icon = match attr with "logseq.property/scheduled" -> Some "calendar" - | "logseq.property/deadline" -> Some "deadline" | _ -> None in + let task_icon = match compact, attr with true, "logseq.property/scheduled" -> Some "calendar" + | true, "logseq.property/deadline" -> Some "deadline" | _ -> None in let label = match task_icon with | Some name -> icon ~name:(`app name) ~width:20 ~height:20 ~foreground:"muted-foreground" ~tooltip:(text (field "title" first)) diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 485adf15..581f813e 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -528,6 +528,23 @@ let task_dates_are_visible_and_editable_in_the_outline () = check ~msg:"date editor has Cancel" (cancel <> -1); App_test.dispatch app (Lui_protocol.Press cancel); App_test.flush app) ["logseq.property/scheduled"; "logseq.property/deadline"]); + ignore (Q.dispatch session "openNode" (Yojson.Basic.to_string (`Assoc ["uuid", `String Q.alpha]))); + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + App_test.send app (Model.RequestAppNode Q.alpha); + let snapshot = match Response_snapshot.decode_response (Rpc_session.snapshot_visible session) with + | Ok value -> value | Error message -> fail message in + App_test.send app (Model.ApplyCoreSnapshot snapshot); App_test.flush app; + let host = Lui_app.runtime app in + let screen = App_test.descendant_with_identifier host (Lui_app.root_node app) "screen.node" in + let find id = App_test.descendant_with_identifier host screen id in + let expand = find ("properties.expand." ^ Q.alpha) in + if expand <> -1 then (App_test.dispatch app (Lui_protocol.Press expand); App_test.flush app); + List.iter (fun (attr, title) -> + let label = find ("property.label." ^ Q.alpha ^ "." ^ attr) in + check ~msg:"zoomed task dates use ordinary property labels" (label <> -1); + check_eq ~msg:"zoomed task date key is text rather than an icon" (App_test.node host label) (Some Lui_protocol.Text); + check_eq (App_test.property_string host label Lui_protocol.TextValue) title) + ["logseq.property/scheduled", "Scheduled"; "logseq.property/deadline", "Deadline"]); check ~msg:"displaying task dates does not add extra semantic writes" (List.length (Graph_runtime.pending_operations runtime) < 10))) [App_test.ios_backend; App_test.kotlin_backend] From 4c50ca70a97b4d396e9fd26d3bc668659ba4fc7a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 00:42:34 +0800 Subject: [PATCH 09/25] Keep task dates on one line with continuous outline guides --- .../logseq/app/AndroidOutlinerSpacingTest.kt | 18 ++++++-- .../app/ui/extensions/PropertyExtensions.kt | 25 +++++++++++ .../Sources/Logseq/LGPropertyExtension.swift | 24 ++++++++++ .../Sources/Logseq/OutlinerIndentGuides.swift | 25 ++++++++--- .../UI/outliner-property-alignment.swift | 35 ++++++++++++++- docs/agent-guide/014-editor-commands.md | 4 ++ shared/src/logseq/view.ml | 8 ++++ shared/src/logseq/view_outliner.ml | 45 ++++++++++++++----- shared/src/logseq/view_property.ml | 16 ++++--- 9 files changed, 174 insertions(+), 26 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt index 01d33c96..4220db4c 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidOutlinerSpacingTest.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.Modifier +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTouchHeightIsEqualTo import androidx.compose.ui.test.assertTouchWidthIsEqualTo @@ -51,9 +53,11 @@ class AndroidOutlinerSpacingTest { .put("uuid", "priority-high").put("ident", "logseq.property.priority/high").put("title", "High")) if (status && id == "parent") block.put("status", JSONObject() .put("uuid", "todo").put("ident", "logseq.property.status/todo").put("title", "Todo")) - if (dates) block.put("taskDates", JSONObject().put("ownerUuid", id).put("properties", JSONArray().put(JSONObject() - .put("attr", "logseq.property/scheduled").put("title", "Scheduled").put("kind", "datetime") - .put("text", "Oct 23, 09:30").put("editorValue", "1792719000000").put("editable", true)))) + if (dates) block.put("taskDates", JSONObject().put("ownerUuid", id).put("properties", JSONArray().apply { + for (name in listOf("scheduled", "deadline")) put(JSONObject() + .put("attr", "logseq.property/$name").put("title", name.replaceFirstChar { it.uppercase() }).put("kind", "datetime") + .put("text", "Oct 23, 09:30").put("editorValue", "1792719000000").put("editable", true)) + })) return JSONObject().put("depth", depth).put("hasChildren", depth == 0 && hasChildren).put("block", block) } val state = JSONObject() @@ -147,6 +151,14 @@ class AndroidOutlinerSpacingTest { val label = ui.onNodeWithTag("property.label.$id.logseq.property/scheduled").getUnclippedBoundsInRoot() val title = ui.onNodeWithText(text).getUnclippedBoundsInRoot() assertEquals("Task dates follow the text edge for variant $variant, $id", title.left.value, label.left.value, 1f) + val deadline = ui.onNodeWithTag("property.label.$id.logseq.property/deadline").getUnclippedBoundsInRoot() + assertEquals("Scheduled and deadline share one line", ((label.top + label.bottom) / 2).value, ((deadline.top + deadline.bottom) / 2).value, 1f) + } + val guide = ui.onNodeWithTag("outliner.metadata.guides.parent").captureToImage().asAndroidBitmap() + val x = with(ui.density) { 12.dp.roundToPx() } + val reference = guide.getPixel(0, guide.height / 2) + for (y in 0 until guide.height) { + assertTrue("The child guide spans the entire metadata region", guide.getPixel(x, y) != reference) } } } diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt index 5ac95f20..f5b048ca 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt @@ -1,5 +1,6 @@ package com.logseq.app.ui.extensions +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.DatePicker @@ -16,6 +17,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints @@ -41,6 +45,27 @@ internal fun LuiExtensionRegistry.registerProperties() { ) { CompositionLocalProvider(LocalPropertyValue provides true) { PropertyRow(this) } } + register("outliner-continuation", + "lui-extension-v1|21:outliner-continuation|profiles:android/kotlin,ios/swiftui|standard-children:1|children:|properties:10:identifier:string:required:none,11:guide-count:int:required:none,12:bullet-depth:int:required:none|events:", + acceptsStandardChildren = true, + properties = listOf( + LuiExtensionProperty("bullet-depth", LuiExtensionValueKind.INTEGER, required = true), + LuiExtensionProperty("guide-count", LuiExtensionValueKind.INTEGER, required = true), + LuiExtensionProperty("identifier", LuiExtensionValueKind.STRING, required = true), + ), + ) { + val count = int("guide-count") ?: 0 + val bulletDepth = int("bullet-depth") ?: -1 + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f) + Box(Modifier.fillMaxWidth().testTag("outliner." + (string("identifier") ?: "").replace("metadata.", "metadata.guides.")) + .drawBehind { + for (level in 0 until count) { + val x = (12 + level * 22).dp.toPx() + val top = if (level == bulletDepth) 17.5.dp.toPx() else 0f + if (size.height > top) drawLine(color, Offset(x, top), Offset(x, size.height), strokeWidth = 1.dp.toPx()) + } + }) { Children() } + } register("native-property-date", "lui-extension-v1|20:native-property-date|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:identifier:string:required:none,4:kind:string:required:none,5:value:string:required:none|events:6:change[5:value:string:required]", properties = listOf( diff --git a/apple/Sources/Logseq/LGPropertyExtension.swift b/apple/Sources/Logseq/LGPropertyExtension.swift index 98ff0d40..cb96e32e 100644 --- a/apple/Sources/Logseq/LGPropertyExtension.swift +++ b/apple/Sources/Logseq/LGPropertyExtension.swift @@ -21,6 +21,30 @@ enum LGPropertyExtension { } }.environment(\.lgPropertyValue, true)) }) + try registry.register(LUIAppleExtension( + identifier: "outliner-continuation", + fingerprint: "lui-extension-v1|21:outliner-continuation|profiles:android/kotlin,ios/swiftui|standard-children:1|children:|properties:10:identifier:string:required:none,11:guide-count:int:required:none,12:bullet-depth:int:required:none|events:", + acceptsStandardChildren: true, + properties: [.init(name: "bullet-depth", kind: .int, isRequired: true), + .init(name: "guide-count", kind: .int, isRequired: true), + .init(name: "identifier", kind: .string, isRequired: true)] + ) { context in + let count: Int = if case let .int(value) = context.property("guide-count") { value } else { 0 } + let identifier: String = if case let .string(value) = context.property("identifier") { value } else { "" } + return AnyView(VStack(alignment: .leading, spacing: 0) { + ForEach(context.childIDs, id: \.self) { context.content(for: $0) } + }.frame(maxWidth: .infinity, alignment: .leading) + .background { + #if os(iOS) + OutlinerIndentGuides(identifier: identifier, guideCount: count) + #else + Canvas { context, size in + context.stroke(Path(OutlinerGuideGeometry.path(depth: count, expanded: false, size: size)), + with: .color(.secondary.opacity(0.18)), lineWidth: 1) + }.allowsHitTesting(false) + #endif + }) + }) try registry.register(LUIAppleExtension( identifier: "native-property-date", fingerprint: "lui-extension-v1|20:native-property-date|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:identifier:string:required:none,4:kind:string:required:none,5:value:string:required:none|events:6:change[5:value:string:required]", diff --git a/apple/Sources/Logseq/OutlinerIndentGuides.swift b/apple/Sources/Logseq/OutlinerIndentGuides.swift index 9a1c0daf..92e22546 100644 --- a/apple/Sources/Logseq/OutlinerIndentGuides.swift +++ b/apple/Sources/Logseq/OutlinerIndentGuides.swift @@ -22,9 +22,21 @@ enum OutlinerGuideGeometry { import UIKit struct OutlinerIndentGuides: UIViewRepresentable { - let row: OutlinerRowPayload + let identifier: String + let depth: Int + let expanded: Bool + init(row: OutlinerRowPayload) { + identifier = row.uuid + depth = max(0, row.depth) + expanded = row.hasChildren && !row.collapsed + } + init(identifier: String, guideCount: Int) { + self.identifier = identifier + depth = max(0, guideCount) + expanded = false + } func makeUIView(context: Context) -> OutlinerIndentGuideView { OutlinerIndentGuideView() } - func updateUIView(_ view: OutlinerIndentGuideView, context: Context) { view.update(row) } + func updateUIView(_ view: OutlinerIndentGuideView, context: Context) { view.update(identifier: identifier, depth: depth, expanded: expanded) } } @MainActor final class OutlinerIndentGuideView: UIView { @@ -47,9 +59,12 @@ struct OutlinerIndentGuides: UIViewRepresentable { required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } func update(_ row: OutlinerRowPayload) { - accessibilityIdentifier = "outliner.indent-guides." + row.uuid - depth = max(0, row.depth) - expanded = row.hasChildren && !row.collapsed + update(identifier: row.uuid, depth: max(0, row.depth), expanded: row.hasChildren && !row.collapsed) + } + func update(identifier: String, depth: Int, expanded: Bool) { + accessibilityIdentifier = "outliner.indent-guides." + identifier + self.depth = depth + self.expanded = expanded isHidden = depth == 0 && !expanded setNeedsLayout() } diff --git a/apple/Tests/UI/outliner-property-alignment.swift b/apple/Tests/UI/outliner-property-alignment.swift index 83b202f6..25b1138c 100644 --- a/apple/Tests/UI/outliner-property-alignment.swift +++ b/apple/Tests/UI/outliner-property-alignment.swift @@ -65,12 +65,45 @@ import UIKit let value = find("property.value.body." + attr).flatMap({ frames[$0] }), let pageID = find("property.label.page." + attr) else { fatalError("Missing property") } print("PROPERTY variant=\(variant) title=\(titleFrame) label=\(key) value=\(value)") - if abs(key.minX - titleFrame.minX) > 1 { failures.append("Outliner \(variant) \(name) must align with text") } + if name == "scheduled", abs(key.minX - titleFrame.minX) > 1 { failures.append("Outliner \(variant) must align with text") } + if name == "deadline", let scheduled = find("property.label.body.logseq.property/scheduled").flatMap({ frames[$0] }), + abs(key.midY - scheduled.midY) > 1 { failures.append("Task dates must share one line") } if abs(key.width - 20) > 1 || value.minX - key.maxX > 9 { failures.append("Inline dates retain compact icons") } if backend.model(id: pageID)?.property(.text)?.stringValue != name.capitalized { failures.append("Page dates must use ordinary text labels: \(name)") } } + guard let guide = views(window).first(where: { $0.accessibilityIdentifier == "outliner.indent-guides.metadata.body" }), + let shape = guide.layer as? CAShapeLayer, let path = shape.path, + let child = views(window).first(where: { $0.accessibilityIdentifier == "outliner.block-action.child" }) else { + failures.append("Properties must continue the ancestor and child guides") + continue + } + let guideFrame = guide.convert(guide.bounds, to: window) + let childFrame = child.convert(child.bounds, to: window) + var segments: [CGPoint] = [] + path.applyWithBlock { element in + if element.pointee.type == .moveToPoint || element.pointee.type == .addLineToPoint { + segments.append(element.pointee.points[0]) + } + } + if segments.count != (variant % 2 + 1) * 2 || segments.contains(where: { $0.y != 0 && abs($0.y - guide.bounds.height) > 0.1 }) { + failures.append("Every metadata guide must span its full height") + } + if abs(guideFrame.minY - titleFrame.maxY - 2) > 0.5 || abs(guideFrame.maxY - childFrame.minY + 2) > 0.5 { + failures.append("Property guides must join both adjacent block rows: \(guideFrame), \(titleFrame), \(childFrame)") + } + } + for name in ["scheduled", "deadline"] { + try backend.performPress(node: find("property.value.body.logseq.property/" + name)!) + for _ in 0..<100 { + if views(window).contains(where: { $0 is UIDatePicker }) { break } + try await Task.sleep(for: .milliseconds(20)) + } + if !views(window).contains(where: { $0 is UIDatePicker }) { failures.append("Inline \(name) must open a native picker") } + guard let cancel = find("view.edit.cancel.property.body.logseq.property/" + name) else { fatalError("Missing date editor Cancel") } + try backend.performPress(node: cancel) + try await Task.sleep(for: .milliseconds(350)) } guard failures.isEmpty else { throw NSError(domain: "PropertyAlignment", code: 1, userInfo: [NSLocalizedDescriptionKey: failures.joined(separator: "; ")]) } diff --git a/docs/agent-guide/014-editor-commands.md b/docs/agent-guide/014-editor-commands.md index fd6f8cd8..1e485690 100644 --- a/docs/agent-guide/014-editor-commands.md +++ b/docs/agent-guide/014-editor-commands.md @@ -101,3 +101,7 @@ YouTube timestamp insertion needs the active player position when available and - Task-date properties start at the block text edge, including nesting, task status and priority. The upstream LUI fast-container path ignored individual edge padding; PR https://github.com/logseq/lui/pull/257 fixes the renderer, and the Apple dependency is pinned to that fix. - Scheduled and Deadline use icons only in compact outliner metadata. Page and zoomed block property panels use ordinary text labels through the same renderer. - Validation: native iOS disclosure, property alignment and container-padding checks; eight Android spacing/touch-target checks; the complete native core gate passes 996 cases. + +- Scheduled and Deadline share one horizontally scrollable compact metadata row. The full page/zoomed property panel retains its regular label layout. +- A native continuation wrapper carries ancestor/child guides through metadata, full property panels and query content. The wrapper uses the same 22-point indentation and 12-point bullet center, draws no hit surface, and suppresses the current child guide when collapsed. +- Native iOS checks verify shared date baselines, guide path coverage and row joins, and both date picker interactions. Eleven Android spacing, property editor and completion checks pass; the full core gate remains green (996 cases). diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index 3426a6d3..d3d26e99 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -209,6 +209,13 @@ let property_row_schema () = component "native-property-row" [profile IOS SwiftUIHost; profile AndroidOS KotlinHost] true [] [property "stacked" BoolScalar true None] [] +let outliner_continuation_schema () = + let open Lui_protocol in + let open Lui_extension in + component "outliner-continuation" [profile IOS SwiftUIHost; profile AndroidOS KotlinHost] + true [] [property "guide-count" IntScalar true None; property "identifier" StringScalar true None; + property "bullet-depth" IntScalar true None] [] + let property_date_schema () = let open Lui_protocol in let open Lui_extension in @@ -222,6 +229,7 @@ let extension_registry () = Lui_extension.register_component registry (node_icon_schema ()); Lui_extension.register_component registry (property_row_schema ()); Lui_extension.register_component registry (property_date_schema ()); + Lui_extension.register_component registry (outliner_continuation_schema ()); Lui_extension.register_component registry (outliner_editor_schema ()); Lui_extension.register_component registry (outliner_block_row_schema ()); Lui_extension.register_component registry diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 44ce1a9e..c954ece3 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -574,14 +574,30 @@ let outliner_row_body (context : Lui_ui.ui_context) model_source retained_row_source row_source send search_open; ])) +let outliner_text_leading context (row : Model.outline_row) = + let status = row.row_status <> None and priority = row.row_priority <> None in + let leading = if Lui_ui.host context = KotlinHost then + 26 + (if status then 31 else 0) + (if priority then 27 else 0) + else (if status then 49 else if priority then 20 else 26) + (if priority then 25 else 0) in + leading + row.depth * 22 + +let outliner_continuation ?(is_body = false) row_source content : t = fun context parent -> + let node = Lui_ui.extension context "outliner-continuation" in + attach context parent node; + Lui_ui.extension_property_signal context node "guide-count" + (Signal.map (fun (row : Model.outline_row) -> IntValue + (row.depth + if row.has_children && not row.is_collapsed then 1 else 0)) row_source); + Lui_ui.extension_property_signal context node "bullet-depth" + (Signal.map (fun (row : Model.outline_row) -> IntValue + (if is_body && row.has_children && not row.is_collapsed then row.depth else -1)) row_source); + Lui_ui.extension_property_signal context node "identifier" + (Signal.map (fun (row : Model.outline_row) -> StringValue ((if is_body then "body." else "metadata.") ^ row.row_uuid)) row_source); + ignore (content context (Some node)); + node + let outliner_task_dates context model_source row_source send = View_base.with_int_prop_signal PaddingLeft - (Signal.map (fun (row : Model.outline_row) -> - let status = row.row_status <> None and priority = row.row_priority <> None in - let leading = if Lui_ui.host context = KotlinHost then - 26 + (if status then 31 else 0) + (if priority then 27 else 0) - else (if status then 49 else if priority then 20 else 26) + (if priority then 25 else 0) in - leading + row.depth * 22) row_source) + (Signal.map (outliner_text_leading context) row_source) (box ~padding_horizontal:8 [View_property.view ~compact:true ~render_block:(fun _ _ -> box []) model_source (Signal.map (fun (row : Model.outline_row) -> row.row_task_dates_json) row_source) send]) @@ -593,8 +609,9 @@ let render_result_block context model_source row_source send = if_ ~test:(Signal.map View_base.outliner_row_fallback_breadcrumb_ row_source) (text ~value_signal:(Signal.map View_base.outliner_row_breadcrumb row_source) ~style_class:"caption" ~foreground:"muted-foreground" []); - outliner_row_body context model_source retained row_source send; - outliner_task_dates context model_source row_source send] + (let body = outliner_row_body context model_source retained row_source send in + if Lui_ui.host context = KotlinHost then outliner_continuation ~is_body:true row_source body else body); + outliner_continuation row_source (outliner_task_dates context model_source row_source send)] let node_properties ?(root = false) context model_source send = View_property.view ~render_block:(render_result_block context model_source) model_source @@ -603,19 +620,23 @@ let node_properties ?(root = false) context model_source send = let outliner_row context model_source retained_row_source row_source send : t = let body = outliner_row_body context model_source retained_row_source row_source send in + let body = if Lui_ui.host context = KotlinHost then outliner_continuation ~is_body:true row_source body else body in let render_block = render_result_block context model_source in let bullet_inset = if Lui_ui.host context = KotlinHost then 12 else 20 in dyn (fun (has_query, has_properties, has_dates) -> if not has_query && not has_properties && not has_dates then body else let dates = if has_properties || not has_dates then [] else [outliner_task_dates context model_source row_source send] in - column ~gap:0 ([body] @ dates @ - (if has_properties then [box ~margin_left:16 [node_properties context model_source send]] else []) @ - (if not has_query then [] else [ + let properties = if not has_properties then [] else [ + View_base.with_int_prop_signal PaddingLeft + (Signal.map (fun row -> max 0 (outliner_text_leading context row - 16)) row_source) + (box [node_properties context model_source send])] in + let query = if not has_query then [] else [ View_base.with_int_prop_signal PaddingLeft (Signal.map (fun (row : Model.outline_row) -> bullet_inset + row.depth * 22) row_source) (box ~padding_horizontal:8 ~accessibility_identifier_signal:(Signal.map (fun (row : Model.outline_row) -> "query.inset." ^ row.row_uuid) row_source) - [View_query.view ~render_block model_source row_source send])]))) + [View_query.view ~render_block model_source row_source send])] in + column ~gap:0 [body; outliner_continuation row_source (column ~gap:0 (dates @ properties @ query))]) (Signal.map2 (fun (model : Model.logseq_model) (row : Model.outline_row) -> row.query_json <> "", (match View_base.active_node_projection model with | Some route -> route.node_uuid = row.row_uuid && route.node_uuid <> route.node_page_uuid diff --git a/shared/src/logseq/view_property.ml b/shared/src/logseq/view_property.ml index 02a9d539..faa54b32 100644 --- a/shared/src/logseq/view_property.ml +++ b/shared/src/logseq/view_property.ml @@ -43,8 +43,12 @@ let view ?(compact = false) ~render_block model_source raw_source send : t = let all_properties = own (Signal.map (fun json -> list (field "properties" json)) source) in let properties = own (Signal.map2 (fun values expanded -> if expanded then values else List.filteri (fun index _ -> index < 3) values) all_properties (Signal.value expanded)) in - View_base.with_string_prop Lui_protocol.FontSize (if compact then "14" else "16") (column ~gap:(if compact then 0 else 2) ~padding_horizontal:(if compact then 0 else 16) ~margin_bottom:(if compact then 0 else 12) ~cross:`stretch - ~accessibility_identifier:("properties." ^ owner) [ + let layout children = if compact then + scroll ~orientation:`horizontal ~height:28 ~accessibility_identifier:("properties." ^ owner) + [row ~gap:16 ~cross:`center children] + else column ~gap:2 ~padding_horizontal:16 ~margin_bottom:12 ~cross:`stretch + ~accessibility_identifier:("properties." ^ owner) children in + View_base.with_string_prop Lui_protocol.FontSize (if compact then "14" else "16") (layout [ keyed ~source:properties ~key:(fun property -> text (field "attr" property)) ~cmp:String.compare ~mount:(fun property_source -> fun context parent -> let own source = Signal.own_signal context.ui_scope source in @@ -90,7 +94,7 @@ let view ?(compact = false) ~render_block model_source raw_source send : t = ~foreground:"muted-foreground" ~style_class:"callout" ~text_alignment:`start ~accessibility_identifier:("property.label." ^ owner ^ "." ^ attr) [] in let value = button ~variant:`ghost ~foreground:"muted-foreground" ~text_alignment:`start - ~min_height:(if compact then 28 else 32) ~padding_horizontal:0 ~padding_vertical:0 ~grow:1.0 + ~min_height:(if compact then 28 else 32) ~padding_horizontal:0 ~padding_vertical:0 ~grow:(if compact then 0.0 else 1.0) ~text_signal:(own (Signal.map (fun p -> let value = display_value p in if value = "" then "Empty" else value) property_source)) ~label:(text (field "title" first)) ~disabled:(field "editable" first <> `Bool true) ~accessibility_identifier:("property.value." ^ owner ^ "." ^ attr) ~on_press:(fun _ -> edit ()) [] in @@ -103,8 +107,10 @@ let view ?(compact = false) ~render_block model_source raw_source send : t = Signal.set checked value; submit ["attr", `String attr; "expected", field "raw" (Signal.sample property_source); "value", `String (string_of_bool value)] | _ -> ()) [] in - (column ~gap:2 ~cross:`stretch [ - adaptive_row (own (Signal.map (fun p -> field "textLayout" p = `Bool true) property_source)) [ + let fields children = if compact then row ~gap:6 ~cross:`center children + else adaptive_row (own (Signal.map (fun p -> field "textLayout" p = `Bool true) property_source)) children in + (column ~gap:2 ~cross:(if compact then `start else `stretch) [ + fields [ label; dyn (fun text_layout -> if text_layout then dyn (fun empty -> if empty then row [value] else box ~opacity:0.72 [ From 89b151aedcec56fd88cb3bdf3c403910f0c7b4b0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 02:45:24 +0800 Subject: [PATCH 10/25] Fix mobile editor stability and preserve structured block clipboard data --- .../assets/query-result-views.json | 32 +- .../logseq/app/AndroidEditorCompletionTest.kt | 69 ++++- .../logseq/app/AndroidQueryResultViewsTest.kt | 14 + .../com/logseq/app/AndroidBlockClipboard.kt | 30 ++ .../com/logseq/app/runtime/LogseqRuntime.kt | 3 +- .../app/ui/extensions/LogseqExtensions.kt | 3 +- .../app/ui/extensions/OutlinerEditor.kt | 62 ++-- apple/App/Info.plist | 13 + apple/Package.resolved | 2 +- apple/Package.swift | 2 +- apple/Sources/Logseq/LGBlockClipboard.swift | 40 +++ .../Logseq/LGInteractionDiagnostics.swift | 123 ++++++++ .../Logseq/LGNavigationExtension.swift | 56 ++-- .../Logseq/LGOutlinerEditorExtension.swift | 4 +- .../Logseq/LGPlatformCommandRouter.swift | 2 + apple/Sources/Logseq/LGRuntime.swift | 8 +- .../Sources/Logseq/OutlinerInlineEditor.swift | 43 ++- apple/Tests/UI/drawer-horizontal-scroll.swift | 8 + apple/Tests/UI/editor-completion.swift | 8 + apple/Tests/UI/nested-selection-toolbar.swift | 116 ++++++++ apple/Tests/UI/outliner-clipboard.swift | 120 ++++++++ .../Tests/UI/outliner-editor-retention.swift | 90 ++++++ .../UI/outliner-hydration-stability.swift | 69 +++++ .../UI/outliner-keyboard-stability.swift | 78 +++++ .../Tests/UI/outliner-scrolling-editor.swift | 65 ++++ apple/Tests/UI/query-block-results.swift | 28 +- apple/Tests/UI/run-simulator-runtime-check.py | 2 + .../016-mobile-interaction-regressions.md | 125 ++++++++ logseq.opam | 2 +- shared/native/dune_modules/core.sexp | 1 + shared/src/logseq/core/block_clipboard.ml | 281 ++++++++++++++++++ shared/src/logseq/core/block_clipboard.mli | 20 ++ shared/src/logseq/core/core_effect.ml | 4 + .../src/logseq/core/editor_command_effects.ml | 2 +- shared/src/logseq/core/graph_runtime.ml | 2 +- shared/src/logseq/core/host_update.ml | 6 + shared/src/logseq/core/host_update.mli | 1 + shared/src/logseq/core/outliner_effects.ml | 17 +- shared/src/logseq/core/outliner_effects.mli | 1 + shared/src/logseq/core/outliner_state.ml | 79 ++--- shared/src/logseq/core/outliner_state.mli | 5 +- shared/src/logseq/core/pending_ops.ml | 4 +- shared/src/logseq/core/pending_ops.mli | 1 + shared/src/logseq/core/pending_projection.ml | 9 +- shared/src/logseq/core/result_view.ml | 13 +- shared/src/logseq/core/rpc_encode.ml | 2 + shared/src/logseq/core/rpc_ops.ml | 7 +- shared/src/logseq/core/rpc_session.ml | 105 ++++++- shared/src/logseq/core/rpc_wire.ml | 9 + shared/src/logseq/core/string_kit.ml | 31 ++ shared/src/logseq/core/string_kit.mli | 5 + shared/src/logseq/model.ml | 130 ++++++-- shared/src/logseq/model.mli | 37 ++- shared/src/logseq/native_bridge.ml | 17 +- shared/src/logseq/response_snapshot.ml | 5 +- shared/src/logseq/view.ml | 1 + shared/src/logseq/view_base.ml | 4 +- shared/src/logseq/view_composer.ml | 54 +++- shared/src/logseq/view_outliner.ml | 9 +- shared/src/logseq/view_query.ml | 32 +- shared/src/logseq/view_rows.ml | 13 +- shared/test/logseq/app_test.ml | 62 +++- shared/test/logseq/block_clipboard_test.ml | 245 +++++++++++++++ shared/test/logseq/composer_tag_test.ml | 246 +++++++++++++++ shared/test/logseq/editor_command_test.ml | 2 +- shared/test/logseq/editor_exit_test.ml | 74 +++++ shared/test/logseq/outliner_state_test.ml | 2 +- shared/test/logseq/pending_ops_test.ml | 2 +- shared/test/logseq/pending_projection_test.ml | 2 +- shared/test/logseq/query_block_test.ml | 10 +- shared/test/logseq/result_view_test.ml | 64 +++- shared/test/logseq/rpc_test.ml | 3 +- shared/test/logseq/test_main.ml | 3 + 73 files changed, 2601 insertions(+), 238 deletions(-) create mode 100644 android/app/src/main/kotlin/com/logseq/app/AndroidBlockClipboard.kt create mode 100644 apple/Sources/Logseq/LGBlockClipboard.swift create mode 100644 apple/Sources/Logseq/LGInteractionDiagnostics.swift create mode 100644 apple/Tests/UI/nested-selection-toolbar.swift create mode 100644 apple/Tests/UI/outliner-clipboard.swift create mode 100644 apple/Tests/UI/outliner-editor-retention.swift create mode 100644 apple/Tests/UI/outliner-hydration-stability.swift create mode 100644 apple/Tests/UI/outliner-keyboard-stability.swift create mode 100644 apple/Tests/UI/outliner-scrolling-editor.swift create mode 100644 docs/agent-guide/016-mobile-interaction-regressions.md create mode 100644 shared/src/logseq/core/block_clipboard.ml create mode 100644 shared/src/logseq/core/block_clipboard.mli create mode 100644 shared/test/logseq/block_clipboard_test.ml create mode 100644 shared/test/logseq/composer_tag_test.ml create mode 100644 shared/test/logseq/editor_exit_test.ml diff --git a/android/app/src/androidTest/assets/query-result-views.json b/android/app/src/androidTest/assets/query-result-views.json index 5b182c2e..f7152823 100644 --- a/android/app/src/androidTest/assets/query-result-views.json +++ b/android/app/src/androidTest/assets/query-result-views.json @@ -43,7 +43,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 1" @@ -79,7 +79,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 2" @@ -115,7 +115,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 3" @@ -144,7 +144,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": true + "editable": false }, { "attr": "user.property/Score", @@ -199,7 +199,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 1" @@ -235,7 +235,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 2" @@ -271,7 +271,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 3" @@ -301,7 +301,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": true + "editable": false }, { "attr": "user.property/Score", @@ -356,7 +356,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 1" @@ -392,7 +392,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 2" @@ -428,7 +428,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 3" @@ -458,7 +458,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": true + "editable": false }, { "attr": "user.property/Score", @@ -513,7 +513,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 1" @@ -549,7 +549,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 2" @@ -585,7 +585,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": true, + "editable": false, "raw": { "type": "string", "value": "Project result 3" @@ -615,7 +615,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": true + "editable": false }, { "attr": "user.property/Score", diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt index e385af41..ffe85852 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt @@ -8,12 +8,14 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.logseq.app.runtime.LogseqNativeBridge @@ -73,6 +75,11 @@ class AndroidEditorCompletionTest { ui.onNodeWithTag("toolbar.outliner.autocomplete").assertIsDisplayed() if (kind == "command") ui.onNodeWithTag("outliner.autocomplete.group.BASIC").assertIsDisplayed() ui.onNodeWithTag("button.outliner.autocomplete.dismiss").assertIsDisplayed() + val panel = ui.onNodeWithTag("toolbar.outliner.autocomplete").fetchSemanticsNode().boundsInRoot + val item = ui.onNodeWithTag("button.outliner.autocomplete.0").fetchSemanticsNode().boundsInRoot + val density = InstrumentationRegistry.getInstrumentation().targetContext.resources.displayMetrics.density + assertTrue("Every $kind option fills the menu width", item.width >= panel.width - 17 * density) + assertTrue("Grouped options retain their natural height", kotlin.math.abs(item.height - 44 * density) <= density) ui.onNodeWithTag("button.outliner.autocomplete.0").assertIsDisplayed().performClick() var chosen: JSONObject? = null val observed = mutableListOf() @@ -102,11 +109,18 @@ class AndroidEditorCompletionTest { } } - @Test fun nativePasteCarriesItsOriginAcrossTheBridge() { + @Test fun nativePasteCarriesItsOriginAcrossTheBridge() = checkNativePaste(null) + + @Test fun nativeRichPasteKeepsTheBlockSnapshot() = checkNativePaste("{:graph \"demo\" :op :copy :blocks []}") + + @Test fun failedPasteReleasesTheEditorAndKeepsNewTyping() = checkNativePaste(null, succeeds = false) + + private fun checkNativePaste(rich: String?, succeeds: Boolean = true) { val context = InstrumentationRegistry.getInstrumentation().targetContext InstrumentationRegistry.getInstrumentation().runOnMainSync { - (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) + if (rich == null) (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) .setPrimaryClip(ClipData.newPlainText("test", "Pasted text /")) + else AndroidBlockClipboard.copy(context, JSONObject().put("plainText", "Pasted text /").put("blocksEDN", rich).toString()) } start("Keep ", null) ui.onNodeWithTag("field.outliner.block.editing").performTouchInput { longClick() } @@ -132,14 +146,10 @@ class AndroidEditorCompletionTest { throw error } assertTrue(automation.windows.firstNotNullOf { pasteAction(it.root) }.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)) - ui.waitUntil(timeoutMillis = 5000) { - ui.onNodeWithTag("field.outliner.block.editing").fetchSemanticsNode().config - .getOrElse(androidx.compose.ui.semantics.SemanticsProperties.EditableText) { androidx.compose.ui.text.AnnotatedString("") } - .text.contains("Pasted text /") - } ui.mainClock.advanceTimeBy(200) ui.waitForIdle() var pasted = false + var pasteId = -1L val observed = mutableListOf() try { ui.waitUntil(timeoutMillis = 5000) { ui.runOnIdle { @@ -147,13 +157,54 @@ class AndroidEditorCompletionTest { if (encoded.isNotEmpty()) { observed.add(encoded) JSONObject(encoded).optJSONObject("effect")?.let { effect -> - pasted = effect.optString("kind") == "paste-outliner-text" && - effect.optString("text").contains("Pasted text /") + if (effect.optString("kind") == "editor-outliner-event") { + val payload = JSONObject(effect.optString("text")) + pasted = payload.optString("type") == "pasteContent" && + payload.optString("beforeTitle") == "Keep " && + payload.optString("plainText") == "Pasted text /" && + payload.optString("blocksEDN") == rich.orEmpty() && payload.optInt("selectionStart", -1) >= 0 + if (pasted) pasteId = effect.getLong("id") + } } } } pasted } } catch (error: Throwable) { throw AssertionError("Paste effects: $observed", error) } assertTrue("A pasted slash must reach the core as a paste: $observed", pasted) + val text = ui.onNodeWithTag("field.outliner.block.editing").fetchSemanticsNode().config + .getOrElse(androidx.compose.ui.semantics.SemanticsProperties.EditableText) { androidx.compose.ui.text.AnnotatedString("") }.text + assertEquals("The shared core decides how the paste changes the block", "Keep ", text) + ui.onNodeWithTag("field.outliner.block.editing").performTextInput("Y") + val nextId = if (rich == null) "editing" else "inserted" + ui.runOnIdle { + if (succeeds) { + val response = JSONObject(snapshot(null, "Accepted")) + response.getJSONObject("result").getJSONArray("outlinerRows").getJSONObject(0).getJSONObject("block").put("uuid", nextId) + response.getJSONObject("result").getJSONObject("outlinerState").getJSONObject("editing").put("uuid", nextId) + bridge.applySnapshot(response.toString()).takeIf { it.isNotEmpty() }?.let(filter::applyJson) + } + bridge.resolveEffect(pasteId, succeeds, if (succeeds) "" else "Storage failed").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + } + val expectedId = if (succeeds) nextId else "editing" + val expected = if (succeeds) "AcceptedY" else "Keep Y" + ui.waitUntil(timeoutMillis = 5000) { + ui.onNodeWithTag("field.outliner.block.$expectedId").fetchSemanticsNode().config + .getOrElse(androidx.compose.ui.semantics.SemanticsProperties.EditableText) { androidx.compose.ui.text.AnnotatedString("") }.text == expected + } + ui.onNodeWithTag("field.outliner.block.$expectedId").assertIsFocused().performTextInput("Z") + ui.mainClock.advanceTimeBy(200) + ui.waitForIdle() + var continued = false + ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + repeat(24) { + val encoded = bridge.takeEffect() + if (encoded.isNotEmpty()) JSONObject(encoded).optJSONObject("effect")?.let { effect -> + if (effect.optString("kind") == "change-outliner-text" && effect.optString("text") == expected + "Z") continued = true + } + } + } + continued + } } } diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt index b04fe6a1..b4f2d406 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.captureToImage @@ -211,6 +212,19 @@ class AndroidQueryResultViewsTest { ui.onNodeWithTag("view.table.scroll.table").performTouchInput { swipeLeft() } ui.onNodeWithTag("view.table.scroll.table").performTouchInput { swipeRight() } ui.onNodeWithTag("sidebar.content").assertIsNotDisplayed() + ui.onNodeWithTag("view.cell.table.result-0.block/title").assertHasNoClickAction() + ui.onNodeWithTag("view.edit.sheet.table").assertDoesNotExist() + for (attr in listOf("user.property/Score")) { + val header = ui.onNodeWithTag("view.cell.separator.table.header.$attr", useUnmergedTree = true) + .getUnclippedBoundsInRoot() + for (row in listOf("result-0", "result-1", "result-2")) { + val divider = ui.onNodeWithTag("view.cell.separator.table.$row.$attr", useUnmergedTree = true) + .getUnclippedBoundsInRoot() + assertEquals("Column dividers stay one point wide", 1f, (divider.right - divider.left).value, 0.2f) + assertTrue("Column dividers span the cell height", (divider.bottom - divider.top).value >= 48f) + assertEquals("Header and body dividers align", header.left.value, divider.left.value, 0.5f) + } + } capture("table") ui.onNodeWithTag("view.cell.table.result-0.user.property/Score") .performScrollTo().performClick() diff --git a/android/app/src/main/kotlin/com/logseq/app/AndroidBlockClipboard.kt b/android/app/src/main/kotlin/com/logseq/app/AndroidBlockClipboard.kt new file mode 100644 index 00000000..e69522e5 --- /dev/null +++ b/android/app/src/main/kotlin/com/logseq/app/AndroidBlockClipboard.kt @@ -0,0 +1,30 @@ +package com.logseq.app + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import org.json.JSONObject + +internal object AndroidBlockClipboard { + private const val MIME = "application/logseq" + + fun copy(context: Context, payload: String) { + val fields = JSONObject(payload) + val plain = fields.getString("plainText") + val rich = fields.getString("blocksEDN") + val item = ClipData.Item(plain, null, Intent().putExtra(MIME, rich), null) + context.getSystemService(ClipboardManager::class.java) + .setPrimaryClip(ClipData("Logseq", arrayOf("text/plain", MIME), item)) + } + + fun content(context: Context, before: String, start: Int, length: Int): String? { + val clip = context.getSystemService(ClipboardManager::class.java).primaryClip ?: return null + if (clip.itemCount == 0) return null + val item = clip.getItemAt(0) + val plain = item.coerceToText(context)?.toString() ?: return null + return JSONObject().put("beforeTitle", before).put("selectionStart", start) + .put("selectionLength", length).put("plainText", plain) + .put("blocksEDN", item.intent?.getStringExtra(MIME).orEmpty()).toString() + } +} diff --git a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt index 97c551c5..db273a5f 100644 --- a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt +++ b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt @@ -466,7 +466,7 @@ internal class LogseqRuntime( scope.launch { runCatching { graphLifecycle?.loadSidebar() } } } if (resolution.succeeded && - effect.kind in setOf("send-capture", "send-task", "sync-now") + effect.kind in setOf("send-capture", "send-task", "send-tagged", "sync-now") ) { requestSyncNow() } @@ -687,6 +687,7 @@ internal class LogseqRuntime( "haptic" -> performHaptic(command.style) "confirmDelete" -> confirmDeletion(command.uuids) "setClipboardText" -> platformServices.copyText(command.text.orEmpty()) + "setClipboardBlocks" -> com.logseq.app.AndroidBlockClipboard.copy(activity, command.text.orEmpty()) "setClipboardReferences" -> platformServices.copyText( command.uuids.joinToString("\n") { "[[$it]]" } ) diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt index 77806b4f..01a6c9da 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt @@ -60,7 +60,7 @@ private const val EDITOR_FINGERPRINT = "properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:" + "required:none,8:block-id:string:required:none|events:11:text-change[" + "18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[" + - "18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:" + + "18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:" + "required,5:title:string:required],9:backspace[16:selection-length:int:" + "required,5:title:string:required]" private const val RICH_FINGERPRINT = @@ -109,6 +109,7 @@ fun logseqExtensionRegistry( LuiExtensionProperty("block-id", LuiExtensionValueKind.STRING), ), events = listOf( + LuiExtensionEventSchema("paste-content", listOf(LuiExtensionProperty("value", LuiExtensionValueKind.STRING, required = true))), LuiExtensionEventSchema( "paste", listOf( diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt index 928828ca..61e93cba 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt @@ -78,22 +78,8 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val handoffSegments = remember { ArrayDeque() } var applyingModel by remember { mutableStateOf(false) } var debounceSeq by remember { mutableIntStateOf(0) } - var pasting by remember { mutableStateOf(false) } - var pastedChange by remember { mutableStateOf(false) } + var pasteRevision by remember { mutableStateOf(null) } val nativeToolbar = LocalTextToolbar.current - val completionToolbar = remember(nativeToolbar) { - object : TextToolbar { - override val status get() = nativeToolbar.status - override fun hide() = nativeToolbar.hide() - override fun showMenu(rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, - onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)?) { - nativeToolbar.showMenu(rect, onCopyRequested, onPasteRequested?.let { paste -> { - pasting = true - paste() - } }, onCutRequested, onSelectAllRequested) - } - } - } val androidContext = LocalContext.current val clipboard = remember(androidContext) { androidContext.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager @@ -104,7 +90,9 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { // Reconcile a structural handoff in this composition, so the IME sees the // new text, caret and identity together instead of waiting another frame. - if (blockId != rememberedBlockId && awaitingHandoff) { + val pasteAcknowledged = pasteRevision != null && selectionRevision != pasteRevision + if (awaitingHandoff && (blockId != rememberedBlockId || pasteAcknowledged)) { + pasteRevision = null val typing = handoffSegments.removeFirstOrNull().orEmpty() val offset = modelCaret.coerceIn(0, modelTitle.length) val merged = modelTitle.substring(0, offset) + typing + modelTitle.substring(offset) @@ -163,7 +151,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { LaunchedEffect(Unit) { focusRequester.requestFocus() } // Debounced text-change emission, cancelled by the next edit. LaunchedEffect(debounceSeq) { - if (debounceSeq == 0 || awaitingHandoff || pastedChange || value.composition != null) return@LaunchedEffect + if (debounceSeq == 0 || awaitingHandoff || value.composition != null) return@LaunchedEffect delay(80) val title = value.text publishedTitles.add(title) @@ -252,17 +240,9 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { if (prior.composition != null && next.composition == null) debounceSeq++ return } - pastedChange = pasting - pasting = false val title = next.text val caret = next.selection.start.coerceAtLeast(0) - if (pastedChange) { - debounceSeq++ - publishedTitles.add(title) - context.emit("paste", "title" to title, "caret-utf16-offset" to caret) - return - } val newline = caret - 1 val singleInsertion = title.length == previousText.length + 1 && @@ -278,7 +258,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { previousSelection.end, "\n", ) == title - if (!multiline && !pastedChange && (singleInsertion || replacedSelection)) { + if (!multiline && (singleInsertion || replacedSelection)) { val splitAt = if (replacedSelection) previousSelection.start else newline val submitted = title.removeRange(splitAt, splitAt + 1) val submittedCaret = splitAt.coerceIn(0, submitted.length) @@ -292,6 +272,27 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } } + fun submitPaste() { + val payload = com.logseq.app.AndroidBlockClipboard.content(androidContext, value.text, + value.selection.min, value.selection.max - value.selection.min) ?: return + debounceSeq++ + pasteRevision = selectionRevision + awaitingHandoff = true + handoffSegments.addLast("") + context.emit("paste-content", "value" to payload) + } + val completionToolbar = remember(nativeToolbar, value, selectionRevision) { + object : TextToolbar { + override val status get() = nativeToolbar.status + override fun hide() = nativeToolbar.hide() + override fun showMenu(rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)?) { + nativeToolbar.showMenu(rect, onCopyRequested, onPasteRequested?.let { { submitPaste() } }, + onCutRequested, onSelectAllRequested) + } + } + } + CompositionLocalProvider(LocalTextToolbar provides completionToolbar) { BasicTextField( value = value, @@ -302,10 +303,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val clip = clipboard.primaryClip val text = if (clip != null && clip.itemCount > 0) clip.getItemAt(0).coerceToText(androidContext)?.toString() else null if (text != null) item(pasteKey, androidContext.getString(android.R.string.paste)) { - val start = value.selection.min.coerceIn(0, value.text.length) - val end = value.selection.max.coerceIn(start, value.text.length) - pasting = true - acceptValue(TextFieldValue(value.text.replaceRange(start, end, text), TextRange(start + text.length))) + submitPaste() close() } } @@ -316,8 +314,8 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { .onPreviewKeyEvent { event -> if (event.type == KeyEventType.KeyDown && ((event.key == Key.V && event.isCtrlPressed) || (event.key == Key.Insert && event.isShiftPressed))) { - pasting = true - return@onPreviewKeyEvent false + submitPaste() + return@onPreviewKeyEvent true } if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && event.isShiftPressed) { val start = value.selection.min.coerceIn(0, value.text.length) diff --git a/apple/App/Info.plist b/apple/App/Info.plist index d3f6e116..4a8e0af1 100644 --- a/apple/App/Info.plist +++ b/apple/App/Info.plist @@ -43,6 +43,19 @@ + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.logseq.blocks + UTTypeDescription + Logseq Blocks + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.mime-typeapplication/logseq + + BGTaskSchedulerPermittedIdentifiers com.logseq.logseq.refresh diff --git a/apple/Package.resolved b/apple/Package.resolved index 4bb3a342..781beafa 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" : "6b25738617d685311f12c7eddbe7c557a6e0adcd" + "revision" : "eea10c7bf99ad1a2a40cc84b1c9657a764073d72" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 7d9833ee..9b799885 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: "6b25738617d685311f12c7eddbe7c557a6e0adcd"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "eea10c7bf99ad1a2a40cc84b1c9657a764073d72"), .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/apple/Sources/Logseq/LGBlockClipboard.swift b/apple/Sources/Logseq/LGBlockClipboard.swift new file mode 100644 index 00000000..f6c8907f --- /dev/null +++ b/apple/Sources/Logseq/LGBlockClipboard.swift @@ -0,0 +1,40 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@MainActor enum LGBlockClipboard { + static let typeIdentifier = "com.logseq.blocks" + + static func write(_ payload: String) -> Bool { + guard let data = payload.data(using: .utf8), + let fields = try? JSONDecoder().decode([String: String].self, from: data), + let plain = fields["plainText"], let rich = fields["blocksEDN"] else { return false } + #if canImport(UIKit) + UIPasteboard.general.items = [["public.utf8-plain-text": Data(plain.utf8), typeIdentifier: Data(rich.utf8)]] + #elseif canImport(AppKit) + let item = NSPasteboardItem() + item.setString(plain, forType: .string) + item.setData(Data(rich.utf8), forType: .init(typeIdentifier)) + NSPasteboard.general.clearContents() + NSPasteboard.general.writeObjects([item]) + #else + return false + #endif + return true + } + + #if canImport(UIKit) + static func content(before: String, selection: NSRange) -> String? { + guard let plain = UIPasteboard.general.string else { return nil } + let rich = UIPasteboard.general.data(forPasteboardType: typeIdentifier) + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + let fields: [String: Any] = ["beforeTitle": before, "selectionStart": selection.location, + "selectionLength": selection.length, "plainText": plain, "blocksEDN": rich] + guard let data = try? JSONSerialization.data(withJSONObject: fields) else { return nil } + return String(decoding: data, as: UTF8.self) + } + #endif +} diff --git a/apple/Sources/Logseq/LGInteractionDiagnostics.swift b/apple/Sources/Logseq/LGInteractionDiagnostics.swift new file mode 100644 index 00000000..793537c4 --- /dev/null +++ b/apple/Sources/Logseq/LGInteractionDiagnostics.swift @@ -0,0 +1,123 @@ +import Foundation +#if os(iOS) +import UIKit +#endif + +/// Temporary device diagnostics: identifiers and geometry only, never note text. +@MainActor enum LGInteractionDiagnostics { + static func event(_ name: String, _ detail: String = "") { + #if (DEBUG || LOGSEQ_INTERACTION_DIAGNOSTICS) && os(iOS) + InteractionMonitor.shared.record(name, detail) + #endif + } + + #if os(iOS) + static func editor(_ view: UITextView, blockID: String, event name: String) { + #if DEBUG || LOGSEQ_INTERACTION_DIAGNOSTICS + InteractionMonitor.shared.watch(view) + event(name, "block=\(blockID) view=\(ObjectIdentifier(view)) attached=\(view.window != nil) responder=\(view.isFirstResponder)") + #endif + } + #endif +} + +#if (DEBUG || LOGSEQ_INTERACTION_DIAGNOSTICS) && os(iOS) +@MainActor private final class InteractionMonitor: NSObject { + static let shared = InteractionMonitor() + private weak var editor: UITextView? + private weak var scroll: UIScrollView? + private weak var window: UIWindow? + private var displayLink: CADisplayLink? + private var lastFrame = "" + private let writer = InteractionLogWriter() + + private override init() { + super.init() + record("session", "build=\(Bundle.main.infoDictionary?["CFBundleVersion"] ?? "debug") system=\(UIDevice.current.systemVersion)") + for name in [UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardDidChangeFrameNotification, + UIResponder.keyboardWillHideNotification, UIResponder.keyboardDidHideNotification, + UIApplication.willResignActiveNotification, UIApplication.didBecomeActiveNotification] { + NotificationCenter.default.addObserver(self, selector: #selector(notification), name: name, object: nil) + } + } + + func record(_ name: String, _ detail: String) { + writer.append("\(Int64(Date().timeIntervalSince1970 * 1_000)) \(String(format: "%.4f", ProcessInfo.processInfo.systemUptime)) \(name) \(detail)\n") + } + + func watch(_ view: UITextView) { + editor = view + if let window = view.window { self.window = window } + var ancestor = view.superview + while let current = ancestor { + if let scroll = current as? UIScrollView { self.scroll = scroll; break } + ancestor = current.superview + } + if displayLink == nil { + let link = CADisplayLink(target: self, selector: #selector(frame)) + link.add(to: .main, forMode: .common) + displayLink = link + } + } + + @objc private func notification(_ note: Notification) { + let info = note.userInfo ?? [:] + let end = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero + let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] ?? 0 + record("notification", "name=\(note.name.rawValue) keyboard=\(end) duration=\(duration)") + } + + @objc private func frame() { + if let editor, editor.window != nil { watch(editor) } + var detail = "editor=none" + if let editor { + detail = "editor=\(ObjectIdentifier(editor)) attached=\(editor.window != nil) responder=\(editor.isFirstResponder) frame=\(editor.convert(editor.bounds, to: window)) bounds=\(editor.bounds) selection=\(editor.selectedRange) marked=\(editor.markedTextRange != nil)" + } + if let scroll { + detail += " scroll=\(ObjectIdentifier(scroll)) offset=\(scroll.contentOffset) size=\(scroll.contentSize) viewport=\(scroll.bounds.size) inset=\(scroll.adjustedContentInset) tracking=\(scroll.isTracking) dragging=\(scroll.isDragging) decelerating=\(scroll.isDecelerating)" + } + if let window { detail += " safeArea=\(window.safeAreaInsets)" } + guard detail != lastFrame else { return } + lastFrame = detail + record("frame", detail) + } +} + +/// File I/O stays off the main thread and is batched to avoid disturbing a repro. +private final class InteractionLogWriter: @unchecked Sendable { + private let queue = DispatchQueue(label: "com.logseq.interaction-diagnostics", qos: .utility) + private var pending = Data() + private var scheduled = false + private let url = URL.cachesDirectory.appendingPathComponent("interaction-debug.log") + + func append(_ line: String) { + queue.async { [self] in + pending.append(contentsOf: line.utf8) + guard !scheduled else { return } + scheduled = true + queue.asyncAfter(deadline: .now() + 0.25) { [self] in flush() } + } + } + + private func flush() { + defer { pending.removeAll(keepingCapacity: true); scheduled = false } + do { + let files = FileManager.default + try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + if let size = try? files.attributesOfItem(atPath: url.path)[.size] as? NSNumber, + size.intValue > 4_000_000 { + let previous = url.deletingLastPathComponent().appendingPathComponent("interaction-debug.previous.log") + try? files.removeItem(at: previous) + try files.moveItem(at: url, to: previous) + } + if !files.fileExists(atPath: url.path) { files.createFile(atPath: url.path, contents: nil) } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: pending) + } catch { + // Diagnostics must never interfere with editing or graph writes. + } + } +} +#endif diff --git a/apple/Sources/Logseq/LGNavigationExtension.swift b/apple/Sources/Logseq/LGNavigationExtension.swift index d0ca3ffd..025703f1 100644 --- a/apple/Sources/Logseq/LGNavigationExtension.swift +++ b/apple/Sources/Logseq/LGNavigationExtension.swift @@ -225,12 +225,9 @@ private struct LGNavigationStack: View { context: context, rootIndex: 0, routeStartIndex: 6, - depth: depth, synchronizationName: "navigation", toolbarStartIndex: 1, bottomChromeIndex: 5, - bottomOccupiesLayoutSpace: bottomOccupiesLayoutSpace, - composerDismissalEnabled: composerDismissalEnabled, rootTransform: nil ) .onGeometryChange(for: Int.self) { geometry in @@ -240,25 +237,6 @@ private struct LGNavigationStack: View { } } - private var depth: Int { - guard case let .int(value) = context.property("depth") else { return 0 } - return value - } - - private var bottomOccupiesLayoutSpace: Bool { - guard case let .bool(value) = context.property("bottom-occupies-layout-space") else { - return false - } - return value - } - - private var composerDismissalEnabled: Bool { - guard case let .bool(value) = context.property("composer-dismissal-enabled") else { - return false - } - return value - } - } private struct LGNavigationDestination: View { @@ -371,12 +349,9 @@ private struct LGSearchSession: View { context: context, rootIndex: 1, routeStartIndex: 3, - depth: depth, synchronizationName: "search navigation", toolbarStartIndex: nil, bottomChromeIndex: 2, - bottomOccupiesLayoutSpace: bottomOccupiesLayoutSpace, - composerDismissalEnabled: false, rootTransform: searchRootTransform ) } @@ -453,11 +428,6 @@ private struct LGSearchSession: View { } #endif - private var bottomOccupiesLayoutSpace: Bool { - guard case let .bool(value) = context.property("bottom-occupies-layout-space") else { return false } - return value - } - private var depth: Int { guard case let .int(value) = context.property("depth") else { return 0 } return value @@ -506,12 +476,9 @@ private struct LGNavigationContent: View { let context: LUIAppleExtensionViewContext let rootIndex: Int let routeStartIndex: Int - let depth: Int let synchronizationName: String let toolbarStartIndex: Int? let bottomChromeIndex: Int? - let bottomOccupiesLayoutSpace: Bool - let composerDismissalEnabled: Bool let rootTransform: ((AnyView) -> AnyView)? @State private var path: [Int] = [] #if os(iOS) @@ -522,6 +489,23 @@ private struct LGNavigationContent: View { @AppStorage("logseq.appearance") private var appearance = "system" @Environment(\.colorScheme) private var colorScheme + // This view observes the extension independently of its parent. Read the + // route depth and chrome from the same live model as the child IDs. + private var depth: Int { + guard case let .int(value) = context.property("depth") else { return 0 } + return value + } + + private var bottomOccupiesLayoutSpace: Bool { + guard case let .bool(value) = context.property("bottom-occupies-layout-space") else { return false } + return value + } + + private var composerDismissalEnabled: Bool { + guard case let .bool(value) = context.property("composer-dismissal-enabled") else { return false } + return value + } + @ViewBuilder var body: some View { let _ = context.revision @@ -627,9 +611,12 @@ private struct LGNavigationContent: View { .toolbarBackground(.visible, for: .navigationBar) #endif .onAppear { + LGInteractionDiagnostics.event("navigation.appear", "node=\(context.nodeID) path=\(path) desired=\(desiredPath)") path = desiredPath } - .onChange(of: desiredPath) { _, newPath in + .onChange(of: desiredPath) { + let newPath = desiredPath + LGInteractionDiagnostics.event("navigation.change", "node=\(context.nodeID) path=\(path) desired=\(newPath) revision=\(context.revision)") if path != newPath { withAnimation { path = newPath } } @@ -781,6 +768,7 @@ private struct LGNavigationContent: View { Binding( get: { path }, set: { newPath in + LGInteractionDiagnostics.event("navigation.binding", "node=\(context.nodeID) path=\(path) new=\(newPath) desired=\(desiredPath)") let removedCount = max(0, path.count - newPath.count) path = newPath if removedCount > 0 { diff --git a/apple/Sources/Logseq/LGOutlinerEditorExtension.swift b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift index 39736957..0353914e 100644 --- a/apple/Sources/Logseq/LGOutlinerEditorExtension.swift +++ b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift @@ -4,7 +4,7 @@ import SwiftUI @MainActor enum LGOutlinerEditorExtension { static let identifier = "outliner-editor" - static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" static func register(in registry: LUIAppleExtensionRegistry) throws { try registry.register( @@ -23,6 +23,7 @@ enum LGOutlinerEditorExtension { .init(name: "title", kind: .string, isRequired: true), .init(name: "caret-utf16-offset", kind: .int, isRequired: true), ]), + .init(name: "paste-content", fields: [.init(name: "value", kind: .string, isRequired: true)]), .init(name: "paste", fields: [ .init(name: "title", kind: .string, isRequired: true), .init(name: "caret-utf16-offset", kind: .int, isRequired: true), @@ -66,6 +67,7 @@ private struct LGOutlinerEditor: View { onPaste: { title, caret in emit(name: "paste", values: ["title": .string(title), "caret-utf16-offset": .int(caret)]) }, + onPasteContent: { payload in emit(name: "paste-content", values: ["value": .string(payload)]) }, onReturn: { title, caret in emit(name: "return", values: [ "title": .string(title), diff --git a/apple/Sources/Logseq/LGPlatformCommandRouter.swift b/apple/Sources/Logseq/LGPlatformCommandRouter.swift index ab544621..1195ba97 100644 --- a/apple/Sources/Logseq/LGPlatformCommandRouter.swift +++ b/apple/Sources/Logseq/LGPlatformCommandRouter.swift @@ -34,6 +34,8 @@ public final class LGPlatformCommandRouter: LGPlatformCommandHandling { present(.confirmDelete(blockIDs: command.uuids ?? [])) case "setClipboardText": setClipboardText(command.text ?? "") + case "setClipboardBlocks": + _ = LGBlockClipboard.write(command.text ?? "") case "setClipboardReferences": setClipboardText(Self.nodeReferences(command.uuids ?? [])) case "setClipboardURLs": diff --git a/apple/Sources/Logseq/LGRuntime.swift b/apple/Sources/Logseq/LGRuntime.swift index 2c59784b..db4a68cf 100644 --- a/apple/Sources/Logseq/LGRuntime.swift +++ b/apple/Sources/Logseq/LGRuntime.swift @@ -283,6 +283,7 @@ public final class LGRuntime { } private func receive(_ event: LGRendererEvent) { + LGInteractionDiagnostics.event("ui.event", "kind=\(event.kind) node=\(event.nodeID) extension=\(event.extensionName ?? "") first=\(event.first ?? -1) last=\(event.last ?? -1)") let operation: LGNativeOperation switch event.kind { case .appear: @@ -474,7 +475,9 @@ public final class LGRuntime { } Self.log.notice("effect start id=\(effect.id, privacy: .public) kind=\(effect.kind, privacy: .public)") + LGInteractionDiagnostics.event("effect.start", "id=\(effect.id) kind=\(effect.kind)") let resolution = await effectExecutor.execute(effect) + LGInteractionDiagnostics.event("effect.end", "id=\(effect.id) kind=\(effect.kind) success=\(resolution.succeeded)") guard epoch == patchApplyEpoch else { return } Self.log.notice("effect resolved id=\(effect.id, privacy: .public) kind=\(effect.kind, privacy: .public) succeeded=\(resolution.succeeded, privacy: .public)") if !resolution.succeeded { @@ -497,7 +500,7 @@ public final class LGRuntime { break } } - if resolution.succeeded && (effect.kind == "send-asset" || effect.kind == "send-capture" || effect.kind == "send-task") { + if resolution.succeeded && (effect.kind == "send-asset" || effect.kind == "send-capture" || effect.kind == "send-task" || effect.kind == "send-tagged") { // A send may finish while backgrounded; do not restore already submitted drafts. _ = await enqueueNative(.hostUpdate("save-ui-session", "null")).value } @@ -527,6 +530,7 @@ public final class LGRuntime { guard !patch.isEmpty else { return } let decoded = try LUIAppleBackend.decode(patch) try renderer.apply(decoded: decoded) + LGInteractionDiagnostics.event("patch.apply", "generation=\(decoded.generation) bytes=\(patch.utf8.count)") #if DEBUG Task.detached { Self.dumpPatchIfRequested(patch, generation: decoded.generation) } #endif @@ -558,7 +562,9 @@ public final class LGRuntime { return } Self.log.notice("apply patch generation=\(decoded.generation, privacy: .public)") + let started = ProcessInfo.processInfo.systemUptime try self.renderer.apply(decoded: decoded) + LGInteractionDiagnostics.event("patch.apply", "generation=\(decoded.generation) bytes=\(patch.utf8.count) ms=\(Int((ProcessInfo.processInfo.systemUptime - started) * 1_000))") } catch { guard epoch == self.patchApplyEpoch else { return } self.lastError = String(describing: error) diff --git a/apple/Sources/Logseq/OutlinerInlineEditor.swift b/apple/Sources/Logseq/OutlinerInlineEditor.swift index 2e891f20..1af47080 100644 --- a/apple/Sources/Logseq/OutlinerInlineEditor.swift +++ b/apple/Sources/Logseq/OutlinerInlineEditor.swift @@ -11,6 +11,7 @@ struct OutlinerInlineEditor: View { var selectionRevision: Int = 0 let onTextChange: (String, Int) -> Void var onPaste: ((String, Int) -> Void)? = nil + var onPasteContent: ((String) -> Void)? = nil let onReturn: (String, Int) -> Void let onBackspace: (String, Int) -> Void let onCaretChange: (Int) -> Void @@ -30,6 +31,7 @@ struct OutlinerInlineEditor: View { selectionRevision: selectionRevision, onTextChange: onTextChange, onPaste: onPaste, + onPasteContent: onPasteContent, onReturn: onReturn, onBackspace: onBackspace, onCaretChange: onCaretChange @@ -142,6 +144,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { var selectionRevision: Int = 0 let onTextChange: (String, Int) -> Void var onPaste: ((String, Int) -> Void)? = nil + var onPasteContent: ((String) -> Void)? = nil let onReturn: (String, Int) -> Void let onBackspace: (String, Int) -> Void let onCaretChange: (Int) -> Void @@ -153,6 +156,16 @@ private struct NativeOutlinerTextView: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { let textView = FocusRetainingTextView() textView.delegate = context.coordinator + let coordinator = context.coordinator + textView.onPasteContent = { [weak coordinator, weak textView] payload in + guard let coordinator, let textView, + let paste = coordinator.parent.onPasteContent else { return false } + coordinator.localText = textView.text + coordinator.pasteSelectionRevision = coordinator.parent.selectionRevision + coordinator.isAwaitingBlockHandoff = true + paste(payload) + return true + } textView.backgroundColor = .clear OutlinerNativeTextMeasurement.configureTextContainer(textView) textView.adjustsFontForContentSizeCategory = true @@ -166,6 +179,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { length: 0 ) textView.requestFocusWhenAttached() + LGInteractionDiagnostics.editor(textView, blockID: blockID, event: "editor.make") return textView } @@ -174,18 +188,22 @@ private struct NativeOutlinerTextView: UIViewRepresentable { applyWritingAssistance(to: textView) context.coordinator.parent = self let isSameBlock = context.coordinator.activeBlockID == blockID - let decision = InlineEditorTextReconciliationPolicy.decision( + let pasteAcknowledged = context.coordinator.pasteSelectionRevision.map { $0 != selectionRevision } ?? false + let decision: InlineEditorTextReconciliationDecision = pasteAcknowledged ? .applyModel : InlineEditorTextReconciliationPolicy.decision( modelText: text, localText: context.coordinator.localText, isSameBlock: isSameBlock, isAwaitingBlockHandoff: context.coordinator.isAwaitingBlockHandoff ) + LGInteractionDiagnostics.editor(textView, blockID: blockID, event: "editor.update") + LGInteractionDiagnostics.event("editor.reconcile", "same=\(isSameBlock) decision=\(decision) handoff=\(context.coordinator.isAwaitingBlockHandoff) selectionRevision=\(selectionRevision)") switch decision { case .keepLocal: break case .acknowledgeLocal: context.coordinator.localText = nil context.coordinator.isAwaitingBlockHandoff = false + context.coordinator.pasteSelectionRevision = nil case .applyModel: context.coordinator.isApplyingModel = true defer { context.coordinator.isApplyingModel = false } @@ -195,6 +213,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { context.coordinator.pendingHandoffTyping = "" context.coordinator.localText = nil context.coordinator.isAwaitingBlockHandoff = false + context.coordinator.pasteSelectionRevision = nil let selection = textView.selectedRange if wasAwaitingHandoff { let merged = InlineEditorHandoffMerge.merged( @@ -210,6 +229,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { length: 0 ) if !bufferedTyping.isEmpty { + context.coordinator.selectionRevision = selectionRevision context.coordinator.localText = merged.text let onTextChange = context.coordinator.parent.onTextChange DispatchQueue.main.async { @@ -254,6 +274,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { } static func dismantleUIView(_ textView: UITextView, coordinator: Coordinator) { + LGInteractionDiagnostics.editor(textView, blockID: coordinator.activeBlockID, event: "editor.dismantle") textView.delegate = nil } @@ -300,6 +321,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { var parent: NativeOutlinerTextView var localText: String? var isAwaitingBlockHandoff = false + var pasteSelectionRevision: Int? var pendingHandoffTyping = "" var activeBlockID: String var isApplyingModel = false @@ -321,6 +343,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { } func textViewDidChange(_ textView: UITextView) { + LGInteractionDiagnostics.editor(textView, blockID: activeBlockID, event: "editor.input") localText = textView.text guard textView.markedTextRange == nil else { return } if (textView as? FocusRetainingTextView)?.isPasting == true, let onPaste = parent.onPaste { @@ -382,6 +405,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { } if replacement == "\n", NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true } if replacement == "\n" { + LGInteractionDiagnostics.editor(textView, blockID: activeBlockID, event: "editor.return") var submittedText = textView.text ?? "" if range.length > 0 { let value = submittedText as NSString @@ -401,6 +425,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { return false } if replacement.isEmpty, range.location == 0, range.length == 0 { + LGInteractionDiagnostics.editor(textView, blockID: activeBlockID, event: "editor.backspace") isAwaitingBlockHandoff = true parent.onBackspace(textView.text, range.length) return false @@ -411,11 +436,14 @@ private struct NativeOutlinerTextView: UIViewRepresentable { } private final class FocusRetainingTextView: OutlinerLayoutTextView { + var onPasteContent: ((String) -> Bool)? private var focusPending = false private(set) var isInsertingLineBreak = false private(set) var isPasting = false override func paste(_ sender: Any?) { + if let payload = LGBlockClipboard.content(before: text ?? "", selection: selectedRange), + onPasteContent?(payload) == true { return } guard let text = UIPasteboard.general.string else { super.paste(sender) return @@ -454,9 +482,22 @@ private final class FocusRetainingTextView: OutlinerLayoutTextView { override func didMoveToWindow() { super.didMoveToWindow() + LGInteractionDiagnostics.event("editor.window", "view=\(ObjectIdentifier(self)) attached=\(window != nil) responder=\(isFirstResponder)") fulfillPendingFocus() } + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + LGInteractionDiagnostics.event("editor.becomeFirstResponder", "view=\(ObjectIdentifier(self)) result=\(result) attached=\(window != nil)") + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + LGInteractionDiagnostics.event("editor.resignFirstResponder", "view=\(ObjectIdentifier(self)) result=\(result) attached=\(window != nil)") + return result + } + private func fulfillPendingFocus() { guard focusPending, window != nil else { return } if becomeFirstResponder() { diff --git a/apple/Tests/UI/drawer-horizontal-scroll.swift b/apple/Tests/UI/drawer-horizontal-scroll.swift index cfba41ac..bd591220 100644 --- a/apple/Tests/UI/drawer-horizontal-scroll.swift +++ b/apple/Tests/UI/drawer-horizontal-scroll.swift @@ -17,6 +17,14 @@ import UIKit table.isScrollEnabled = false precondition(!LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell), "A disabled horizontal scroller does not intercept sidebar gestures") + table.isScrollEnabled = true + table.contentSize.width = table.bounds.width + table.alwaysBounceHorizontal = true + precondition(!LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell), + "A fitting Scheduled/Deadline row retains the sidebar swipe even with bounce enabled") + table.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + precondition(LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell), + "Scrollable content insets count towards horizontal overflow") print("PASS: table scroll wins without disabling sidebar gestures in ordinary content") } } diff --git a/apple/Tests/UI/editor-completion.swift b/apple/Tests/UI/editor-completion.swift index f457589c..d59e6ba9 100644 --- a/apple/Tests/UI/editor-completion.swift +++ b/apple/Tests/UI/editor-completion.swift @@ -38,6 +38,9 @@ import UIKit let effects = EditorCompletionEffects() effects.response = try snapshot(after, caret: caret, kind: nil, label: label, value: value) let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + var frames: [Int: CGRect] = [:] + runtime.renderer.backend.frameReportingEnabled = true + runtime.renderer.backend.onFramesReport = { frames = $0 } try runtime.applyCoreResponse(snapshot(before, caret: before.utf16.count, kind: kind, label: label, value: value)) try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") @@ -58,6 +61,11 @@ import UIKit if kind == "command", node("outliner.autocomplete.group.FORMAT") == nil { throw failure("Command group heading is missing") } guard node("button.outliner.autocomplete.dismiss") != nil, let candidate = node("button.outliner.autocomplete.0") else { throw failure("Missing shared panel for \(kind)") } + guard let panel = node("toolbar.outliner.autocomplete").flatMap({ frames[$0] }), + let item = frames[candidate], item.width >= panel.width - 17, + abs(item.height - 44) <= 1 else { + throw failure("Every \(kind) option must fill the panel width with a 44pt target: item=\(String(describing: frames[candidate]))") + } try backend.performPress(node: candidate) await runtime.drainEffectsForTesting() try await Task.sleep(for: .milliseconds(400)) diff --git a/apple/Tests/UI/nested-selection-toolbar.swift b/apple/Tests/UI/nested-selection-toolbar.swift new file mode 100644 index 00000000..91bacee8 --- /dev/null +++ b/apple/Tests/UI/nested-selection-toolbar.swift @@ -0,0 +1,116 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@main struct NestedSelectionToolbarCheck { + @MainActor static func main() async throws { + var routes: [String] = [] + var selected = false + var longPressed: [String] = [] + var performed: [String] = [] + func snapshot() throws -> String { + let projections: [[String: Any]] = routes.map { uuid in + ["uuid": uuid, "page": ["uuid": uuid, "title": uuid], + "outlinerRows": [["depth": 0, "block": ["uuid": uuid + "-child", "title": "Select this block", "pageId": uuid]]], + "outlinerState": ["selectedBlockIds": selected && uuid == routes.last ? [uuid + "-child"] : []]] + } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + "graphName": "Work", "selectedGraphId": "work", "nodeRoutes": projections, + "outlinerRows": [["depth": 0, "block": ["uuid": "journal-child", "title": "Journal", "pageId": "journal"]]], + "searchQuery": "Found", "searchResults": [["uuid": "Found", "title": "Found", "isPage": true]] + ]]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in + if let data = request.params.payload?.data(using: .utf8), + let effect = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + switch effect["kind"] as? String { + case "open-node", "open-search-result": + if let uuid = effect["text"] as? String { routes.append(uuid); selected = false } + case "close-node": _ = routes.popLast(); selected = false + case "long-press-outliner-block": + if let uuid = effect["text"] as? String { longPressed.append(uuid); selected = true } + case "outliner-toolbar": + if let action = effect["text"] as? String { + performed.append(action) + if action == "copy" || action == "unselect" { selected = false } + } + default: break + } + } + return try! snapshot() + })) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { window.isHidden = true; runtime.stop() } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func descendants(_ id: Int) -> [Int] { + let backend = runtime.renderer.backend + return [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + let backend = runtime.renderer.backend + return backend.rootIDs.flatMap(descendants).last { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier + } + } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message) + } + try await Task.sleep(for: .milliseconds(500)) + for destination in ["Page A", "Page B", "Found"] { + if destination == "Found" { + guard let search = find("button.search") else { throw failure("Missing search action") } + try runtime.renderer.backend.performPress(node: search) + try await wait("Native search must accept input") { + views(window).contains { ($0 as? UISearchTextField)?.isFirstResponder == true } + } + views(window).compactMap { $0 as? UISearchTextField }.first { $0.isFirstResponder }!.insertText("Found") + try await wait("Search must show its result") { find("search.result.Found") != nil } + try runtime.renderer.backend.performPress(node: find("search.result.Found")!) + } else { + try runtime.applyHostUpdate(kind: "open-node", payload: String(decoding: try JSONSerialization.data(withJSONObject: destination, options: .fragmentsAllowed), as: UTF8.self)) + } + try await Task.sleep(for: .milliseconds(650)) + let backend = runtime.renderer.backend + guard let row = backend.rootIDs.flatMap(descendants).first(where: { + guard let node = backend.extensionModel(id: $0), node.identifier == "outliner-block-row", + case let .string(payload) = node.properties["payload"] else { return false } + return payload.contains(destination + "-child") + }) else { throw failure("Missing destination row") } + func longPress() throws { + try backend.performExtensionEvent(node: row, name: "long-press", values: ["uuid": .string(destination + "-child")]) + } + try longPress() + try await wait("The native long press must traverse the core effect boundary") { longPressed.last == destination + "-child" } + try await wait("The active destination owns selection actions") { find("button.outliner.selection.copy") != nil } + try await Task.sleep(for: .milliseconds(650)) + let nativeActions = views(window).compactMap { $0 as? UIScrollView }.filter { + let rect = $0.convert($0.bounds, to: window) + return $0.window != nil && rect.height >= 44 && rect.height <= 60 + && rect.minY > window.bounds.height - 130 + } + guard !nativeActions.isEmpty else { throw failure("Long press must show the native selection bar on " + destination) } + try backend.performPress(node: find("button.outliner.selection.copy")!) + try await wait("Copy must use the shared toolbar pipeline") { performed.last == "copy" } + try await wait("Copy clears the destination's selection") { find("toolbar.outliner.selection") == nil } + try longPress() + try await wait("Selection must reopen after copying") { find("button.outliner.selection.unselect") != nil } + try backend.performPress(node: find("button.outliner.selection.unselect")!) + try await wait("Unselect must reach the active destination") { performed.last == "unselect" } + try await wait("Unselect removes native selection chrome") { find("toolbar.outliner.selection") == nil } + } + print("PASS: direct, linked/nested and search destinations display native selection actions and dispatch Copy/Unselect") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "NestedSelection", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-clipboard.swift b/apple/Tests/UI/outliner-clipboard.swift new file mode 100644 index 00000000..a8c5d506 --- /dev/null +++ b/apple/Tests/UI/outliner-clipboard.swift @@ -0,0 +1,120 @@ +import SwiftUI +import UIKit +import LogseqModel +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class ClipboardEffects: LGEffectExecuting { + var received: [[String: Any]] = [] + var typed: [String] = [] + var response = "" + var succeeds = true + func execute(_ effect: LGEffect) async -> LGEffectResolution { + if effect.kind == "change-outliner-text" { typed.append(effect.text) } + if effect.kind == "editor-outliner-event", + let data = effect.text.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + payload["type"] as? String == "pasteContent" { + received.append(payload) + try? await Task.sleep(for: .milliseconds(300)) + return .init(succeeded: succeeds, message: succeeds ? response : "Storage failed", + output: succeeds ? .coreResponse : .discard) + } + return .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerClipboardCheck { + @MainActor static func main() async throws { + let rich = "{:graph \"demo\" :op :copy :blocks [{:block/uuid #uuid \"10000000-0000-4000-8000-000000000004\" :block/title \"Alpha\"}]}" + let plain = "- Alpha\n" + var errors: [String] = [] + let payload = String(decoding: try JSONSerialization.data(withJSONObject: ["plainText": plain, "blocksEDN": rich]), as: UTF8.self) + UIPasteboard.general.string = "Unrelated prior clipboard" + let router = LGPlatformCommandRouter(setClipboardText: { UIPasteboard.general.string = $0 }, + performHaptic: { _ in }, present: { _ in }) + router.handle(LGPlatformCommandBatch(revision: 1, graphName: "demo", commands: [ + LogseqOutlinerCommand(type: "setClipboardBlocks", style: nil, uuid: nil, uuids: nil, text: payload) + ])) + if UIPasteboard.general.string != plain || + UIPasteboard.general.data(forPasteboardType: "com.logseq.blocks") != Data(rich.utf8) { + errors.append("Copy must write the Markdown and EDN to the same native clipboard item") + } + router.handle(LGPlatformCommandBatch(revision: 2, graphName: "demo", commands: [ + LogseqOutlinerCommand(type: "setClipboardText", style: nil, uuid: nil, uuids: nil, text: "New ordinary text") + ])) + if UIPasteboard.general.data(forPasteboardType: "com.logseq.blocks") != nil { + errors.append("Ordinary Copy must invalidate the previous rich block snapshot") + } + + func snapshot(uuid: String, title: String, caret: Int) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + "selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": [ + ["depth": 0, "block": ["uuid": "editing", "title": uuid == "editing" ? title : "Keep original", "pageId": "page"]], + ["depth": 0, "block": ["uuid": "inserted", "title": uuid == "inserted" ? title : "Destination", "pageId": "page"]]], + "outlinerState": ["editing": ["uuid": uuid, "title": title, "caretUTF16Offset": caret], "selectedBlockIds": []] + ]]), as: UTF8.self) + } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func type(_ text: String, into editor: UITextView) { + if editor.delegate?.textView?(editor, shouldChangeTextIn: editor.selectedRange, replacementText: text) ?? true { + editor.insertText(text) + } + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + for kind in ["blocks", "inline", "failure"] { + let effects = ClipboardEffects() + let before = kind == "inline" ? "A😀Z" : "Keep original" + effects.succeeds = kind != "failure" + effects.response = try snapshot(uuid: kind == "blocks" ? "inserted" : "editing", + title: kind == "blocks" ? "Alpha" : "AXZ", caret: kind == "blocks" ? 5 : 2) + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(snapshot(uuid: "editing", title: before, caret: kind == "inline" ? 1 : before.utf16.count)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + try await Task.sleep(for: .milliseconds(500)) + guard let editor = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.isFirstResponder }) else { + throw failure("Missing native clipboard target") + } + let selection = kind == "inline" ? NSRange(location: 1, length: 2) : NSRange(location: before.utf16.count, length: 0) + editor.selectedRange = selection + if kind == "blocks" { + UIPasteboard.general.items = [["public.utf8-plain-text": Data(plain.utf8), "com.logseq.blocks": Data(rich.utf8)]] + } else { UIPasteboard.general.string = "X" } + editor.paste(nil) + if editor.text != before { errors.append("\(kind): paste must retain the old block until the shared core answers") } + type("Y", into: editor) + // Let the single production effect drain own the acknowledgement. + try await Task.sleep(for: .milliseconds(1400)) + guard let received = effects.received.first else { + errors.append("\(kind): native Paste must transport its raw clipboard and pre-paste selection") + window.isHidden = true; runtime.stop(); continue + } + if effects.received.count != 1 || received["beforeTitle"] as? String != before || + received["selectionStart"] as? Int != selection.location || received["selectionLength"] as? Int != selection.length || + received["plainText"] as? String != (kind == "blocks" ? plain : "X") || + received["blocksEDN"] as? String != (kind == "blocks" ? rich : "") { + errors.append("\(kind): clipboard data changed while crossing the native/core bridge") + } + let expected = kind == "blocks" ? "AlphaY" : kind == "inline" ? "AXYZ" : "Keep originalY" + guard let final = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.isFirstResponder }) else { + errors.append("\(kind): Paste lost keyboard focus"); window.isHidden = true; runtime.stop(); continue + } + if final !== editor || final.text != expected { + errors.append("\(kind): rapid typing must follow the acknowledgement without losing the editor: \(final.text ?? "nil")") + } + type("Z", into: final) + try await Task.sleep(for: .milliseconds(250)) + if !effects.typed.contains(expected + "Z") { errors.append("\(kind): editing remains blocked after paste completes") } + window.isHidden = true; runtime.stop() + } + if !errors.isEmpty { throw failure(errors.joined(separator: "\n")) } + print("PASS: native block clipboard, pre-paste selection, same/different block acknowledgements, fast typing and storage failure recovery") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "OutlinerClipboard", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-editor-retention.swift b/apple/Tests/UI/outliner-editor-retention.swift new file mode 100644 index 00000000..fc885ac2 --- /dev/null +++ b/apple/Tests/UI/outliner-editor-retention.swift @@ -0,0 +1,90 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +private final class RetentionImageProtocol: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + nonisolated(unsafe) static var requests = 0 + nonisolated(unsafe) static var image = Data() + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "image-retention.logseq.test" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + Self.lock.lock() + Self.requests += 1 + let data = Self.image + Self.lock.unlock() + client?.urlProtocol(self, didReceive: URLResponse(url: request.url!, mimeType: "image/png", + expectedContentLength: data.count, textEncodingName: nil), cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + static func requestCount() -> Int { lock.lock(); defer { lock.unlock() }; return requests } +} + +@MainActor private final class RetentionEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerEditorRetentionCheck { + @MainActor static func main() async throws { + RetentionImageProtocol.image = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 48)).image { context in + UIColor.orange.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 48)) + }.pngData()! + URLProtocol.registerClass(RetentionImageProtocol.self) + defer { URLProtocol.unregisterClass(RetentionImageProtocol.self) } + func row(_ uuid: String, _ title: String, day: Int, markup: [[String: Any]]? = nil) -> [String: Any] { + var block: [String: Any] = ["uuid": uuid, "title": title, "pageId": "page-\(day)", + "journalDay": day, "journalTitle": day == 20261023 ? "Today" : "Yesterday"] + if let markup { block["markup"] = markup } + return ["depth": 0, "block": block] + } + func snapshot(_ editing: Bool) throws -> String { + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": [ + row("today", "Today block", day: 20261023), + row("target", "Nice", day: 20261022), + row("photo", "Photo", day: 20261022, markup: [["type": "image", "text": "Photo", + "url": "https://image-retention.logseq.test/photo.png"]])], + "outlinerState": editing ? ["editing": ["uuid": "target", "title": "Nice", "caretUTF16Offset": 4]] : ["selectedBlockIds": []]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: RetentionEffects()) + try runtime.applyCoreResponse(snapshot(false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer).ignoresSafeArea(.keyboard)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + for _ in 0..<60 { + if RetentionImageProtocol.requestCount() > 0 { break } + try await Task.sleep(for: .milliseconds(50)) + } + try await Task.sleep(for: .milliseconds(400)) + guard RetentionImageProtocol.requestCount() > 0 else { throw failure("The native image must load before checking retention") } + let initialLoads = RetentionImageProtocol.requestCount() + let label = views(window).first { $0.accessibilityIdentifier == "outliner.block-action.target" }! + let originalFrame = label.convert(label.bounds, to: window) + var failures: [String] = [] + for editing in [true, false, true, false] { + try runtime.applyCoreResponse(snapshot(editing)) + try await Task.sleep(for: .milliseconds(450)) + let identifier = editing ? "field.outliner.block.target" : "outliner.block-action.target" + guard let content = views(window).first(where: { $0.accessibilityIdentifier == identifier }) else { + throw failure("The target block must remain visible") + } + let frame = content.convert(content.bounds, to: window) + if abs(frame.minY - originalFrame.minY) > 0.5 { failures.append("Target moved from \(originalFrame.minY) to \(frame.minY)") } + if RetentionImageProtocol.requestCount() != initialLoads { failures.append("An unchanged image reloaded during editing") } + } + guard failures.isEmpty else { throw failure(failures.joined(separator: "; ")) } + print("PASS: entering and leaving editing across journal sections preserves block positions and loaded images") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "EditorRetention", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-hydration-stability.swift b/apple/Tests/UI/outliner-hydration-stability.swift new file mode 100644 index 00000000..0d82fbef --- /dev/null +++ b/apple/Tests/UI/outliner-hydration-stability.swift @@ -0,0 +1,69 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class HydrationStabilityEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerHydrationStabilityCheck { + @MainActor static func main() async throws { + func block(_ index: Int, shell: Bool) -> [String: Any] { + ["uuid": "row-\(index)", "title": shell || index.isMultiple(of: 2) ? "" : "Row \(index)", + "pageId": "page", "isShell": shell] + } + func snapshot(shell: Bool, patch: Bool = false) throws -> String { + var result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo"] + if patch { + result["isOutlinerContentPatch"] = true + result["hydratedBlocks"] = (0..<100).map { block($0, shell: false) } + } else { + result["outlinerRows"] = (0..<100).map { ["depth": 0, "block": block($0, shell: shell && $0 >= 32)] } + result["outlinerState"] = ["editing": ["uuid": "row-45", "title": "Row 45", "caretUTF16Offset": 0]] + } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: HydrationStabilityEffects()) + try runtime.applyCoreResponse(snapshot(shell: false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + try await Task.sleep(for: .milliseconds(700)) + let first = views(window).first { $0.accessibilityIdentifier == "outliner.block-action.row-0" }! + var ancestor = first.superview + while ancestor != nil && !(ancestor is UIScrollView) { ancestor = ancestor?.superview } + let scroll = ancestor as! UIScrollView + scroll.setContentOffset(CGPoint(x: 0, y: 1050), animated: false) + try await Task.sleep(for: .milliseconds(700)) + guard let editor = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.isFirstResponder }) else { + throw failure("The scrolled fixture must contain a native editor") + } + let originalY = editor.convert(editor.bounds, to: window).minY + var positions: [CGFloat] = [] + for _ in 0..<5 { + try runtime.applyCoreResponse(snapshot(shell: true)) + for _ in 0..<12 { + try await Task.sleep(for: .milliseconds(16)) + positions.append(editor.convert(editor.bounds, to: window).minY) + } + try runtime.applyCoreResponse(snapshot(shell: false, patch: true)) + for _ in 0..<12 { + try await Task.sleep(for: .milliseconds(16)) + positions.append(editor.convert(editor.bounds, to: window).minY) + } + } + let drift = positions.map { abs($0 - originalY) }.max() ?? 0 + guard drift <= 1 else { throw failure("Unchanged empty blocks must retain geometry across shell snapshots and hydration; editor drifts \(drift)pt") } + print("PASS: five shell/hydration cycles preserve empty blocks and editor geometry without flicker") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "HydrationStability", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-keyboard-stability.swift b/apple/Tests/UI/outliner-keyboard-stability.swift new file mode 100644 index 00000000..dd9a96a3 --- /dev/null +++ b/apple/Tests/UI/outliner-keyboard-stability.swift @@ -0,0 +1,78 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class KeyboardStabilityEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerKeyboardStabilityCheck { + @MainActor static func main() async throws { + func snapshot(_ editing: Int?) throws -> String { + func title(_ index: Int) -> String { + index == 44 ? "Row 44\nSecond line\nThird line\nFourth line\nFifth line\nSixth line" : "Row \(index)" + } + let rows: [[String: Any]] = (0..<200).map { index in + ["depth": index % 3, "hasChildren": index % 3 < 2, "isCollapsed": false, + "block": ["uuid": "row-\(index)", "title": title(index), "pageId": "page"]] + } + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "outlinerRows": rows, + "outlinerState": editing.map { ["editing": ["uuid": "row-\($0)", "title": title($0), "caretUTF16Offset": 0]] } ?? ["selectedBlockIds": []]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: KeyboardStabilityEffects()) + try runtime.applyCoreResponse(snapshot(nil)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + try await Task.sleep(for: .milliseconds(700)) + let firstRow = views(window).first { $0.accessibilityIdentifier == "outliner.block-action.row-0" }! + var ancestor = firstRow.superview + while ancestor != nil && !(ancestor is UIScrollView) { ancestor = ancestor?.superview } + let scroll = ancestor as! UIScrollView + scroll.setContentOffset(CGPoint(x: 0, y: 1100), animated: false) + try await Task.sleep(for: .milliseconds(600)) + let originalOffset = scroll.contentOffset + guard let anchor = views(window).first(where: { $0.accessibilityIdentifier == "outliner.block-action.row-40" }) else { + let ids = views(window).compactMap(\.accessibilityIdentifier).filter { $0.hasPrefix("outliner.block-action") } + throw failure("The scrolled anchor must be materialized; offset=\(scroll.contentOffset), size=\(scroll.contentSize), rows=\(ids)") + } + let originalAnchorY = anchor.convert(anchor.bounds, to: window).minY + var failures: [String] = [] + for editing: Int? in [44, nil, 43, 44, 42, nil] { + try runtime.applyCoreResponse(snapshot(editing)) + for _ in 0..<35 { + try await Task.sleep(for: .milliseconds(16)) + if abs(scroll.contentOffset.y - originalOffset.y) > 1 { + failures.append("Editing \(String(describing: editing)) shifts the viewport from \(originalOffset.y) to \(scroll.contentOffset.y)") + } + if let anchor = views(window).first(where: { $0.accessibilityIdentifier == "outliner.block-action.row-40" }), + abs(anchor.convert(anchor.bounds, to: window).minY - originalAnchorY) > 1 { + failures.append("An unchanged visible row moved during keyboard presentation") + } + for editor in views(window).compactMap({ $0 as? UITextView }) where editor.isFirstResponder { + if !editor.text.contains("\n"), editor.bounds.height > 30 { failures.append("Single-line editor expanded to \(editor.bounds.height)") } + } + for guide in views(window).compactMap({ $0 as? OutlinerIndentGuideView }) where !guide.isHidden { + if let shape = guide.layer as? CAShapeLayer, let presented = shape.presentation(), + let path = presented.path, path.boundingBox.maxY > guide.bounds.height + 1 { + failures.append("A guide draws outside its row during editor handoff") + } + } + } + } + guard failures.isEmpty else { throw failure(failures.prefix(6).joined(separator: "; ")) } + print("PASS: keyboard presentation, dismissal, and editor handoffs preserve the scrolled viewport and row heights") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "KeyboardStability", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-scrolling-editor.swift b/apple/Tests/UI/outliner-scrolling-editor.swift new file mode 100644 index 00000000..d3f0dc54 --- /dev/null +++ b/apple/Tests/UI/outliner-scrolling-editor.swift @@ -0,0 +1,65 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class ScrollingEditorEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerScrollingEditorCheck { + @MainActor static func main() async throws { + let secret = "Diagnostic privacy fixture" + let rows: [[String: Any]] = (0..<200).map { index in + ["depth": index % 3, "hasChildren": index % 3 < 2, "isCollapsed": false, + "block": ["uuid": "row-\(index)", "title": index == 8 ? secret : "Row \(index)", "pageId": "page"]] + } + func snapshot(_ editing: Bool) throws -> String { + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": rows, + "outlinerState": editing ? ["editing": ["uuid": "row-8", "title": secret, "caretUTF16Offset": 0]] : ["selectedBlockIds": []]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: ScrollingEditorEffects()) + try runtime.applyCoreResponse(snapshot(false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + try await Task.sleep(for: .milliseconds(500)) + try runtime.applyCoreResponse(snapshot(true)) + try await Task.sleep(for: .milliseconds(700)) + guard let editor = views(window).compactMap({ $0 as? UITextView }).first(where: { $0.isFirstResponder }) else { + throw failure("The fixture must start with a real focused native editor") + } + var ancestor = editor.superview + while ancestor != nil && !(ancestor is UIScrollView) { ancestor = ancestor?.superview } + let scroll = ancestor as! UIScrollView + var lostFocus = false + for y in stride(from: 0, through: 1600, by: 100) { + scroll.setContentOffset(CGPoint(x: 0, y: y), animated: false) + try await Task.sleep(for: .milliseconds(70)) + if !editor.isFirstResponder { lostFocus = true } + } + for y in stride(from: 1600, through: 0, by: -100) { + scroll.setContentOffset(CGPoint(x: 0, y: y), animated: false) + try await Task.sleep(for: .milliseconds(70)) + if !editor.isFirstResponder { lostFocus = true } + } + try await Task.sleep(for: .milliseconds(350)) + let log = try String(contentsOf: URL.cachesDirectory.appendingPathComponent("interaction-debug.log"), encoding: .utf8) + guard log.contains("editor.make"), log.contains("frame"), log.contains("UIKeyboardWillChangeFrameNotification") else { + throw failure("Device diagnostics must persist editor, geometry, and keyboard events") + } + guard !log.contains(secret) else { throw failure("Interaction diagnostics must never record note content") } + guard !lostFocus else { throw failure("Scrolling away from and back to the edited block must not discard its first responder or flicker the keyboard") } + print("PASS: scrolling preserves the native editor and diagnostics record geometry without note text") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "ScrollingEditor", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index 8693993d..aeb7cdbf 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -48,7 +48,7 @@ import Vision "error": queryError as Any? ?? NSNull()] if includeViews { let columns: [[String: Any]] = [ - ["attr": "block/title", "title": "Name", "kind": "default", "editable": true, "filterOperators": ["is", "is-not", "text-contains", "text-not-contains"]], + ["attr": "block/title", "title": "Name", "kind": "default", "editable": false, "filterOperators": ["is", "is-not", "text-contains", "text-not-contains"]], ["attr": "user.property/Score", "title": "Score", "kind": "number", "editable": true, "ref": true, "filterValues": (1...3).map { value in ["title": "\(value)", "match": [String(format: "30000000-0000-4000-8000-%012d", value)]] @@ -58,7 +58,7 @@ import Vision let rows = results.enumerated().map { index, result -> [String: Any] in var result = result result["properties"] = [ - ["attr": "block/title", "text": "Project result \(index + 1)", "editable": true, + ["attr": "block/title", "text": "Project result \(index + 1)", "editable": false, "raw": ["type": "string", "value": "Project result \(index + 1)"]], ["attr": "user.property/Score", "kind": "number", "text": index == 1 ? "" : "\(index + 1)", "editable": true, "raw": ["type": "ref-uuid", "value": String(format: "30000000-0000-4000-8000-%012d", index + 1)]], @@ -91,6 +91,10 @@ import Vision try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") let backend = runtime.renderer.backend + var nativeFrames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { nativeFrames = $0 } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } func descendants(_ id: Int) -> [Int] { [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) } @@ -190,6 +194,21 @@ import Vision _ = try await capture("query-multiple-list.png") try backend.performPress(node: find("view.tab.table")!) try await wait("Table must mount a horizontal scroll container") { find("view.table.scroll.table") != nil } + for attr in ["user.property/Score", "user.property/Ready"] { + for row in ["header", "result-0", "result-1", "result-2"] { + guard let divider = find("view.cell.separator.table.\(row).\(attr)"), + backend.model(id: divider)?.property(.orientation)?.stringValue == "vertical" else { + throw failure("Table columns must have native vertical separators") + } + try await wait("Column dividers must have visible native geometry") { + nativeFrames[divider].map { $0.width > 0 && $0.width <= 1.1 && $0.height >= 48 } ?? false + } + let header = find("view.cell.separator.table.header.\(attr)")! + if let frame = nativeFrames[divider], let heading = nativeFrames[header], abs(frame.minX - heading.minX) > 0.5 { + throw failure("Header and body column dividers must align") + } + } + } let tableText = try await capture("query-table.png") guard tableText.contains("Name") && tableText.contains("Project result 1") else { throw failure("Table cells must have visible native content: \(tableText)") @@ -286,6 +305,11 @@ import Vision try await wait("Changing the current view's type must mount its new renderer") { find("view.table.scroll.gallery") != nil && find("view.gallery.gallery") == nil } + try await Task.sleep(for: .milliseconds(250)) + guard let horizontal = views(window).compactMap({ $0 as? UIScrollView }).first(where: { + $0.window != nil && $0.contentSize.width > $0.bounds.width + 100 + }) else { throw failure("The changed table must remain horizontally scrollable") } + horizontal.setContentOffset(CGPoint(x: 100, y: 0), animated: false) let changedText = try await capture("query-type-changed-table.png") guard changedText.contains("Name") && changedText.contains("Score") else { throw failure("Changing view type must visibly replace the native gallery: \(changedText)") diff --git a/apple/Tests/UI/run-simulator-runtime-check.py b/apple/Tests/UI/run-simulator-runtime-check.py index 9d11961a..4738b05f 100644 --- a/apple/Tests/UI/run-simulator-runtime-check.py +++ b/apple/Tests/UI/run-simulator-runtime-check.py @@ -144,6 +144,8 @@ def main(): destination.mkdir(parents=True, exist_ok=True) for screenshot in (container / "Documents").glob("*.png"): shutil.copy2(screenshot, destination / screenshot.name) + for diagnostic in (container / "Library/Caches").glob("interaction-debug*.log"): + shutil.copy2(diagnostic, destination / diagnostic.name) if not benchmark: run(["xcrun", "simctl", "uninstall", SIMULATOR, bundle]) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md new file mode 100644 index 00000000..af0c7b6a --- /dev/null +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -0,0 +1,125 @@ +# Mobile Interaction Regressions Implementation Plan + +Goal: Fix the reported mobile editing, navigation, clipboard, date, table, and TestFlight failures with reproducible evidence and native regression checks. + +Architecture: Keep graph semantics in the shared OCaml core and reuse existing native SwiftUI/UIKit and Compose controls. +Read extension state from its current owner and preserve native view identity across unrelated updates. + +Tech Stack: OCaml, DataScript, LUI, SwiftUI, UIKit, Kotlin, Jetpack Compose. + +Related: Builds on `012-result-views.md`, `013-node-properties.md`, `014-editor-commands.md`, and `015-native-view-filters.md`. + +## Problem statement + +The user reports visible movement on entering block editing, unchanged images loading again, severe jitter during keyboard transitions, and flashes during navigation. +They also report unavailable long-press actions in nested navigation and search, multiple copied blocks pasting as one block, and an unavailable date-format preference. +Table cells need separators and typed editing for every property except Name, Created at, and Updated at. +The specified TestFlight crash must be investigated after these interaction problems are resolved. + +## Testing Plan + +Run core integration tests through actual RPC dispatch, graph transactions, SQLite pending operations, and confirmed sync replay. +Use `apple/Tests/UI/run-simulator-runtime-check.py` for real iOS control geometry, keyboard transitions, image request counts, navigation controllers, and clipboard behavior. +Use Android instrumentation to exercise equivalent selection, editing, clipboard, and date interactions. +Record the failing behavior before each fix, then run the same test after the fix. +Do not run `swift test`. +Serialize Dune and every mobile build wrapper that invokes Dune. + +NOTE: I will write *all* tests before I add any implementation behavior. + +## Status and order + +| Order | Work | Current evidence and next action | +| --- | --- | --- | +| 1 | Editing position and image retention | Native regression reproduced a 135.67pt shift and repeated image requests; retaining hidden placeholder slots and their height passes the same test. | +| 2 | Keyboard jitter and page flashes | Device diagnostics reproduced idle 4–8pt jumps during shell/hydration cycles. Native regression reproduces 16pt drift: previously loaded empty blocks become taller skeletons during refresh. Preserve cached renderability separately from hydration freshness; also retain an editor scrolled beyond its window. Repeated navigation validation remains open. | +| 3 | Composer tags | Native controls are shared with property selection; hashtag moved after attachment and status and installed. Nine graph-backed/shared-host checks now cover sends and recovery, including all graph-switch paths, stale catalog failures, tagged asset retries and removing restored tags. Finish native picker checks. | +| 4 | Trim on leaving editing | Shared RPC tests cover hide, switch, selection, collapse, zoom, Return, autosave, blank content, and failed storage; full core gate passes. | +| 5 | Nested/search long press | The feature branch already contains the destination-toolbar fix from `bb4575f`. Native direct/nested/search selection now traverses the effect boundary, visibly renders the bar and dispatches Copy/Unselect successfully. Complete the remaining Recent/root-path audit and physical gesture check. | +| 6 | Master-compatible clipboard | Eleven graph-backed round-trip cases pass: Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, empty/nonempty/parent placement, UTF-16 selection, paragraphs, fenced code, foreign graphs and atomic storage failure. Both iOS and Android native clipboard/selection/rapid-typing/failed-save checks pass. Audit remaining master cases before final delivery. | +| 7 | Date-format preference | Audit master settings and existing formatter support, add persistence and consistent rendering without changing journal identity. | +| 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name/timestamps are read-only; Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | +| 9 | TestFlight crash | Read the specified crash, identify the crashed build and symbolicated stack, reproduce its cause, and add a regression check before fixing it. | +| 10 | Delivery | Run final gates, update PRs, and non-destructively install and launch the final build on the user's iPhone. | + +## Editing and navigation + +1. Keep `apple/Tests/UI/outliner-editor-retention.swift` as a native integration check with multiple journal sections and an uncached image URL protocol. +2. Preserve hidden placeholder layout in `shared/src/logseq/view_rows.ml` and `shared/src/logseq/view_outliner.ml` without exposing a placeholder control while editing. +3. Exercise scrolled long pages, multiline editor handoffs, keyboard dismissal, and guide paths with `apple/Tests/UI/outliner-keyboard-stability.swift`. +4. Exercise real UIKit pushes and pops with `apple/Tests/UI/navigation-content-stability.swift`, including repeated visits to the same UUID. +5. Keep `apple/Sources/Logseq/LGNavigationExtension.swift` coherent by reading depth, child IDs, and bottom presentation state from the live extension model. +6. Verify native search navigation and editor controls with `search-result-navigation.swift`, `search-editor-toolbar.swift`, and `editor-completion.swift`. +7. Audit long-press events through `OutlinerRowExtension.swift`, `shared/src/logseq/core/rpc_session.ml`, and route-specific state projection in `shared/src/logseq/model.ml` before editing that behavior. + +## Composer and trim + +1. Exercise the existing shared picker in `shared/src/logseq/view_composer.ml` using actual native search, create, Done, Cancel, and chip removal controls. +2. Validate `shared/test/logseq/composer_tag_test.ml` against actual capture, asset upload activation, pending-log restart, and confirmed sync replay. +3. Add recovery tests before adjusting graph changes or failed capture restoration in `shared/src/logseq/model.ml`. +4. Keep `shared/test/logseq/editor_exit_test.ml` as a graph-backed regression suite for edge trimming. +5. Autosave in `shared/src/logseq/core/outliner_state.ml` must preserve draft spaces and caret state while finalization and completed split halves trim their edges. + +## Clipboard parity + +1. Inspect master in `/tmp/logseq-remove-normal-text/src/main/frontend/handler/editor.cljs` and `handler/paste.cljs`, including custom clipboard payloads and plain-text fallback. +2. Add round-trip integration cases for adjacent roots, descendants, nonadjacent selection, multiline block content, properties, references, and paste at different caret positions. +3. Follow the existing native clipboard bridge in `apple/Sources/Logseq/LogseqApp.swift` and the explicit paste origin in `OutlinerInlineEditor.swift`. +4. Implement parsing and insertion through shared core semantics so Android and iOS share block ordering and hierarchy rules. +5. Verify text copied from other apps still behaves like ordinary text, including text ending in slash, brackets, or a hashtag. + +## Dates and tables + +1. Inspect master preference persistence and `shared/src/logseq/core/dev_query.ml` formatter support before choosing the mobile representation. +2. Add behavioral tests for changing the date format, restarting the app, existing journal identity, local time, and property/table rendering. +3. Add native table checks for vertical and horizontal separators, horizontal scrolling, sidebar gesture arbitration, and editing every supported property type. +4. Update the shared table and property definitions in `shared/src/logseq/view_query.ml` and `shared/src/logseq/core/result_view.ml` without changing master filter or sort semantics. +5. Reject edits to Name and timestamps at the core boundary as well as in the UI. + +## TestFlight crash + +Investigate `https://appstoreconnect.apple.com/m/teams/067ef756-c710-4381-8cdd-6ba5c7b89823/apps/1601013908/testflight/crashes/APr-g7HZYfbevY4eiVzs--k` after the preceding work is validated. +Record the affected version, build, exception, crashed thread, symbolication status, and relevant user steps. +Use the actual stack and repository revision to choose the reproduction and fix. +If authentication or crash symbols are unavailable, report that precise missing evidence while continuing any independent repair work. + +## Testing Details + +The core gate is `rtk proxy opam exec --switch=5.5.0 -- dune build @shared/native/runtest`. +The latest complete gate passed 1024 tests in 63.858 seconds, including clipboard round trips, composer graph isolation, table edit policy, cached empty-row renderability and bounded editor retention (`/tmp/logseq-clipboard-full-core-final.log`). +Native image retention checks count actual uncached image requests and compare rendered block coordinates. +Table policy checks pass in `/tmp/logseq-table-policy-core-green.log` and native iOS geometry and interactions pass in `/tmp/logseq-table-divider-native-final.log`. +Composer graph isolation and asset recovery regressions fail before their fixes in `/tmp/logseq-composer-recovery-red.log` and all nine composer cases pass in `/tmp/logseq-composer-recovery-green.log`; three pre-existing asset capture cases also remain green. +Android initially passed 15 of 16 interaction checks, with the new vertical divider geometry case exposing an upstream missing stretch implementation. After fixing stretch and correcting an assertion for a Ready column absent from the Android fixture, all sixteen native checks pass (`/tmp/logseq-stretch-final-android-instrumentation.log`). The upstream geometry snapshot was inspected and passes `verifyPaparazziDebug` (`/tmp/logseq-lui-row-stretch-verify.log`). +Clipboard core regressions fail in `/tmp/logseq-clipboard-red.log` before implementation and pass all eleven graph-backed cases in `/tmp/logseq-clipboard-core-final.log`. Native iOS clipboard transport fails before implementation in `/tmp/logseq-clipboard-native-red-2.log`; native clipboard, exact pre-paste selection, same/different block acknowledgement, rapid typing and failure recovery pass in `/tmp/logseq-clipboard-native-input.log`. The test uses the UITextView delegate input path and the production effect drain, avoiding programmatic `insertText` bypass and parallel test-only drains. +Android clipboard transport fails before implementation in `/tmp/logseq-clipboard-android-red.log`. All eighteen native query/editor/property/outliner cases now pass in `/tmp/logseq-clipboard-android-green.log`, including actual Android clipboard creation/read, same/different block acknowledgements, rapid typing and failed storage recovery. +Direct and nested destination selection, plus a result opened through the native search field, pass `/tmp/logseq-nested-selection-final-2.log`. The check no longer injects selected snapshots manually; long-press effects return the selection projection and Copy/Unselect return the cleared projection. +Upstream LUI PR 258 now includes Android stretch at `eea10c7bf99ad1a2a40cc84b1c9657a764073d72`; both the Swift package and opam pin reference it. Local Android verification uses `/tmp/logseq-lui-override.gradle` to consume that exact upstream worktree while preserving the user's dirty `duniverse/lui` checkout. +Keyboard checks operate on the scroll view containing the measured block, rather than an arbitrary scroll view in the window. +Navigation checks assert native controller depth so a test cannot pass without a real push or pop. +Device diagnostic capture `/tmp/logseq-phone-jitter-repro.log` contains 1225 geometry/lifecycle events without note text. The keyboard dismissal at uptime 270565 follows an explicit toolbar press and is not evidence of an unexpected dismissal. Idle geometry oscillations after full snapshots and hydration are confirmed. +`outliner-hydration-stability.swift` reproduces 16pt drift before the cached-empty-row fix; `outliner-scrolling-editor.swift` independently reproduces focus loss when the active block leaves the mounted window. +The cached-empty-row native regression now passes all five refresh cycles. Persistent focused rows also pass forward/reverse scrolling without detachment or a keyboard-hide notification. Native image retention, editor geometry, and multiline keyboard handoff checks remain green. Upstream fixes are in https://github.com/logseq/lui/pull/258 (PR 257 had already merged). The diagnostic fix build was installed and launched at 01:56 on the user iPhone; the user confirmed the flicker/jitter fix at 01:57. +A diagnostic build must explicitly define `LOGSEQ_INTERACTION_DIAGNOSTICS`; Release does not define `DEBUG`. Verify `Library/Caches/interaction-debug.log` can be pulled from the phone before asking for a repro. +Clipboard tests must round-trip actual copied blocks rather than merely validating an intermediate JSON shape. +Final verification includes an iOS build, Android build/instrumentation, and a non-destructive iPhone installation. +At the user's interim installation request, the latest iOS build (build number `1792693092`) was installed and launched successfully at 02:18 on the paired iPhone, preserving the app container. It adds the tested table policy/dividers and composer graph-isolation/asset-retry fixes to the confirmed jitter fix. Installation/launch evidence is in `/tmp/logseq-table-recovery-install.json` and `/tmp/logseq-table-recovery-launch.json`. Clipboard parity, date-format settings, final native checks and the crash investigation remain open. + +## Implementation Details + +- Preserve the user's `apple/Package.resolved` origin hash and pre-existing `duniverse/lui` edits. +- Never reset or uninstall the user's graph data. +- Keep all code and comments in English. +- Keep master clipboard, filter, and sorting semantics authoritative. +- Avoid independent copies of editor, tag picker, and property editor UI. +- Retain standard native controls and consistent theme colors. +- Fix upstream library defects when evidence identifies the library as the owner. +- Update mobile PR 69 and upstream LUI PR 258 with the final scope and verification. +- Do not merge the current PR without a new explicit merge instruction. + +## Question + +No product clarification blocks the interaction fixes. +Crash investigation may require the existing App Store Connect session and matching symbols; access has not yet been checked because the user requested that investigation last. + +--- diff --git a/logseq.opam b/logseq.opam index 9331d90b..848b91d3 100644 --- a/logseq.opam +++ b/logseq.opam @@ -33,7 +33,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#daa22591cca58e733ef8b24c55e4dea5cd301417"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#eea10c7bf99ad1a2a40cc84b1c9657a764073d72"] ["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/shared/native/dune_modules/core.sexp b/shared/native/dune_modules/core.sexp index 28304037..d95cb703 100644 --- a/shared/native/dune_modules/core.sexp +++ b/shared/native/dune_modules/core.sexp @@ -38,6 +38,7 @@ "Editor_command" "Editor_command_effects" "Node_presentation" +"Block_clipboard" "Outliner_effects" "Outliner_state" "Pending_projection" diff --git a/shared/src/logseq/core/block_clipboard.ml b/shared/src/logseq/core/block_clipboard.ml new file mode 100644 index 00000000..2759e43e --- /dev/null +++ b/shared/src/logseq/core/block_clipboard.ml @@ -0,0 +1,281 @@ +module D = Datascript +module G = Graph_read +module O = Pending_ops +module S = String_kit + +type content = {target_uuid : string; before_title : string; selection_start : int; + selection_length : int; plain_text : string; blocks_edn : string} +type insertion = {intent : O.pending_intent; focus : string option; title : string; caret : int} +type node = {uuid : string; parent : string option; attrs : (string * D.value) list} +let field attr node = Option.value ~default:D.Nil (List.assoc_opt attr node.attrs) +let string = function D.String s | D.Uuid s -> s | _ -> "" +let eid db uuid = match D.entid db "block/uuid" (D.Uuid uuid) with + | Some id -> id | None -> invalid_arg "Clipboard target no longer exists" +let get db id attr = Option.value ~default:D.Nil (G.value db id attr) +let lookup uuid = D.Vector [D.Keyword "block/uuid"; D.Uuid uuid] +let lookup_uuid = function + | D.Vector [D.Keyword "block/uuid"; D.Uuid uuid] + | D.Vector [D.Keyword "block/uuid"; D.String uuid] -> Some uuid + | _ -> None +let parent_uuid db id = Option.bind (Datascript_value.optional_ref_eid db "block/parent" (G.value db id "block/parent")) + (G.uuid_for_eid db) +let many db attr = Option.fold ~none:false ~some:(fun (spec : D.schema_attr) -> spec.cardinality = D.Many) + (List.assoc_opt attr db.D.schema) +let rec canonical db = function + | D.Ref id -> (match G.uuid_for_eid db id with Some uuid -> lookup uuid + | None -> (match get db id "db/ident" with D.Keyword name -> D.Vector [D.Keyword "db/ident"; D.Keyword name] + | _ -> invalid_arg "Clipboard contains an unresolved reference")) + | D.Set xs -> D.Set (List.map (canonical db) xs) + | D.Vector xs -> D.Vector (List.map (canonical db) xs) + | D.Map fields -> D.Map (List.map (fun (k, v) -> k, canonical db v) fields) + | v -> v +let attrs db id = + let values = Hashtbl.create 16 in + D.Db.datoms db D.Eavt ~e:id () |> Seq.iter (fun (d : D.datom) -> + if not (String.starts_with ~prefix:"block.temp/" d.a) then + Hashtbl.replace values d.a (canonical db d.v :: Option.value ~default:[] (Hashtbl.find_opt values d.a))); + Hashtbl.fold (fun attr values acc -> + (attr, if many db attr then D.Set (List.rev values) else List.hd values) :: acc) values [] + |> List.sort (fun (a, _) (b, _) -> String.compare a b) +let children db id = Pending_projection.children db id |> List.sort (fun a b -> + compare (get db a "block/order", a) (get db b "block/order", b)) +let protect f = try Ok (f ()) with Invalid_argument message | Failure message -> Error message + +let copy db ~graph selected = protect (fun () -> + let selected_ids = Hashtbl.create (List.length selected) in + List.iter (fun uuid -> Hashtbl.replace selected_ids (eid db uuid) ()) selected; + let has_selected_ancestor id = + let seen = Hashtbl.create 8 in + let rec walk id = + match Datascript_value.optional_ref_eid db "block/parent" (G.value db id "block/parent") with + | None -> false + | Some parent when Hashtbl.mem seen parent -> invalid_arg "Clipboard source contains a parent cycle" + | Some parent -> Hashtbl.replace seen parent (); + Hashtbl.mem selected_ids parent || walk parent + in walk id in + let roots = List.filter_map (fun uuid -> let id = eid db uuid in if has_selected_ancestor id then None else Some id) selected in + let seen = Hashtbl.create 32 and reversed = ref [] and markdown = Buffer.create 256 in + let rec visit depth id = + if not (Hashtbl.mem seen id) then begin + Hashtbl.add seen id (); + let values = attrs db id in + reversed := D.Map ((D.Keyword "db/id", D.Int64 (Int64.of_int id)) :: + List.map (fun (attr, v) -> D.Keyword attr, v) values) :: !reversed; + let raw_title = string (get db id "block/title") in + let label uuid = Option.bind (D.entid db "block/uuid" (D.Uuid uuid)) (fun id -> + match get db id "block/title" with D.String title -> Some (G.display_title (fun s -> Ok s) db title) | _ -> None) in + let display = Ref_text.to_text label label raw_title in + let lines = String.split_on_char '\n' display in + List.iteri (fun i line -> + Buffer.add_string markdown (String.make depth '\t'); + Buffer.add_string markdown (if i = 0 then "- " else " "); + Buffer.add_string markdown line; Buffer.add_char markdown '\n') lines; + List.iter (visit (depth + 1)) (children db id) + end in + List.iter (visit 0) roots; + if !reversed = [] then invalid_arg "Select blocks before copying"; + let rich = Dev_query.Edn_util.pr_str (D.Map [D.Keyword "graph", D.String graph; + D.Keyword "op", D.Keyword "copy"; D.Keyword "blocks", D.Vector (List.rev !reversed)]) in + Yojson.Basic.to_string (`Assoc ["plainText", `String (Buffer.contents markdown); "blocksEDN", `String rich])) + +let decode ~graph source = + let map_field attr fields = Option.value ~default:D.Nil (List.assoc_opt (D.Keyword attr) fields) in + match (try Some (Dev_query.Edn_util.read_string source) with _ -> None) with + | Some (D.Map fields) when map_field "graph" fields = D.String graph -> + (match map_field "blocks" fields with + | D.Vector blocks | D.List blocks when blocks <> [] -> + let parsed = List.map (function + | D.Map fields -> + let uuid = string (map_field "block/uuid" fields) in + if not (Ref_text.is_uuid uuid) then invalid_arg "Clipboard block UUID is invalid"; + let attrs = List.filter_map (function D.Keyword attr, value -> Some (attr, value) | _ -> None) fields in + {uuid; parent = lookup_uuid (map_field "block/parent" fields); attrs} + | _ -> invalid_arg "Clipboard block is invalid") blocks in + Some parsed + | _ -> None) + | _ -> None + +let normalize_lines source = Str.global_replace (Str.regexp_string "\r\n") "\n" source +let markdown_pattern = Str.regexp "^[ \t]*\\([-+*] +\\|#+ +\\|```\\|\\$\\$[ \t]*$\\)" +let has_markdown source = List.exists (fun line -> Str.string_match markdown_pattern line 0) (String.split_on_char '\n' source) +let add_attr attr value attrs = (attr, value) :: List.remove_assoc attr attrs + +(* mldoc identifies fenced code/math before outline prefixes are examined, so + a dash, heading or blank line inside a literal block never becomes a node. *) +let parse_text ~fresh_uuid source = + let source = normalize_lines source in + let config = {Ref_text.config with Mldoc.Conf.parse_outline_only = false} in + let literals = Hashtbl.create 8 in + List.iter (fun (block, (pos : Mldoc.Pos.pos_meta)) -> match block with + | Mldoc.Type.Src code -> Hashtbl.replace literals pos.start_pos + (pos.end_pos, String.concat "" code.lines, ["logseq.property.node/display-type", D.Keyword "code"] + @ (match code.language with None -> [] | Some language -> ["logseq.property.code/lang", D.String language])) + | Mldoc.Type.Displayed_Math body -> Hashtbl.replace literals pos.start_pos + (pos.end_pos, body, ["logseq.property.node/display-type", D.Keyword "math"]) + | _ -> ()) (Mldoc.Parser.parse config source); + let lines = String.split_on_char '\n' source |> Array.of_list in + let starts = Array.make (Array.length lines) 0 in + for i = 1 to Array.length lines - 1 do starts.(i) <- starts.(i - 1) + String.length lines.(i - 1) + 1 done; + let nodes = ref [] and stack = ref [] and current = ref None and blank = ref false in + let new_node indent title extra = + let rec ancestors = function (level, _) :: rest when level >= indent -> ancestors rest | rest -> rest in + stack := ancestors !stack; + let uuid = fresh_uuid () in + let parent = Option.map snd (List.nth_opt !stack 0) in + let buffer = Buffer.create (String.length title + 32) in Buffer.add_string buffer title; + nodes := (uuid, parent, buffer, extra) :: !nodes; + stack := (indent, uuid) :: !stack; current := Some (indent, buffer); + blank := false in + let indent line = + let rec count index width = if index >= String.length line then index, width else + match line.[index] with ' ' -> count (index + 1) (width + 1) | '\t' -> count (index + 1) (width + 2) | _ -> index, width in + count 0 0 in + let heading = Str.regexp "^\\([-+*] +\\|\\(#+\\) +\\)\\(.*\\)$" in + let i = ref 0 in + while !i < Array.length lines do + let line = lines.(!i) in + match Hashtbl.find_opt literals starts.(!i) with + | Some (finish, body, extra) -> + let _, level = indent line in new_node level (String.trim body) extra; + incr i; while !i < Array.length lines && starts.(!i) < finish do incr i done + | None -> + let spaces, level = indent line in + let trimmed = String.trim line in + if trimmed = "" then blank := true + else if Str.string_match heading (String.sub line spaces (String.length line - spaces)) 0 then begin + let body = Str.matched_group 3 (String.sub line spaces (String.length line - spaces)) in + let size = try Some (String.length (Str.matched_group 2 (String.sub line spaces (String.length line - spaces)))) with Not_found -> None in + let extra = Option.fold ~none:[] ~some:(fun size -> ["logseq.property/heading", D.Int64 (Int64.of_int size)]) size in + new_node (Option.fold ~none:level ~some:(fun size -> size - 1) size) body extra + end else (match !current with + | None -> new_node level trimmed [] + | Some (_, buffer) -> + Buffer.add_string buffer (if !blank then "\n\n" else "\n"); + Buffer.add_string buffer trimmed; blank := false); + incr i + done; + List.rev_map (fun (uuid, parent, buffer, extra) -> {uuid; parent; + attrs = ("block/title", D.String (Buffer.contents buffer)) :: extra}) !nodes + +let paragraphs ~fresh_uuid source = Str.split (Str.regexp "\n\n+") source + |> List.map (fun title -> {uuid = fresh_uuid (); parent = None; attrs = ["block/title", D.String (String.trim title)]}) + +let inline content = + let source = content.before_title in + let first = S.byte_index_of_utf16 source content.selection_start in + let last = S.byte_index_of_utf16 source (content.selection_start + content.selection_length) in + let before = String.sub source 0 first and after = String.sub source last (String.length source - last) in + let selected = String.sub source first (last - first) in + let plain = normalize_lines content.plain_text in + let url value = Str.string_match (Str.regexp "^\\(https?\\|file\\|ftp\\)://[^ \t\r\n]+$") value 0 in + let within_link = String.ends_with ~suffix:"](" before && String.starts_with ~prefix:")" after in + let inserted = + if before <> "" && after <> "" && String.ends_with ~suffix:"((" before && String.starts_with ~prefix:"))" after + && String.starts_with ~prefix:"((" plain && String.ends_with ~suffix:"))" plain + then String.sub plain 2 (String.length plain - 4) + else if selected <> "" && (url plain || url selected) && not (url plain && url selected) && not within_link then + if url selected then "[" ^ plain ^ "](" ^ selected ^ ")" else "[" ^ selected ^ "](" ^ plain ^ ")" + else plain in + let title = before ^ inserted ^ after in + title, S.utf16_length (before ^ inserted) + +let remap_value mapping value = + let remap value = match lookup_uuid value with + | Some uuid -> Option.fold ~none:value ~some:lookup (Hashtbl.find_opt mapping uuid) + | None -> value in + match value with + | D.Set xs -> D.Set (List.map remap xs) + | D.Vector _ -> (match lookup_uuid value with Some _ -> remap value | None -> value) + | v -> v + +let rec semantic db = function + | D.Vector [D.Keyword "block/uuid"; D.Uuid uuid] -> O.Ref_uuid uuid + | D.Vector [D.Keyword "db/ident"; D.Keyword ident] -> O.Ref_ident ident + | D.Ref id -> (match G.uuid_for_eid db id with Some uuid -> O.Ref_uuid uuid + | None -> (match get db id "db/ident" with D.Keyword ident -> O.Ref_ident ident | _ -> invalid_arg "Missing clipboard reference")) + | D.Set xs -> O.Set_value (List.map (semantic db) xs) + | D.Vector xs -> O.Vector_value (List.map (semantic db) xs) + | v -> (match O.semantic_value_from_datascript v with Ok v -> v | Error message -> invalid_arg message) +let raw db uuid attr = + let values = D.Db.datoms db D.Eavt ~e:(eid db uuid) ~a:attr () |> Seq.map (fun (d : D.datom) -> d.v) |> List.of_seq in + match values with [] -> None | [v] when not (many db attr) -> Some (semantic db v) | xs -> Some (O.Set_value (List.map (semantic db) xs)) + +let insert db ~now ~fresh_uuid content nodes = + let by_id = Hashtbl.create (List.length nodes) in + List.iter (fun node -> if Hashtbl.mem by_id node.uuid then invalid_arg "Clipboard has duplicate block identities"; + Hashtbl.add by_id node.uuid node) nodes; + let seen = Hashtbl.create (List.length nodes) and visiting = Hashtbl.create 8 in + let rec validate node = + if Hashtbl.mem visiting node.uuid then invalid_arg "Clipboard contains a parent cycle"; + if not (Hashtbl.mem seen node.uuid) then begin + Hashtbl.add visiting node.uuid (); + Option.iter (fun parent -> Option.iter validate (Hashtbl.find_opt by_id parent)) node.parent; + Hashtbl.remove visiting node.uuid; Hashtbl.add seen node.uuid () + end in + List.iter validate nodes; + let target_id = eid db content.target_uuid in + let page = Option.bind (Datascript_value.optional_ref_eid db "block/page" (G.value db target_id "block/page")) (G.uuid_for_eid db) + |> Option.value ~default:content.target_uuid in + let existing_children = children db target_id in + let empty = String.trim content.before_title = "" in + let nested = List.exists (fun node -> Option.fold ~none:false ~some:(Hashtbl.mem by_id) node.parent) nodes in + let replace = empty && (existing_children = [] || List.length nodes = 1) in + let target_parent = Option.value ~default:page (parent_uuid db target_id) in + let siblings = children db (eid db target_parent) in + let parent, lower, upper = + if replace then target_parent, Some (string (get db target_id "block/order")), + (let rec next = function [] -> None | id :: rest when id = target_id -> Option.map (fun id -> string (get db id "block/order")) (List.nth_opt rest 0) | _ :: rest -> next rest in next siblings) + else if empty && existing_children <> [] && nested then + let rec previous lower = function [] -> lower | id :: _ when id = target_id -> lower | id :: rest -> previous (Some (string (get db id "block/order"))) rest in + target_parent, previous None siblings, Some (string (get db target_id "block/order")) + else if existing_children <> [] then content.target_uuid, None, Some (string (get db (List.hd existing_children) "block/order")) + else + let rec next = function [] -> None | id :: rest when id = target_id -> Option.map (fun id -> string (get db id "block/order")) (List.nth_opt rest 0) | _ :: rest -> next rest in + target_parent, Some (string (get db target_id "block/order")), next siblings in + let mapping = Hashtbl.create (List.length nodes) in + List.iteri (fun index node -> Hashtbl.add mapping node.uuid + (if index = 0 && replace then content.target_uuid else fresh_uuid ())) nodes; + let orders = Hashtbl.create 8 in + let operations = ref [] in + let stage intent = operations := intent :: !operations in + if not replace && content.before_title <> string (get db target_id "block/title") then + stage (O.Save_title {uuid = content.target_uuid; expected_title = string (get db target_id "block/title"); title = content.before_title}); + List.iteri (fun index node -> + let uuid = Hashtbl.find mapping node.uuid in + let node_parent, root = match Option.bind node.parent (Hashtbl.find_opt mapping) with Some id -> id, false | None -> parent, true in + let order = if replace && index = 0 then string (get db target_id "block/order") else + let previous = Option.value ~default:(if root then lower else None) (Hashtbl.find_opt orders node_parent) in + match Fractional_order.between previous (if root then upper else None) with Ok v -> v | Error message -> invalid_arg message in + Hashtbl.replace orders node_parent (Some order); + let title = string (field "block/title" node) in + if replace && index = 0 then stage (O.Save_title {uuid; expected_title = string (get db target_id "block/title"); title}) + else stage (O.Insert_block {uuid; title; page_uuid = page; parent_uuid = node_parent; order; created_at = now})) nodes; + let inserted = List.rev !operations in + let projected = match Pending_projection.compile db (O.Atomic inserted) with + | Ok tx -> D.db_with tx db | Error message -> invalid_arg message in + let structural = ["db/id"; "db/ident"; "block/uuid"; "block/title"; "block/page"; "block/parent"; "block/order"; "block/level"; "block/name"] in + List.iter (fun node -> + let uuid = Hashtbl.find mapping node.uuid in + let changes = List.filter_map (fun (attr, v) -> + if List.mem attr structural || String.starts_with ~prefix:"block.temp/" attr then None + else let expected = raw projected uuid attr and value = Some (semantic projected (remap_value mapping v)) in + if expected = value then None else Some {O.attr; expected; value}) node.attrs in + if changes <> [] then stage (O.Set_properties {uuid; changes})) nodes; + let last = List.hd (List.rev nodes) in + let uuid = Hashtbl.find mapping last.uuid in + {intent = O.Atomic (List.rev !operations); focus = Some uuid; + title = string (field "block/title" last); caret = S.utf16_length (string (field "block/title" last))} + +let paste db ~graph ~now ~fresh_uuid content = protect (fun () -> + let id = eid db content.target_uuid in + let literal = List.mem (get db id "logseq.property.node/display-type") [D.Keyword "code"; D.Keyword "math"] in + let rich = if literal then None else decode ~graph content.blocks_edn in + let source = normalize_lines content.plain_text in + let nodes = match rich with Some nodes -> Some nodes | None when literal -> None + | None when has_markdown source -> Some (parse_text ~fresh_uuid source) + | None when S.index_of ~sub:"\n\n" ~start:0 source <> None -> Some (paragraphs ~fresh_uuid source) + | None -> None in + match nodes with + | Some (_ :: _ as nodes) -> insert db ~now ~fresh_uuid content nodes + | _ -> let title, caret = inline content in + {intent = O.Save_title {uuid = content.target_uuid; expected_title = string (get db id "block/title"); title}; focus = None; title; caret}) diff --git a/shared/src/logseq/core/block_clipboard.mli b/shared/src/logseq/core/block_clipboard.mli new file mode 100644 index 00000000..3af04321 --- /dev/null +++ b/shared/src/logseq/core/block_clipboard.mli @@ -0,0 +1,20 @@ +type content = { + target_uuid : string; + before_title : string; + selection_start : int; + selection_length : int; + plain_text : string; + blocks_edn : string; +} + +type insertion = { + intent : Pending_ops.pending_intent; + focus : string option; + title : string; + caret : int; +} + +val copy : Datascript.db -> graph:string -> string list -> (string, string) result +val inline : content -> string * int +val paste : Datascript.db -> graph:string -> now:int -> fresh_uuid:(unit -> string) -> + content -> (insertion, string) result diff --git a/shared/src/logseq/core/core_effect.ml b/shared/src/logseq/core/core_effect.ml index 28d598b9..d436c5ca 100644 --- a/shared/src/logseq/core/core_effect.ml +++ b/shared/src/logseq/core/core_effect.ml @@ -28,6 +28,10 @@ let request ~now ~fresh_id payload = let identity () = ["uuid", `String (fresh_id ()); "now", `Int (now ())] in let operation () = ["operationId", `String (fresh_id ()); "now", `Int (now ())] in match kind with + | "load-composer-tags" -> empty "composerTags" + | "send-tagged" -> + let* value = text () in let* value = object_string value in + (match value with `Assoc fields -> json "sendTagged" (identity () @ fields) | _ -> assert false) | "send-capture" -> let* value = text () in json "send" (("text", `String value) :: identity ()) | "send-task" -> let* value = text () in let* status = metadata () in diff --git a/shared/src/logseq/core/editor_command_effects.ml b/shared/src/logseq/core/editor_command_effects.ml index d67bc8e5..04b25484 100644 --- a/shared/src/logseq/core/editor_command_effects.ml +++ b/shared/src/logseq/core/editor_command_effects.ml @@ -120,7 +120,7 @@ let plan db ~now ~fresh_uuid operation = let day = Cache_model.journal_day_for_ms ms in if Read.journal_page_uuid db day <> None then [] else [Ops.Create_journal {page_uuid = Printf.sprintf "00000001-%04d-%04d-0000-000000000000" (day / 10000) (day mod 10000); - block_uuid = fresh_uuid (); title = Journal.day_title day; journal_day = day; created_at = now}] + block_uuid = fresh_uuid (); block_title = ""; title = Journal.day_title day; journal_day = day; created_at = now}] | Insert_code (uuid, language) -> let target = eid db uuid in let page = match Read.ref_eids db target "block/page" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> invalid_arg "Missing page" in diff --git a/shared/src/logseq/core/graph_runtime.ml b/shared/src/logseq/core/graph_runtime.ml index 1bbba0dc..0d491687 100644 --- a/shared/src/logseq/core/graph_runtime.ml +++ b/shared/src/logseq/core/graph_runtime.ml @@ -547,7 +547,7 @@ let ensure_today_journal runtime = Ops.page_uuid = Printf.sprintf "00000001-%04d-%04d-0000-000000000000" (day / 10000) (day mod 10000); - block_uuid = fresh_uuid (); + block_uuid = fresh_uuid (); block_title = ""; title = Journal.day_title day; journal_day = day; created_at; diff --git a/shared/src/logseq/core/host_update.ml b/shared/src/logseq/core/host_update.ml index f2d1853c..96ffe817 100644 --- a/shared/src/logseq/core/host_update.ml +++ b/shared/src/logseq/core/host_update.ml @@ -11,6 +11,7 @@ type host_ui_session = { graph_id : string option ; destination : string ; assets : host_composer_asset list + ; composer_tags : (string * string) list ; composer_expanded : bool ; search_open : bool ; query : string @@ -103,6 +104,11 @@ let ui_session value = List.map (fun asset -> composer_asset asset (string_field "payload" asset)) (Util.to_list (Util.member "assets" value)); + composer_tags = (match Util.member "composerTags" value with + | `List tags -> List.filter_map (fun tag -> + let uuid = string_field "uuid" tag and title = string_field "title" tag in + if uuid = "" || title = "" then None else Some (uuid, title)) tags + | _ -> []); composer_expanded = bool_field "composerExpanded" value; search_open = bool_field "searchOpen" value; query = string_field "query" value; diff --git a/shared/src/logseq/core/host_update.mli b/shared/src/logseq/core/host_update.mli index 701a4381..6e918996 100644 --- a/shared/src/logseq/core/host_update.mli +++ b/shared/src/logseq/core/host_update.mli @@ -11,6 +11,7 @@ type host_ui_session = { graph_id : string option ; destination : string ; assets : host_composer_asset list + ; composer_tags : (string * string) list ; composer_expanded : bool ; search_open : bool ; query : string diff --git a/shared/src/logseq/core/outliner_effects.ml b/shared/src/logseq/core/outliner_effects.ml index 8c2fb270..e3070170 100644 --- a/shared/src/logseq/core/outliner_effects.ml +++ b/shared/src/logseq/core/outliner_effects.ml @@ -7,6 +7,7 @@ type outliner_platform_command = | Platform_haptic of State.outliner_haptic | Focus_block of string | Confirm_delete of string list + | Set_clipboard_blocks of string | Set_clipboard_text of string | Set_clipboard_references of string list | Set_clipboard_urls of string list @@ -113,11 +114,11 @@ let command base_t now fresh_uuid context cmd = let intent = match value.split_placement with | State.After -> Ops.Split_block { uuid = value.split_uuid; expected_title = value.split_expected_title; - before = value.before; after = value.after; new_uuid; new_order; created_at = now ()} + before = String_kit.trim value.before; after = value.after; new_uuid; new_order; created_at = now ()} | State.Inside | State.Before -> let title, inserted = if value.split_placement = State.Before then value.after, value.before else value.before, value.after in - Ops.Atomic [Ops.Save_title {uuid = block.uuid; expected_title = value.split_expected_title; title}; + Ops.Atomic [Ops.Save_title {uuid = block.uuid; expected_title = value.split_expected_title; title = String_kit.trim title}; Ops.Insert_block {uuid = new_uuid; title = inserted; page_uuid = block.page_id; parent_uuid; order = new_order; created_at = now ()}] in @@ -294,6 +295,18 @@ let command base_t now fresh_uuid context cmd = Ok (result [] [ Platform_pick_attachment uuid ]) | State.Take_photo uuid -> Ok (result [] [ Platform_take_photo uuid ]) | State.Record_audio uuid -> Ok (result [] [ Platform_record_audio uuid ]) + | State.Copy_blocks uuids -> + (match context.State.db, context.graph_id with + | Some db, Some graph -> let* payload = Block_clipboard.copy db ~graph uuids in + Ok (result [] [Set_clipboard_blocks payload]) + | _ -> Error "Select a graph before copying blocks") + | State.Paste_blocks content -> + (match context.State.db, context.graph_id with + | Some db, Some graph -> + let* pasted = Block_clipboard.paste db ~graph ~now:(now ()) ~fresh_uuid content in + Ok (result [operation base_t fresh_uuid pasted.intent] + (Option.fold ~none:[] ~some:(fun uuid -> [Focus_block uuid]) pasted.focus)) + | _ -> Error "Select a graph before pasting blocks") | State.Copy_text text -> Ok (result [] [ Set_clipboard_text text ]) | State.Copy_references uuids -> Ok (result [] [ Set_clipboard_references uuids ]) diff --git a/shared/src/logseq/core/outliner_effects.mli b/shared/src/logseq/core/outliner_effects.mli index aeca6400..12f0ab72 100644 --- a/shared/src/logseq/core/outliner_effects.mli +++ b/shared/src/logseq/core/outliner_effects.mli @@ -4,6 +4,7 @@ type outliner_platform_command = | Platform_haptic of Outliner_state.outliner_haptic | Focus_block of string | Confirm_delete of string list + | Set_clipboard_blocks of string | Set_clipboard_text of string | Set_clipboard_references of string list | Set_clipboard_urls of string list diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index e8bc1216..c2d95c7b 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -88,6 +88,7 @@ type outliner_priority = type outliner_message = | Tap_block of string | Long_press_block of string + | Paste_content of Block_clipboard.content | Text_pasted of outliner_text | Text_changed of outliner_text | Caret_moved of int @@ -158,6 +159,8 @@ type outliner_command = | Take_photo of string | Record_audio of string | Insert_root_block of outliner_root + | Copy_blocks of string list + | Paste_blocks of Block_clipboard.content | Copy_text of string | Copy_references of string list | Copy_urls of string list @@ -284,36 +287,10 @@ let display_block_title (context : outliner_context) (block : Model.block) = (summary_title dup (block.references @ block.tags)) block.title -let utf8_sequence_length byte = - if byte land 128 = 0 then 1 - else if byte land 224 = 192 then 2 - else if byte land 240 = 224 then 3 - else if byte land 248 = 240 then 4 - else 1 - -let utf16_units byte = if utf8_sequence_length byte = 4 then 2 else 1 - -let byte_index_of_utf16 value offset = - let rec loop index units = - if index >= String.length value || units >= max 0 offset then index - else - let byte = Char.code value.[index] in - loop - (min (String.length value) (index + utf8_sequence_length byte)) - (units + utf16_units byte) - in - loop 0 0 - -let utf16_length value = - let rec loop index units = - if index >= String.length value then units - else - let byte = Char.code value.[index] in - loop - (min (String.length value) (index + utf8_sequence_length byte)) - (units + utf16_units byte) - in - loop 0 0 +let utf8_sequence_length = String_kit.utf8_sequence_length +let utf16_units = String_kit.utf16_units +let byte_index_of_utf16 = String_kit.byte_index_of_utf16 +let utf16_length = String_kit.utf16_length let prefix_at value caret = String.sub value 0 (byte_index_of_utf16 value caret) @@ -627,7 +604,7 @@ let remove_tag_token (editing : editor_draft) = } | None -> None -let commit_effect (context : outliner_context) editing = +let commit_effect ?(trim = false) (context : outliner_context) editing = match editing with | Some (editing : editor_draft) -> let expected = @@ -637,14 +614,15 @@ let commit_effect (context : outliner_context) editing = { block with title = editing.expected_title } | None -> editing.expected_title in - if expected = editing.title then [] + let title = if trim then String_kit.trim editing.title else editing.title in + if expected = title then [] else [ Commit_title { uuid = editing.uuid; expected_title = editing.expected_title; - title = editing.title; + title; }; ] | None -> [] @@ -1007,7 +985,7 @@ let step (state : outliner_state) commands = (state, commands) let leave_interaction context (state : outliner_state) = step { state with editing = None; selected = Sset.empty; autocomplete = None } - (commit_effect context state.editing) + (commit_effect ~trim:true context state.editing) let split_editing context (state : outliner_state) (editing : editor_draft) = let index = byte_index_of_utf16 editing.title editing.caret in @@ -1251,7 +1229,7 @@ let apply_editor_action context state editing action = | Code language when String.trim editing.title <> "" || Option.bind (find_block context editing.uuid) (fun block -> block.Model.display_type) <> None -> step {state with editing = None; autocomplete = None} - (commit_effect context (Some editing) @ [Execute_editor (Insert_code (editing.uuid, language))]) + (commit_effect ~trim:true context (Some editing) @ [Execute_editor (Insert_code (editing.uuid, language))]) | Properties _ | Choice _ | Code _ | Number _ -> finish editing (commit_effect context (Some editing) @ [Execute_editor (Apply (editing.uuid, action))]) @@ -1402,7 +1380,8 @@ let update context (state : outliner_state) message = with selected = Sset.empty; } - (commit_effect context state.editing) + (commit_effect ~trim:(Option.fold ~none:false + ~some:(fun (editing : editor_draft) -> editing.uuid <> uuid) state.editing) context state.editing) | None -> step state []) | Long_press_block uuid -> step @@ -1412,7 +1391,14 @@ let update context (state : outliner_state) message = selected = Sset.singleton uuid; autocomplete = None; } - (commit_effect context state.editing @ [ Haptic Selection ]) + (commit_effect ~trim:true context state.editing @ [ Haptic Selection ]) + | Paste_content content -> + (match state.editing with + | Some editing when editing.uuid = content.Block_clipboard.target_uuid -> + let title, caret = Block_clipboard.inline content in + step {state with editing = Some {editing with title; caret}; + autocomplete = None; completion_dismissed = true} [Paste_blocks content] + | _ -> step state [Editor_error "Paste target is no longer being edited"]) | Text_pasted value | Text_changed value -> (match state.editing with | Some editing -> @@ -1508,12 +1494,12 @@ let update context (state : outliner_state) message = | Toolbar Hide_keyboard -> step { state with editing = None; autocomplete = None } - (commit_effect context state.editing @ [ Haptic Impact ]) + (commit_effect ~trim:true context state.editing @ [ Haptic Impact ]) | Save_editing -> step state (commit_effect context state.editing) | Cancel_editing -> step { state with editing = None; autocomplete = None } - (commit_effect context state.editing) + (commit_effect ~trim:true context state.editing) | Toolbar Tag_action -> toolbar_insert state "#" 0 | Toolbar Page_reference -> toolbar_insert state "[[]]" 2 | Toolbar Camera -> @@ -1532,16 +1518,9 @@ let update context (state : outliner_state) message = step state [ Pick_attachment editing.uuid; Haptic Impact ] | None -> step state []) | Toolbar Copy -> - let text = - String.concat "\n" - (List.filter_map - (fun (block : Model.block) -> - if Sset.mem block.uuid state.selected then Some block.title - else None) - context.blocks) - in - step { state with selected = Sset.empty } - [ Copy_text text; Haptic Impact ] + let uuids = List.filter_map (fun (block : Model.block) -> + if Sset.mem block.uuid state.selected then Some block.uuid else None) context.blocks in + step {state with selected = Sset.empty} [Copy_blocks uuids; Haptic Impact] | Toolbar Copy_reference -> step { state with selected = Sset.empty } @@ -1569,7 +1548,7 @@ let update context (state : outliner_state) message = editing = None; autocomplete = None; } - (commit_effect context state.editing @ [ Haptic Impact ]) + (commit_effect ~trim:true context state.editing @ [ Haptic Impact ]) | Zoom_in uuid -> if Option.is_some (find_block context uuid) then let state, effects = leave_interaction context state in diff --git a/shared/src/logseq/core/outliner_state.mli b/shared/src/logseq/core/outliner_state.mli index 5411a3c6..75bbd764 100644 --- a/shared/src/logseq/core/outliner_state.mli +++ b/shared/src/logseq/core/outliner_state.mli @@ -85,6 +85,7 @@ type outliner_priority = type outliner_message = | Tap_block of string | Long_press_block of string + | Paste_content of Block_clipboard.content | Text_pasted of outliner_text | Text_changed of outliner_text | Caret_moved of int @@ -155,6 +156,8 @@ type outliner_command = | Take_photo of string | Record_audio of string | Insert_root_block of outliner_root + | Copy_blocks of string list + | Paste_blocks of Block_clipboard.content | Copy_text of string | Copy_references of string list | Copy_urls of string list @@ -228,7 +231,7 @@ val complete : val trim_right : string -> string val remove_tag_token : editor_draft -> editor_draft option val commit_effect : - outliner_context -> editor_draft option -> outliner_command list + ?trim:bool -> outliner_context -> editor_draft option -> outliner_command list val insert_at_caret : editor_draft -> string -> int -> editor_draft val compare_blocks : Cache_model.block -> Cache_model.block -> int val compare_root_blocks : Cache_model.block -> Cache_model.block -> int diff --git a/shared/src/logseq/core/pending_ops.ml b/shared/src/logseq/core/pending_ops.ml index a2029c35..9c83ac04 100644 --- a/shared/src/logseq/core/pending_ops.ml +++ b/shared/src/logseq/core/pending_ops.ml @@ -145,6 +145,7 @@ type pending_create = type pending_journal = { page_uuid : string ; block_uuid : string + ; block_title : string ; title : string ; journal_day : int ; created_at : int @@ -686,7 +687,7 @@ let rec intent_json intent = ("title", `String value.title); ("journalDay", `Int value.journal_day); ("createdAt", `Int value.created_at); - ] ) + ] @ (if value.block_title = "" then [] else ["blockTitle", `String value.block_title]) ) | Add_tag value -> ( "add-tag" , [ @@ -828,6 +829,7 @@ let rec intent_of_json input = { page_uuid = string_field fields "pageUuid"; block_uuid = string_field fields "blockUuid"; + block_title = (match List.assoc_opt "blockTitle" fields with Some (`String title) -> title | _ -> ""); title = string_field fields "title"; journal_day = int_field fields "journalDay"; created_at = int_field fields "createdAt"; diff --git a/shared/src/logseq/core/pending_ops.mli b/shared/src/logseq/core/pending_ops.mli index 8601e21b..85b03028 100644 --- a/shared/src/logseq/core/pending_ops.mli +++ b/shared/src/logseq/core/pending_ops.mli @@ -113,6 +113,7 @@ type pending_create = type pending_journal = { page_uuid : string ; block_uuid : string + ; block_title : string ; title : string ; journal_day : int ; created_at : int diff --git a/shared/src/logseq/core/pending_projection.ml b/shared/src/logseq/core/pending_projection.ml index 88e066bb..72b791de 100644 --- a/shared/src/logseq/core/pending_projection.ml +++ b/shared/src/logseq/core/pending_projection.ml @@ -687,9 +687,9 @@ let rec compile db (intent : Ops.pending_intent) = @ [ entity_tx (Ds.Temp_id ("pending/" ^ value.block_uuid)) - [ + ([ ("block/uuid", Ds.One_value (Ds.Uuid value.block_uuid)) - ; ("block/title", Ds.One_value (Ds.String "")) + ; ("block/title", Ds.One_value (Ds.String value.block_title)) ; ("block/page", Ds.One_value (Ds.Ref_to page_ref)) ; ("block/parent", Ds.One_value (Ds.Ref_to page_ref)) ; ("block/order", Ds.One_value (Ds.String "a0")) @@ -697,8 +697,9 @@ let rec compile db (intent : Ops.pending_intent) = , Ds.One_value (Ds.Int64 (Int64.of_int value.created_at)) ) ; ( "block/updated-at" , Ds.One_value (Ds.Int64 (Int64.of_int value.created_at)) ) - ]; - ])) + ] @ many_refs "block/refs" (refs_for_title db value.block_title) + @ many_refs "block/tags" (tag_eids_for_title db value.block_title)); + ] @ List.concat_map (tag_behavior_tx db value.block_uuid) (tag_eids_for_title db value.block_title))) | Ops.Add_tag value -> (match ( Ds.entid db "block/uuid" (Ds.Uuid value.uuid) diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index b6fe2954..8130bd21 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -42,7 +42,8 @@ let contains value input = Outliner_state.includes_case_insensitive value input type property = {attr : string; title : string; typ : string; many : bool; ref_ : bool; choices : int list} let property db attr = let id = attr_eid db attr in - let typ = match Option.map (fun id -> get db id "logseq.property/type") id with Some (Ds.Keyword typ) -> typ | _ -> "default" in + let typ = if attr = "block/tags" then "class" else + match Option.map (fun id -> get db id "logseq.property/type") id with Some (Ds.Keyword typ) -> typ | _ -> "default" in let schema = List.assoc_opt attr db.Ds.schema in let choices = match id with None -> [] | Some id -> Datascript_value.datoms_by_ref db Ds.Aevt "block/closed-value-property" id @@ -156,10 +157,10 @@ let columns db view rows = (if ordered = [] then "block/title" :: List.filter (( <> ) "block/title") rest else ordered @ rest) |> List.map (property db) let editable_type p = List.mem p.typ ["default"; "url"; "number"; "checkbox"; "date"; "datetime"; "page"; "entity"; "node"; "class"; "property"] -let editable p = p.attr = "block/title" || - ((String.starts_with ~prefix:"user.property/" p.attr || List.mem p.attr ["logseq.property/status"; "logseq.property/priority"]) - && editable_type p) -let node_editable db p = editable p || (editable_type p && String.starts_with ~prefix:"logseq.property/" p.attr +let editable p = public_attr p.attr + && not (List.mem p.attr ["block/title"; "block/created-at"; "block/updated-at"]) + && editable_type p +let node_editable db p = p.attr = "block/title" || p.attr = "block/tags" || editable p || (editable_type p && String.starts_with ~prefix:"logseq.property/" p.attr && match attr_eid db p.attr with None -> false | Some id -> get db id "logseq.property/hide?" <> Ds.Bool true && get db id "logseq.property/public?" <> Ds.Bool false) let column_json db rows p = @@ -352,7 +353,7 @@ let edit_property ?(text_value = false) ?(allow_builtin = false) db ~now ~fresh_ | Some (d, _) -> semantic db (Ds.Ref d.e) | None -> let uuid = Rpc_wire.journal_page_uuid day in - created := Ops.Create_journal {page_uuid = uuid; block_uuid = fresh_uuid (); + created := Ops.Create_journal {page_uuid = uuid; block_uuid = fresh_uuid (); block_title = ""; title = Rpc_wire.journal_day_title day; journal_day = day; created_at = now} :: !created; Ops.Ref_uuid uuid)) else if List.mem p.typ ["number"; "datetime"; "default"; "url"] then diff --git a/shared/src/logseq/core/rpc_encode.ml b/shared/src/logseq/core/rpc_encode.ml index 3c7e316d..e2beed42 100644 --- a/shared/src/logseq/core/rpc_encode.ml +++ b/shared/src/logseq/core/rpc_encode.ml @@ -380,6 +380,8 @@ let outliner_command_json (command : Effects.outliner_platform_command) = ("focusBlock", "uuid", `String uuid) | Effects.Confirm_delete uuids -> ("confirmDelete", "uuids", Wire.json_strings uuids) + | Effects.Set_clipboard_blocks text -> + ("setClipboardBlocks", "text", `String text) | Effects.Set_clipboard_text text -> ("setClipboardText", "text", `String text) | Effects.Set_clipboard_references uuids -> diff --git a/shared/src/logseq/core/rpc_ops.ml b/shared/src/logseq/core/rpc_ops.ml index bc58f698..555596e2 100644 --- a/shared/src/logseq/core/rpc_ops.ml +++ b/shared/src/logseq/core/rpc_ops.ml @@ -43,6 +43,9 @@ let reconcile_authoritative_blocks cache blocks = let normalize_title_intent normalize (intent : Ops.pending_intent) = match intent with + | Ops.Create_journal value -> + let titles, tags = normalize value.block_uuid [value.block_title] in + ((match titles with [block_title] -> Ops.Create_journal {value with Ops.block_title} | _ -> intent), tags) | Ops.Save_title value -> let titles, tags = normalize value.uuid [ value.title ] in ( (match titles with @@ -125,8 +128,8 @@ let capture_operation base_t uuid title now load_context journal_page_id (Ops.Create_journal { Ops.page_uuid = Wire.journal_page_uuid journal_day; - block_uuid = uuid; - title; + block_uuid = uuid; block_title = title; + title = Wire.journal_day_title journal_day; journal_day; created_at = now; })) diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index 132ce5a5..46c399d8 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -719,6 +719,15 @@ let dispatch_outliner_event session payload = (Outliner.Operation_staged operation.intent))) next_state operations in + let next_state = match message with + | Outliner.Paste_content _ -> + List.fold_left (fun current -> function + | Effects.Focus_block uuid -> + (match Outliner.find_block projected uuid with + | Some block -> fst (Outliner.staged_editing projected current uuid block.title false) + | None -> current) + | _ -> current) next_state interpreted.platform + | _ -> next_state in session.Types.state := { !(session.state) with @@ -1082,7 +1091,7 @@ let ensure_journal session payload = let uuid = Rpc.journal_page_uuid day in let operation = { Ops.operation_id = fresh_squuid (); base_t; state = Ops.Queued; - intent = Ops.Create_journal { Ops.page_uuid = uuid; block_uuid = fresh_squuid (); + intent = Ops.Create_journal { Ops.page_uuid = uuid; block_uuid = fresh_squuid (); block_title = ""; title = Rpc.journal_day_title day; journal_day = day; created_at = now_ms () } } in Result.map (fun () -> uuid) (enqueue_semantic session operation) | None, _, _ -> Error "Select a graph before creating a journal" @@ -1206,6 +1215,84 @@ let send_message session payload = end end +let prepare_capture_tags session uuid tags (operation : Ops.pending_operation) now = + let ( let* ) = Result.bind in + let* db = match query_db session with Some db -> Ok db | None -> Error "The graph is unavailable" in + let* tx = Pending_projection.compile db operation.intent in + try + let db = Datascript.db_with tx db in + let field name = match tags with `Assoc fields -> Option.value ~default:`Null (List.assoc_opt name fields) + | _ -> invalid_arg "Invalid tags" in + let strings name = match field name with + | `Null -> [] + | `List values -> List.map (function `String value -> value | _ -> invalid_arg "Choose a tag") values + | _ -> invalid_arg "Invalid tag selection" in + let catalog = Hashtbl.create 32 in + Graph_read.tag_pages (fun value -> Ok value) db |> List.iter (fun (tag : Model.entity_summary) -> + let key = Dev_query.Unicode.lowercase tag.title in + let previous = Option.value ~default:[] (Hashtbl.find_opt catalog key) in + Hashtbl.replace catalog key (tag.uuid :: previous)); + let selected = ref (strings "selectedValues") and created = ref [] in + let seen = Hashtbl.create 8 in + List.iter (fun title -> + let title = String.trim title in + if title = "" then invalid_arg "Enter a tag name"; + let key = Dev_query.Unicode.lowercase title in + if not (Hashtbl.mem seen key) then ( + Hashtbl.add seen key (); + match Hashtbl.find_opt catalog key with + | Some [uuid] -> selected := uuid :: !selected + | Some _ -> invalid_arg "More than one tag has this name; choose the existing tag" + | None -> created := title :: !created)) (strings "newValues"); + let property = Result_view.property_json ~include_candidates:false db ~owner:uuid ~attr:"block/tags" ~encode_block:(fun _ -> `Null) in + let expected = match property with `Assoc fields -> Option.value ~default:`Null (List.assoc_opt "raw" fields) | _ -> `Null in + let id = match Datascript.entid db "block/uuid" (Datascript.Uuid uuid) with Some id -> id | None -> invalid_arg "Missing capture" in + let existing = Graph_read.entity_summaries (fun value -> Ok value) db id "block/tags" + |> List.map (fun (tag : Model.entity_summary) -> tag.uuid) in + let selected = List.sort_uniq String.compare (existing @ !selected) in + let intent = Result_view.edit_property ~allow_builtin:true db ~now ~fresh_uuid:fresh_squuid + (`Assoc ["blockUuid", `String uuid; "attr", `String "block/tags"; "expected", expected; + "value", `String ""; "create", `Bool true; + "selectedValues", `List (List.map (fun uuid -> `String uuid) selected); + "newValues", `List (List.map (fun title -> `String title) (List.rev !created))]) in + let* _ = Pending_projection.compile db intent in + Ok intent + with Invalid_argument message | Failure message -> Error message + +let send_tagged session input fields = + let ( let* ) = Result.bind in + let prepared = + let* text = Rpc.required_string fields "text" in + let* uuid = Rpc.required_string fields "uuid" in + let* now = Rpc.optional_int fields "now" in + let* status = Rpc.optional_status_payload input in + let now = Option.value ~default:(now_ms ()) now in + let text = String.trim text in + if text = "" then Error "Enter text to capture" else + let* operations = capture_operations session uuid text now status in + match operations with + | [] -> Error "The capture is unavailable" + | first :: _ -> + let operation = {first with Ops.operation_id = fresh_squuid (); + intent = Ops.Atomic (List.map (fun (op : Ops.pending_operation) -> op.intent) operations)} in + let tags = match input with `Assoc fields -> Option.value ~default:`Null (List.assoc_opt "tags" fields) | _ -> `Null in + let* tag_intent = prepare_capture_tags session uuid tags operation now in + Ok {operation with Ops.intent = Ops.Atomic [operation.intent; tag_intent]} + in + match prepared with + | Error message -> Rpc.failure "capture_failed" message + | Ok operation -> (match enqueue_semantic session operation with + | Ok () -> snapshot_visible session + | Error message -> Rpc.failure "capture_failed" message) + +let composer_tags session = + let tags = match (host session).Types.graph_tag_pages with + | Some load -> Option.value ~default:[] (load ()) + | None -> Option.fold ~none:[] ~some:(Graph_read.tag_pages (fun value -> Ok value)) (query_db session) in + Rpc.success (`Assoc ["selectedGraphId", (match (state session).config with Some config -> `String config.graph_id | None -> `Null); + "composerTagChoices", `List (List.map (fun (tag : Model.entity_summary) -> + `Assoc ["uuid", `String tag.uuid; "title", `String tag.title]) tags)]) + let send_task session input fields = match let ( let* ) = Result.bind in @@ -1950,6 +2037,8 @@ let rec dispatch session action payload = "selectedGraphId", (match (state session).config with Some config -> `String config.Api.graph_id | None -> `Null); "searchQuery", `String query; "searchOffset", `Int offset; "searchHasMore", `Bool has_more; "searchResults", Rpc.json_list (List.map Rpc.search_hit_json found)]) + | "composerTags" -> composer_tags session + | "sendTagged" -> object_action (send_tagged session) | "send" -> send_message session payload | "sendTask" -> object_action (send_task session) | "addAsset" -> @@ -1969,9 +2058,19 @@ let rec dispatch session action payload = (base_outliner_context session).Outliner.blocks in let stage () = - if (state session).config <> None then + if (state session).config = None then None else + Option.map (fun stage operation -> + let tags = Option.bind payload (fun raw -> try match Json.from_string raw with + | `Assoc fields -> List.assoc_opt "tags" fields | _ -> None with _ -> None) in + match tags with + | None -> stage operation + | Some tags -> + let ( let* ) = Result.bind in + let uuid = match operation.Ops.intent with Ops.Create_asset asset -> asset.uuid | _ -> "" in + let* intent = prepare_capture_tags session uuid tags operation (now_ms ()) in + let* () = stage operation in + stage {operation with Ops.operation_id = fresh_squuid (); state = Ops.Queued; intent}) (host session).Types.stage_operation - else None in Rpc.add_asset ~prepare_file:h.import_asset_file payload s.model now_ms target prepare_view (fun block -> asset_datoms_operation session block Ops.Applied) diff --git a/shared/src/logseq/core/rpc_wire.ml b/shared/src/logseq/core/rpc_wire.ml index 0ff0d374..baeaab53 100644 --- a/shared/src/logseq/core/rpc_wire.ml +++ b/shared/src/logseq/core/rpc_wire.ml @@ -215,6 +215,15 @@ let decode_outliner_event fields = | "longPressBlock" -> let* uuid = required_string fields "uuid" in Ok (Outliner.Long_press_block uuid) + | "pasteContent" -> + let* target_uuid = required_string fields "uuid" in + let* before_title = required_string fields "beforeTitle" in + let* selection_start = event_int fields "selectionStart" in + let* selection_length = event_int fields "selectionLength" in + let* plain_text = required_string fields "plainText" in + let blocks_edn = match List.assoc_opt "blocksEDN" fields with Some (`String value) -> value | _ -> "" in + Ok (Outliner.Paste_content {Block_clipboard.target_uuid; before_title; + selection_start; selection_length; plain_text; blocks_edn}) | "textChanged" -> let* title = required_string fields "title" in let* caret = event_int fields "caretUTF16Offset" in diff --git a/shared/src/logseq/core/string_kit.ml b/shared/src/logseq/core/string_kit.ml index a560ca82..dd9bcdfa 100644 --- a/shared/src/logseq/core/string_kit.ml +++ b/shared/src/logseq/core/string_kit.ml @@ -87,3 +87,34 @@ let to_bytes s = Bytes.of_string s let is_ascii_whitespace c = c = ' ' || c = '\t' || c = '\n' || c = '\r' || c = '\011' || c = '\012' + +let utf8_sequence_length byte = + if byte land 128 = 0 then 1 + else if byte land 224 = 192 then 2 + else if byte land 240 = 224 then 3 + else if byte land 248 = 240 then 4 + else 1 + +let utf16_units byte = if utf8_sequence_length byte = 4 then 2 else 1 + +let byte_index_of_utf16 value offset = + let rec loop index units = + if index >= String.length value || units >= max 0 offset then index + else + let byte = Char.code value.[index] in + loop + (min (String.length value) (index + utf8_sequence_length byte)) + (units + utf16_units byte) + in + loop 0 0 + +let utf16_length value = + let rec loop index units = + if index >= String.length value then units + else + let byte = Char.code value.[index] in + loop + (min (String.length value) (index + utf8_sequence_length byte)) + (units + utf16_units byte) + in + loop 0 0 diff --git a/shared/src/logseq/core/string_kit.mli b/shared/src/logseq/core/string_kit.mli index c2f38ba7..cb8ce538 100644 --- a/shared/src/logseq/core/string_kit.mli +++ b/shared/src/logseq/core/string_kit.mli @@ -23,3 +23,8 @@ val hex_of_char : char -> string val of_bytes : bytes -> string val to_bytes : string -> bytes val is_ascii_whitespace : char -> bool + +val utf8_sequence_length : int -> int +val utf16_units : int -> int +val byte_index_of_utf16 : string -> int -> int +val utf16_length : string -> int diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index b0e929c2..bd346885 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -27,10 +27,16 @@ type asset_preview = ; preview_path : string } +type sidebar_page = + { uuid : string + ; title : string + } + type ui_session = { graph_id : string option ; destination : primary_destination ; assets : composer_asset list + ; composer_tags : sidebar_page list ; composer_expanded : bool ; search_open : bool ; query : string @@ -41,11 +47,6 @@ type ui_session = ; recent_graph_ids : string list } -type sidebar_page = - { uuid : string - ; title : string - } - type graph = { id : string ; name : string @@ -118,6 +119,7 @@ type search_hit = type outline_row = { row_uuid : string ; row_loaded : bool + ; row_cached : bool ; row_title : string ; row_task_dates_json : string ; row_list_number : int option @@ -218,7 +220,8 @@ type outline_row_splice = } type core_projection = - { graph_name : string option + { tag_choices_update : sidebar_page list option + ; graph_name : string option ; selected_graph_id : string option ; graphs : graph list ; is_graph_encrypted : bool @@ -259,11 +262,20 @@ type core_projection = ; outliner_row_splices : outline_row_splice list } +type composer_submission = + { capture_text : string + ; capture_status : task_status option + ; capture_tags : sidebar_page list + ; capture_graph_id : string option + } + type logseq_effect = | ResultViewEffect of int * string * string | NodePropertyEffect of int * string * string + | LoadComposerTagsEffect of int * string option + | SendTaggedCaptureEffect of int * composer_submission | SendCaptureEffect of int * string - | SendAssetEffect of int * composer_asset + | SendAssetEffect of int * composer_asset * sidebar_page list * string option | SendTaskEffect of int * string * task_status | PersistComposerDraftEffect of int * string | PersistUISessionEffect of int * ui_session @@ -412,6 +424,10 @@ type logseq_model = ; has_older_journals : bool ; composer_expanded : bool ; composer_draft : string + ; composer_tags : sidebar_page list + ; composer_tag_choices : sidebar_page list + ; composer_tags_open : bool + ; composer_tags_loading : bool ; composer_assets : composer_asset list ; composer_autofocus : bool ; capture_confirmation : int option @@ -444,6 +460,7 @@ type logseq_action = | ApplyCoreSnapshot of core_projection | BeginOutlinerEdit of string | EditorCompletion of string * string + | PasteOutlinerContent of string * string | PasteOutlinerText of string * string * int | ChangeOutlinerText of string * string * int | ReturnOutlinerEditor of string * string * int @@ -460,6 +477,10 @@ type logseq_action = | DismissAssetPreview | DismissCaptureConfirmation of int | CloseSearch + | OpenComposerTags + | CloseComposerTags + | SetComposerTags of sidebar_page list + | RemoveComposerTag of string | ExpandComposer | FocusComposer | ApplyComposerDraft of string @@ -747,6 +768,10 @@ let initial () = has_older_journals = false; composer_expanded = false; composer_draft = ""; + composer_tags = []; + composer_tag_choices = []; + composer_tags_open = false; + composer_tags_loading = false; composer_assets = []; composer_autofocus = false; capture_confirmation = None; @@ -797,8 +822,10 @@ let navigation_route_uuid route = let effect_id eff = match eff with | ResultViewEffect (id, _, _) | NodePropertyEffect (id, _, _) -> id + | LoadComposerTagsEffect (id, _) -> id + | SendTaggedCaptureEffect (id, _) -> id | SendCaptureEffect (id, _) -> id - | SendAssetEffect (id, _) -> id + | SendAssetEffect (id, _, _, _) -> id | SendTaskEffect (id, _, _) -> id | PersistComposerDraftEffect (id, _) -> id | PersistUISessionEffect (id, _) -> id @@ -887,7 +914,7 @@ let contains_journal_refresh_effect_ effects = (fun eff -> match eff with | LoadOlderJournalsEffect _ -> true - | SendCaptureEffect _ -> true + | SendTaggedCaptureEffect _ | SendCaptureEffect _ -> true | SendTaskEffect _ -> true | _ -> false) effects @@ -1564,6 +1591,11 @@ let rollback_navigation_effect (current : logseq_model) eff = | OpenGraphEffect _ -> { current with graph_loading = false } | _ -> current +let composer_for_graph (current : logseq_model) graph_id = + if current.selected_graph_id = graph_id then current + else {current with composer_tags = []; composer_tag_choices = []; + composer_tags_open = false; composer_tags_loading = false} + let resolve_failed_effect (current : logseq_model) eff message = match eff with | SignInEffect _ -> @@ -1576,9 +1608,19 @@ let resolve_failed_effect (current : logseq_model) eff message = { current with sync_state = FailedState message } | CreateGraphEffect _ when String.ends_with ~suffix:E2ee_keyring.password_required_message message -> { current with new_graph_password_required = true } + | LoadComposerTagsEffect (_, graph_id) -> + if graph_id <> current.selected_graph_id then current + else {current with composer_tags_open = false; composer_tags_loading = false} + | SendTaggedCaptureEffect (_, capture) -> + if capture.capture_graph_id <> current.selected_graph_id then current + else {current with capture_confirmation = None; + composer_draft = (if current.composer_draft = "" then capture.capture_text else current.composer_draft); + composer_tags = (if current.composer_tags = [] then capture.capture_tags else current.composer_tags)} | SendCaptureEffect _ | SendTaskEffect _ -> { current with capture_confirmation = None } - | SendAssetEffect (_, asset) -> + | SendAssetEffect (_, asset, tags, graph_id) -> + if graph_id <> current.selected_graph_id then current else + let current = { current with composer_tags = (if current.composer_tags = [] then tags else current.composer_tags) } in let current = { current with capture_confirmation = None } in if List.exists (fun (candidate : composer_asset) -> candidate.uuid = asset.uuid) current.composer_assets then current else { current with composer_assets = current.composer_assets @ [asset] } @@ -1586,9 +1628,10 @@ let resolve_failed_effect (current : logseq_model) eff message = let resolve_successful_effect (current : logseq_model) eff _message = match eff with - | SendCaptureEffect (id, _) | SendTaskEffect (id, _, _) -> + | SendTaggedCaptureEffect (id, _) | SendCaptureEffect (id, _) | SendTaskEffect (id, _, _) -> { current with capture_confirmation = Some id } - | SendAssetEffect (id, asset) -> + | SendAssetEffect (id, asset, _, graph_id) -> + if graph_id <> current.selected_graph_id then current else { current with capture_confirmation = Some id; @@ -1604,6 +1647,7 @@ let resolve_successful_effect (current : logseq_model) eff _message = authentication_error = None; } | OpenGraphEffect (_, graph_id) -> + let current = composer_for_graph current (Some graph_id) in (match graph_by_id current.graphs graph_id with | Some selected -> { @@ -1767,6 +1811,11 @@ let editing_sync_state (current : logseq_model) = | FailedState reason -> FailedState reason | _ -> SyncingState +let composer_tag_selection tags = + let existing, created = List.partition (fun (tag : sidebar_page) -> not (String.starts_with ~prefix:"new:" tag.uuid)) tags in + `Assoc ["selectedValues", `List (List.map (fun (tag : sidebar_page) -> `String tag.uuid) existing); + "newValues", `List (List.map (fun (tag : sidebar_page) -> `String tag.title) created)] + let ui_session (current : logseq_model) = { graph_id = current.selected_graph_id; @@ -1776,12 +1825,13 @@ let ui_session (current : logseq_model) = List.fold_left (fun assets eff -> match eff with - | SendAssetEffect (_, asset) - when not (List.exists (fun (candidate : composer_asset) -> candidate.uuid = asset.uuid) assets) -> + | SendAssetEffect (_, asset, _, graph_id) + when graph_id = current.selected_graph_id && not (List.exists (fun (candidate : composer_asset) -> candidate.uuid = asset.uuid) assets) -> assets @ [asset] | _ -> assets) current.composer_assets (current.in_flight_effects @ current.pending_effects); + composer_tags = current.composer_tags; composer_expanded = current.composer_expanded; search_open = current.search_open; query = current.search_query; @@ -1816,6 +1866,7 @@ let restore_ui_session (current : logseq_model) (session : ui_session) = destination = session.destination; recent_graph_ids = session.recent_graph_ids; composer_assets = session.assets; + composer_tags = session.composer_tags; composer_expanded = session.composer_expanded; composer_autofocus = session.composer_expanded; search_open = session.search_open; @@ -1867,7 +1918,8 @@ let retain_shell_contents previous rows = if row.row_loaded then row else match Hashtbl.find_opt by_id row.row_uuid with | None -> row - | Some cached -> { cached with row_loaded = false; depth = row.depth; + | Some cached -> { cached with row_loaded = false; + row_cached = cached.row_loaded || cached.row_cached; depth = row.depth; has_children = row.has_children; is_collapsed = row.is_collapsed; opens_as_page = row.opens_as_page; page_id = row.page_id; journal_title = row.journal_title; journal_day = row.journal_day }) rows @@ -1973,6 +2025,16 @@ let rec update (current : logseq_model) action = if not (List.mem kind ["searchAutocomplete"; "submitAutocomplete"; "dismissAutocomplete"]) then current else enqueue_effect current (EditorOutlinerEventEffect (current.next_effect_id, Yojson.Basic.to_string (`Assoc (("type", `String kind) :: fields)))) + | PasteOutlinerContent (uuid, payload) -> + (try match Yojson.Basic.from_string payload with + | `Assoc fields -> + let updated = cancel_outliner_autosave current in + let updated = {updated with outliner_autocomplete = None; outliner_autocomplete_candidates = []} in + let fields = ("type", `String "pasteContent") :: ("uuid", `String uuid) + :: List.remove_assoc "type" (List.remove_assoc "uuid" fields) in + enqueue_effect updated (EditorOutlinerEventEffect (updated.next_effect_id, Yojson.Basic.to_string (`Assoc fields))) + | _ -> current + with _ -> current) | PasteOutlinerText (uuid, title, caret) -> let updated = update_editing current uuid title caret in let updated = {updated with outliner_autocomplete = None; outliner_autocomplete_candidates = []; @@ -2102,6 +2164,13 @@ let rec update (current : logseq_model) action = } in enqueue_close_search_effects closed path + | OpenComposerTags -> + enqueue_effect {current with composer_tags_open = true; composer_tags_loading = true; composer_autofocus = false} + (LoadComposerTagsEffect (current.next_effect_id, current.selected_graph_id)) + | CloseComposerTags -> {current with composer_tags_open = false; composer_autofocus = true} + | SetComposerTags tags -> {current with composer_tags = tags; composer_tags_open = false; composer_autofocus = true} + | RemoveComposerTag uuid -> + {current with composer_tags = List.filter (fun (tag : sidebar_page) -> tag.uuid <> uuid) current.composer_tags} | ExpandComposer -> { current with composer_expanded = true; composer_autofocus = true } | FocusComposer -> @@ -2141,9 +2210,18 @@ let rec update (current : logseq_model) action = } | SendComposer -> begin - let assets = current.composer_assets in + let tags = current.composer_tags in + let graph_id = current.selected_graph_id in + let assets = List.map (fun (asset : composer_asset) -> + let payload = try match Yojson.Basic.from_string asset.payload with + | `Assoc fields -> Yojson.Basic.to_string (`Assoc + (if tags = [] then List.remove_assoc "tags" fields + else ("tags", composer_tag_selection tags) :: List.remove_assoc "tags" fields)) + | _ -> asset.payload with _ -> asset.payload in + {asset with payload}) current.composer_assets in let submission = String.trim current.composer_draft in let current = { current with composer_assets = []; + composer_tags = (if submission = "" && assets = [] then tags else []); capture_confirmation = (if submission = "" && assets = [] then current.capture_confirmation else None) } in let with_text = @@ -2164,7 +2242,10 @@ let rec update (current : logseq_model) action = (PersistComposerDraftEffect (cleared.next_effect_id, "")) in enqueue_effect persisted - (match current.selected_task_status with + (if tags <> [] then SendTaggedCaptureEffect (persisted.next_effect_id, + {capture_text = submission; capture_status = current.selected_task_status; + capture_tags = tags; capture_graph_id = graph_id}) + else match current.selected_task_status with | Some status -> SendTaskEffect (persisted.next_effect_id, submission, status) | None -> @@ -2174,7 +2255,7 @@ let rec update (current : logseq_model) action = List.fold_left (fun updated asset -> enqueue_effect updated - (SendAssetEffect (updated.next_effect_id, asset))) + (SendAssetEffect (updated.next_effect_id, asset, tags, graph_id))) with_text assets end | DequeueEffect id -> @@ -2205,6 +2286,8 @@ let rec update (current : logseq_model) action = in_flight_effects = remove_effect current.in_flight_effects id; outliner_selection_revision = (match succeeded, eff with + | _, EditorOutlinerEventEffect (_, payload) + when (try match Yojson.Basic.from_string payload with `Assoc fields -> List.assoc_opt "type" fields = Some (`String "pasteContent") | _ -> false with _ -> false) -> current.outliner_selection_revision + 1 | true, (ChooseOutlinerAutocompleteEffect _ | EditorOutlinerEventEffect _) -> current.outliner_selection_revision + 1 | _ -> current.outliner_selection_revision); result_view_feedback = (match eff with @@ -2841,7 +2924,10 @@ let rec update (current : logseq_model) action = and apply_core_snapshot (current : logseq_model) (projection : core_projection) = - if Option.is_some projection.reference_update then + if Option.is_some projection.tag_choices_update then + if projection.selected_graph_id <> current.selected_graph_id then current + else {current with composer_tag_choices = Option.get projection.tag_choices_update; composer_tags_loading = false} + else if Option.is_some projection.reference_update then if projection.selected_graph_id <> current.selected_graph_id then current else let uuid, rows = Option.get projection.reference_update in @@ -2878,6 +2964,7 @@ and apply_core_snapshot (current : logseq_model) node_linked_reference_rows = hydrate route.node_linked_reference_rows }) current.node_routes } else if projection.is_graph_catalog_patch then + let current = composer_for_graph current projection.selected_graph_id in { current with selected_graph = projection.graph_name; @@ -3035,10 +3122,13 @@ and apply_core_snapshot (current : logseq_model) in let updated = { - current with + (composer_for_graph current projection.selected_graph_id) with search_navigation_path; selected_graph = projection.graph_name; selected_graph_id = projection.selected_graph_id; + composer_tags = (if projection.selected_graph_id = current.selected_graph_id then current.composer_tags else []); + composer_tag_choices = (if projection.selected_graph_id = current.selected_graph_id then current.composer_tag_choices else []); + composer_tags_open = current.composer_tags_open && projection.selected_graph_id = current.selected_graph_id; graphs = projection.graphs; local_graph_ids; is_graph_encrypted = projection.is_graph_encrypted; diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index cb5931e3..6f4b46f4 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -24,10 +24,16 @@ type asset_preview = ; preview_path : string } +type sidebar_page = + { uuid : string + ; title : string + } + type ui_session = { graph_id : string option ; destination : primary_destination ; assets : composer_asset list + ; composer_tags : sidebar_page list ; composer_expanded : bool ; search_open : bool ; query : string @@ -38,11 +44,6 @@ type ui_session = ; recent_graph_ids : string list } -type sidebar_page = - { uuid : string - ; title : string - } - type graph = { id : string ; name : string @@ -115,6 +116,7 @@ type search_hit = type outline_row = { row_uuid : string ; row_loaded : bool + ; row_cached : bool ; row_title : string ; row_task_dates_json : string ; row_list_number : int option @@ -215,7 +217,8 @@ type outline_row_splice = } type core_projection = - { graph_name : string option + { tag_choices_update : sidebar_page list option + ; graph_name : string option ; selected_graph_id : string option ; graphs : graph list ; is_graph_encrypted : bool @@ -256,11 +259,20 @@ type core_projection = ; outliner_row_splices : outline_row_splice list } +type composer_submission = + { capture_text : string + ; capture_status : task_status option + ; capture_tags : sidebar_page list + ; capture_graph_id : string option + } + type logseq_effect = | ResultViewEffect of int * string * string | NodePropertyEffect of int * string * string + | LoadComposerTagsEffect of int * string option + | SendTaggedCaptureEffect of int * composer_submission | SendCaptureEffect of int * string - | SendAssetEffect of int * composer_asset + | SendAssetEffect of int * composer_asset * sidebar_page list * string option | SendTaskEffect of int * string * task_status | PersistComposerDraftEffect of int * string | PersistUISessionEffect of int * ui_session @@ -409,6 +421,10 @@ type logseq_model = ; has_older_journals : bool ; composer_expanded : bool ; composer_draft : string + ; composer_tags : sidebar_page list + ; composer_tag_choices : sidebar_page list + ; composer_tags_open : bool + ; composer_tags_loading : bool ; composer_assets : composer_asset list ; composer_autofocus : bool ; capture_confirmation : int option @@ -441,6 +457,7 @@ type logseq_action = | ApplyCoreSnapshot of core_projection | BeginOutlinerEdit of string | EditorCompletion of string * string + | PasteOutlinerContent of string * string | PasteOutlinerText of string * string * int | ChangeOutlinerText of string * string * int | ReturnOutlinerEditor of string * string * int @@ -457,6 +474,10 @@ type logseq_action = | DismissAssetPreview | DismissCaptureConfirmation of int | CloseSearch + | OpenComposerTags + | CloseComposerTags + | SetComposerTags of sidebar_page list + | RemoveComposerTag of string | ExpandComposer | FocusComposer | ApplyComposerDraft of string @@ -686,3 +707,5 @@ val restore_ui_session : logseq_model -> ui_session -> logseq_model val graph_creation_password_invalid_ : logseq_model -> bool val wide_content_breakpoint : int + +val composer_tag_selection : sidebar_page list -> Yojson.Basic.t diff --git a/shared/src/logseq/native_bridge.ml b/shared/src/logseq/native_bridge.ml index 5f1293ad..9c2077ae 100644 --- a/shared/src/logseq/native_bridge.ml +++ b/shared/src/logseq/native_bridge.ml @@ -121,6 +121,7 @@ let host_action (update : Host_update.host_update) = | "graphs" -> Model.GraphsDestination | _ -> Model.JournalsDestination); assets = List.map session_asset session.assets; + composer_tags = List.map (fun (uuid, title) -> ({Model.uuid; title} : Model.sidebar_page)) session.composer_tags; composer_expanded = session.composer_expanded; search_open = session.search_open; query = session.query; @@ -177,7 +178,10 @@ let encode_ui_session (session : Model.ui_session) = | Model.JournalsDestination -> "journals") ^ ",\"assets\":[" ^ String.concat "," (List.map encode_session_asset session.assets) - ^ "]" ^ ",\"composerExpanded\":" + ^ "]" ^ ",\"composerTags\":" + ^ Yojson.Basic.to_string (`List (List.map (fun (tag : Model.sidebar_page) -> + `Assoc ["uuid", `String tag.uuid; "title", `String tag.title]) session.composer_tags)) + ^ ",\"composerExpanded\":" ^ string_of_bool session.composer_expanded ^ ",\"searchOpen\":" ^ string_of_bool session.search_open ^ ",\"query\":" ^ Lui_wire.quoted session.query ^ ",\"appPath\":" @@ -223,10 +227,18 @@ let encode_runtime_log_records records = let encode_effect (eff : Model.logseq_effect) = match eff with - | SendAssetEffect (id, asset) -> + | SendAssetEffect (id, asset, _, _) -> "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"send-asset\",\"text\":" ^ Lui_wire.quoted asset.payload ^ "}" + | LoadComposerTagsEffect (id, _) -> + "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"load-composer-tags\",\"text\":\"\"}" + | SendTaggedCaptureEffect (id, capture) -> + let status = match capture.capture_status with None -> `Null + | Some status -> Yojson.Basic.from_string (encode_task_status status) in + let payload = Yojson.Basic.to_string (`Assoc ["text", `String capture.capture_text; + "tags", Model.composer_tag_selection capture.capture_tags; "status", status]) in + "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"send-tagged\",\"text\":" ^ Lui_wire.quoted payload ^ "}" | SendCaptureEffect (id, text) -> "{\"id\":" ^ string_of_int id ^ ",\"kind\":\"send-capture\",\"text\":" ^ Lui_wire.quoted text ^ "}" @@ -547,6 +559,7 @@ let extension_event node identifier name text value = | 1 -> "inside" | _ -> "after") ); ] + | _, "paste-content" -> ["value", StringValue text] | _, ("text-change" | "paste" | "return") -> [ "title", StringValue text; diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index 4924fe35..e1ba2e0b 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -243,6 +243,7 @@ let outline_row_from_block youtube_target_url opens_as_page depth row_task_dates_json = (match List.assoc_opt "taskDates" fields with Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); row_list_number = int_member "listNumber" fields; row_display_type = string_member "displayType" fields; row_loaded = not (bool_member "isShell" fields); + row_cached = false; query_json = (match List.assoc_opt "query" fields with | Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); markup_json = @@ -444,7 +445,9 @@ let core_projection fields = | Some route -> get route | None -> fallback in - { Model.graph_name = string_member "graphName" fields; + { Model.tag_choices_update = (match List.assoc_opt "composerTagChoices" fields with + | Some (`List choices) -> Some (List.filter_map sidebar_page choices) | _ -> None); + graph_name = string_member "graphName" fields; selected_graph_id = string_member "selectedGraphId" fields; graphs = List.filter_map graph (list_member "graphs" fields); is_graph_encrypted = bool_member "isGraphEncrypted" fields; diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index d3d26e99..b2ad4c30 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -59,6 +59,7 @@ let outliner_editor_schema () = Lui_extension.event "paste" [Lui_extension.event_field "title" StringScalar true; Lui_extension.event_field "caret-utf16-offset" IntScalar true]; + Lui_extension.event "paste-content" [Lui_extension.event_field "value" StringScalar true]; Lui_extension.event "return" [ Lui_extension.event_field "title" StringScalar true; diff --git a/shared/src/logseq/view_base.ml b/shared/src/logseq/view_base.ml index 92f640d2..057d368f 100644 --- a/shared/src/logseq/view_base.ml +++ b/shared/src/logseq/view_base.ml @@ -68,7 +68,9 @@ let handle_outliner_editor_event input_event block_id_source send = match input_event with | ExtensionEvent (_node, _identifier, name, values) -> let uuid = Signal.sample block_id_source in - if name = "paste" then + if name = "paste-content" then + send (Model.PasteOutlinerContent (uuid, extension_string values "value")) + else if name = "paste" then send (Model.PasteOutlinerText (uuid, extension_string values "title", extension_int values "caret-utf16-offset")) else if name = "text-change" then send diff --git a/shared/src/logseq/view_composer.ml b/shared/src/logseq/view_composer.ml index 6efc7b93..693ac34e 100644 --- a/shared/src/logseq/view_composer.ml +++ b/shared/src/logseq/view_composer.ml @@ -151,7 +151,51 @@ let composer_send_button context ?send_icon send_disabled on_send : t = ~accessibility_identifier:"button.send" ?disabled_signal:send_disabled ~on_press:on_send [] -let composer_surface ?key ?accessibility_identifier ?attachments +let composer_tag_chips model_source send : t = + if_ ~test:(Signal.map (fun (model : Model.logseq_model) -> model.composer_tags <> []) model_source) + (scroll ~orientation:`horizontal ~height:40 [row ~gap:6 [ + keyed ~source:(Signal.map (fun (model : Model.logseq_model) -> model.composer_tags) model_source) + ~key:(fun (tag : Model.sidebar_page) -> tag.uuid) ~cmp:String.compare + ~mount:(fun source -> let tag = Signal.sample source in + button ~text_signal:(Signal.map (fun (tag : Model.sidebar_page) -> "#" ^ tag.title) source) + ~icon:(`app "close") ~icon_placement:`trailing ~variant:`secondary ~corner_radius:16 + ~style_class:"caption single-line" ~min_height:32 ~padding_vertical:0 ~padding_horizontal:10 + ~foreground:"muted-foreground" ~label:("Remove tag " ^ tag.title) + ~accessibility_identifier:("button.composer.tag.remove." ^ tag.uuid) + ~on_press:(fun _ -> ignore (send (Model.RemoveComposerTag (Signal.sample source).uuid))) []) + ]]) + +let composer_tag_picker model_source send : t = + if_ ~test:(Signal.map (fun (model : Model.logseq_model) -> model.composer_tags_open && not model.composer_tags_loading) model_source) + (fun context parent -> + let model : Model.logseq_model = Signal.sample model_source in + let seen = Hashtbl.create 32 in + let options = List.filter (fun (tag : Model.sidebar_page) -> + if Hashtbl.mem seen tag.uuid then false else (Hashtbl.add seen tag.uuid (); true)) + (model.composer_tag_choices @ model.composer_tags) in + let property = `Assoc ["attr", `String "block/tags"; "title", `String "Tags"; "kind", `String "class"; + "many", `Bool true; "ref", `Bool true; "editable", `Bool true; "closed", `Bool false; + "canCreate", `Bool true; "textLayout", `Bool false; + "filterValues", `List (List.map (fun (tag : Model.sidebar_page) -> + `Assoc ["title", `String tag.title; "match", `List [`String tag.uuid]]) options); + "raw", `Assoc ["type", `String "set"; "value", `List (List.map (fun (tag : Model.sidebar_page) -> + `Assoc ["type", `String "ref-uuid"; "value", `String tag.uuid]) model.composer_tags)]] in + (View_query.property_editor ~id:"composer.tags" ~column:property model_source property + (fun () -> ignore (send Model.CloseComposerTags)) + ~submit:(fun fields -> + let strings name = match List.assoc_opt name fields with Some (`List values) -> + List.filter_map (function `String value -> Some value | _ -> None) values | _ -> [] in + let selected = List.filter_map (fun uuid -> List.find_opt (fun (tag : Model.sidebar_page) -> tag.uuid = uuid) options) + (strings "selectedValues") in + let created = List.map (fun title -> ({Model.uuid = "new:" ^ Dev_query.Unicode.lowercase title; title} : Model.sidebar_page)) + (strings "newValues") in + let seen = Hashtbl.create 8 in + let tags = List.filter (fun (tag : Model.sidebar_page) -> + let key = Dev_query.Unicode.lowercase tag.title in + if Hashtbl.mem seen key then false else (Hashtbl.add seen key (); true)) (selected @ created) in + ignore (send (Model.SetComposerTags tags)))) context parent) + +let composer_surface ?key ?accessibility_identifier ?attachments ?tag_strip ?attachments_visible_signal ?(actions = []) ~placeholder ?label ?text ?text_signal ?(autofocus = false) ?autofocus_signal ?submit_on_enter ?send_icon ?send_disabled_signal ?on_input ?on_submit ?on_send ?on_press () @@ -189,6 +233,7 @@ let composer_surface ?key ?accessibility_identifier ?attachments | None -> field | Some signal_ -> View_base.with_bool_prop_signal Lui_protocol.Autofocus signal_ field in + let field = match tag_strip with None -> field | Some tags -> column ~gap:0 [field; tags] in let send_button = match on_send with | None -> [] @@ -281,10 +326,16 @@ let composer_view (context : Lui_ui.ui_context) model_source send : t = composer_asset_view context asset_source send)) ~attachments_visible_signal: (Signal.map View_base.composer_assets_present_ model_source) + ~tag_strip:(composer_tag_chips model_source send) ~actions: [ stack [composer_attachment_button context send]; stack [composer_task_status_button context model_source send]; + stack [button ~icon:(`app "toolbar-tag") ~variant:`ghost + ~width:(if Lui_ui.host context = KotlinHost then 48 else 32) ~height:44 + ~label:"Add tags" ~accessibility_identifier:"button.composer.tags" + ~disabled_signal:(Signal.map (fun (model : Model.logseq_model) -> model.composer_tags_loading) model_source) + ~on_press:(press send Model.OpenComposerTags) []]; ] ~send_icon:(if Lui_ui.host context = KotlinHost then `send else `app "arrow-up") @@ -296,6 +347,7 @@ let composer_view (context : Lui_ui.ui_context) model_source send : t = ~on_send:(press send Model.SendComposer) ~on_press:(press send Model.FocusComposer) ()); + composer_tag_picker model_source send; if_ ~test:(Signal.map View_base.composer_collapsed_ model_source) (collapsed_composer context send); diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index c954ece3..956773a8 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -795,7 +795,7 @@ let outliner_autocomplete_row (context : Lui_ui.ui_context) | Some group -> text ~value:group ~font_size:"12" ~foreground:"muted-foreground" ~padding_horizontal:10 ~padding_vertical:5 ~accessibility_identifier:("outliner.autocomplete.group." ^ group) []) candidate_source; - View_base.with_label_signal + row ~height:44 ~grow:0.0 ~cross:`center [View_base.with_label_signal (Signal.map View_base.outliner_autocomplete_label candidate_source) (View_base.with_string_prop_signal AccessibilityIdentifier (Signal.map View_base.outliner_autocomplete_identifier candidate_source) @@ -803,7 +803,7 @@ let outliner_autocomplete_row (context : Lui_ui.ui_context) ~text_signal: (Signal.map View_base.outliner_autocomplete_label candidate_source) ~variant:`ghost - ~grow:0.0 + ~grow:1.0 ~height:44 ~padding_horizontal:10 ~background:"autocomplete-row-background" ~foreground:"foreground" ~corner_radius:8 ~text_alignment:`start @@ -814,7 +814,7 @@ let outliner_autocomplete_row (context : Lui_ui.ui_context) (send (Model.ChooseOutlinerAutocomplete (Signal.sample candidate_source : Model.outliner_autocomplete_candidate).candidate_value))) - []))] + []))]] let outliner_autocomplete_bar (context : Lui_ui.ui_context) model_source send : t = let kind = function @@ -1082,7 +1082,8 @@ let placeholder_block context model_source (value : View_rows.placeholder) send Lui_ui.bool_property mount_context node PressEnabled true; register_press mount_context node (fun _ -> ignore (send (Model.AddRootBlock value.parent_uuid))); node in - let placeholder = if_ ~test:(Signal.map (fun (current : Model.logseq_model) -> current.outliner_editing = None) model_source) placeholder in + let placeholder = box ~height:28 [ + if_ ~test:(Signal.map (fun (current : Model.logseq_model) -> current.outliner_editing = None) model_source) placeholder] in match value.journal_heading with | None -> box ~padding_horizontal:8 [placeholder] | Some (title, _, divider) -> diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index b43832fb..b41efb82 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -588,22 +588,35 @@ let collection ~render_block model_source raw_source send : t = List.find_opt (fun property -> jfield "attr" property = `String attr) (jlist (jfield "properties" row)) |> Option.value ~default:(`Assoc ["text", `String ""; "attr", `String attr; "raw", `Null; "editable", jfield "editable" (view_column view attr)]) in + let table_cells view_id row_id cells = + let rec separated = function + | [] -> [] + | [(_, cell)] -> [cell] + | (_, cell) :: ((next_attr, _) :: _ as rest) -> + cell :: separator ~orientation:`vertical + ~accessibility_identifier:("view.cell.separator." ^ view_id ^ "." ^ row_id ^ "." ^ next_attr) [] :: separated rest + in separated cells in let table_row item_source : t = - dyn (fun (view, item) -> row ~gap:0 ~cross:`stretch (List.map (fun col -> let attr = jtext (jfield "attr" col) in + dyn (fun (view, item) -> row ~gap:0 ~cross:`stretch (table_cells view.id + (Option.value ~default:(string_of_int item.index) item.uuid) + (List.map (fun col -> let attr = jtext (jfield "attr" col) in attr, if String.starts_with ~prefix:"query.result/" attr then let index = int_of_string (String.sub attr 13 (String.length attr - 13)) in let values = jlist (jfield "cells" item.json) in - text ~value:(Option.fold ~none:"" ~some:text_value (List.nth_opt values index)) ~width:170 ~min_height:48 ~padding:12 + text ~value:(Option.fold ~none:"" ~some:text_value (List.nth_opt values index)) ~style_class:"single-line" ~width:170 ~min_height:48 ~padding:12 ~accessibility_identifier:("view.scalar." ^ view.id ^ "." ^ string_of_int item.index ^ "." ^ string_of_int index) [] else let property = cell view item attr in let value = jtext (jfield "text" property) in let label = jtext (jfield "title" col) ^ ": " ^ (if value = "" then "Empty" else value) in - button ~text:value ~label ~text_alignment:`start ~variant:`ghost ~style_class:"single-line" - ~width:(if attr = "block/title" then 260 else 170) ~min_height:48 ~corner_radius:0 ~padding_horizontal:12 - ~disabled:(not (jbool (jfield "editable" property))) - ~accessibility_identifier:("view.cell." ^ view.id ^ "." ^ Option.value ~default:"" item.uuid ^ "." ^ attr) - ~on_press:(fun _ -> edit item property) []) (choices view))) (own (Signal.map2 (fun view item -> view, item) view_source item_source)) in + let identifier = "view.cell." ^ view.id ^ "." ^ Option.value ~default:"" item.uuid ^ "." ^ attr in + let width = if attr = "block/title" then 260 else 170 in + if jbool (jfield "editable" property) then + button ~text:value ~label ~text_alignment:`start ~variant:`ghost ~style_class:"single-line" + ~width ~min_height:48 ~corner_radius:0 ~padding_horizontal:12 + ~accessibility_identifier:identifier ~on_press:(fun _ -> edit item property) [] + else text ~value ~text_alignment:`start ~style_class:"single-line" ~width ~min_height:48 ~padding:12 + ~accessibility_identifier:identifier []) (choices view)))) (own (Signal.map2 (fun view item -> view, item) view_source item_source)) in let gallery_card item_source : t = dyn (fun (view, item) -> column ~gap:10 ~padding:16 ~background:"secondary" ~border_color:"border" ~border_width:1 ~corner_radius:12 ~accessibility_identifier:("view.card." ^ view.id ^ "." ^ Option.value ~default:"" item.uuid) [ @@ -666,8 +679,9 @@ let collection ~render_block model_source raw_source send : t = (dyn (fun current -> match current.typ with | "table" -> scroll ~orientation:`horizontal ~accessibility_identifier:("view.table.scroll." ^ view.id) [ column ~gap:0 ~cross:`stretch ~border_color:"border" ~border_width:1 ~corner_radius:10 [ - row ~gap:0 (List.map (fun col -> text ~value:(jtext (jfield "title" col)) ~style_class:"subheadline single-line" - ~width:(if jfield "attr" col = `String "block/title" then 260 else 170) ~padding:12 ~background:"secondary" []) (choices current)); + row ~gap:0 ~cross:`stretch (table_cells view.id "header" (List.map (fun col -> jtext (jfield "attr" col), + text ~value:(jtext (jfield "title" col)) ~style_class:"subheadline single-line" + ~width:(if jfield "attr" col = `String "block/title" then 260 else 170) ~min_height:48 ~padding:12 ~background:"secondary" []) (choices current))); separator []; keyed ~source:entries ~key:(fun item -> item.index) ~cmp:Int.compare ~mount:(fun source -> column ~gap:0 [table_row source; separator []])]] | "gallery" -> dyn (fun columns -> grid ~columns ~gap:12 ~accessibility_identifier:("view.gallery." ^ view.id) [ keyed ~source:entries ~key:(fun item -> item.index) ~cmp:Int.compare ~mount:gallery_card]) diff --git a/shared/src/logseq/view_rows.ml b/shared/src/logseq/view_rows.ml index a8d20745..50c4f9d0 100644 --- a/shared/src/logseq/view_rows.ml +++ b/shared/src/logseq/view_rows.ml @@ -117,8 +117,7 @@ let windowed_keyed ?scroll_target ?first_section ?section_titles ?reset_key metadata_base request) in let visible = own (Signal.map (fun (items, start, length) -> let visible = Array.sub items start length |> Array.to_list in - if Lui_ui.host context <> Lui_protocol.KotlinHost then visible - else match Array.find_index is_pinned items with + match Array.find_index is_pinned items with | Some index when index < start || index >= start + length -> visible @ [items.(index)] | _ -> visible) window) in ignore (Signal.own context.ui_scope (Signal.observe visible on_window)); @@ -195,7 +194,7 @@ let needs_placeholder rows = match List.rev rows with | [] -> true | last :: _ -> let title = if last.is_editing then last.editing_title else last.value.row_title in - not (last.value.row_loaded && String_kit.is_blank title && not last.value.has_children) + not ((last.value.row_loaded || last.value.row_cached) && String_kit.is_blank title && not last.value.has_children) let outline_entries rows = List.map (fun row -> Outline_entry row) rows @@ -221,7 +220,7 @@ let journal_entries rows (current : Model.logseq_model) = let entries = List.mapi (fun index (page, title, day, rows) -> let outline = outline_entries rows in match title, day with - | Some title, Some day when (current.outliner_editing = None && needs_placeholder rows) || rows = [] -> + | Some title, Some day when needs_placeholder rows || rows = [] -> outline @ [Placeholder_entry {parent_uuid = page; journal_heading = if rows = [] then Some (title, day, index > 0) else None}] | _ -> outline) sections in @@ -238,7 +237,7 @@ let page_entries rows current = && View_base.page_content_ready_ current && parent <> "" && (match Model.active_page current with | Some page -> String.lowercase_ascii page.title <> "library" | None -> true) in - if allowed && current.outliner_editing = None && needs_placeholder rows then + if allowed && needs_placeholder rows then outline @ [Placeholder_entry {parent_uuid = parent; journal_heading = None}] else outline in List.rev_append (List.rev outline) @@ -283,8 +282,8 @@ let unloaded_entry_id = function | _ -> None let page_entry_ready = function - | Outline_entry row -> row.value.Model.row_loaded || row.value.row_title <> "" || row.is_editing - | Reference_entry (Reference_row (_, row)) -> row.Model.row_loaded || row.row_title <> "" + | Outline_entry row -> row.value.Model.row_loaded || row.value.row_cached || row.value.row_title <> "" || row.is_editing + | Reference_entry (Reference_row (_, row)) -> row.Model.row_loaded || row.row_cached || row.row_title <> "" | _ -> true let page_entry_editor_ = function diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 71133ea5..b2e637bf 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -331,7 +331,8 @@ let node_projection uuid page_uuid title related_rows linked_reference_rows = let empty_core_projection () = { - Model.graph_name = None; + Model.tag_choices_update = None; + graph_name = None; selected_graph_id = None; graphs = []; is_graph_encrypted = false; @@ -426,6 +427,7 @@ let journal_outline_row uuid page_id title journal_title journal_day depth = { Model.row_uuid = uuid; row_loaded = true; + row_cached = false; row_task_dates_json = ""; row_list_number = None; row_display_type = None; row_title = title; @@ -2038,7 +2040,7 @@ let authentication_screen_uses_the_product_name_on_every_host () = let outliner_editor_extension_contract_is_pinned () = check_eq ~msg:"the native editor registry must match the LG wire schema" (Lui_extension.fingerprint (View.outliner_editor_schema ())) - "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" let outliner_block_content_extension_contract_is_pinned () = check_eq ~msg:"the rich block renderer must match the LG wire schema" @@ -2762,6 +2764,7 @@ let outline_row uuid title = { Model.row_uuid = uuid; row_loaded = true; + row_cached = false; row_task_dates_json = ""; row_list_number = None; row_display_type = None; row_title = title; @@ -4690,7 +4693,7 @@ let composer_matches_the_main_branch_expand_draft_and_send_contract () = "surface.composer.root" in let expanded = - parent_with_child_identifier renderer expanded_composer "field.composer" + parent_with_child_identifier renderer expanded_composer "row.composer.controls" in let field = descendant_with_identifier renderer expanded "field.composer" @@ -4751,7 +4754,7 @@ let composer_matches_the_main_branch_expand_draft_and_send_contract () = (property_int renderer controls Lui_protocol.HeightValue) 44; check_eq ~msg:"main's controls include a flexible trailing spacer" - (List.length control_children) 4; + (List.length control_children) 5; check_eq ~msg:"the composer spacer remains directly testable" (property_string renderer control_spacer Lui_protocol.AccessibilityIdentifier) @@ -8257,6 +8260,32 @@ let ten_thousand_blocks_mount_only_the_requested_window () = check ~msg:"the window stays bounded after reverse scrolling" (count_rows () <= 32) ) ["root"; "node"; "tag"; "mixed"; "journals"] +let scrolling_keeps_the_active_editor_outside_the_visible_window () = + List.iter (fun backend -> + let application = App.create (backend ()) in + start application; + let rows = List.init 10_000 (fun index -> + { (outline_row ("retained-" ^ string_of_int index) "Block") with Model.page_id = "page" }) in + let editing = { Model.editing_uuid = "retained-8"; editing_title = "Unsaved draft"; caret_utf16_offset = 4 } in + send application (apply_core_snapshot None (empty_sidebar_projection ()) [] false "" [] [] + (Some editing) None [] [] rows false []); + flush application; + let renderer = Lui_app.runtime application in + let root = main_root renderer application in + let window = descendant_with_identifier renderer root "outliner.window" in + let editor = extension_node application "outliner-editor" in + check ~msg:"the fixture mounts a real editor" (editor <> -1); + List.iter (fun first -> + dispatch application (Lui_protocol.VisibleRange (window, first, first + 19)); + flush application; + check_eq ~msg:"scrolling must not dispose and recreate the active editor" + (extension_node application "outliner-editor") editor; + check_eq ~msg:"the retained editor keeps its unsaved text" + (extension_property application editor "title") (Some (Lui_protocol.StringValue "Unsaved draft")); + check ~msg:"only the window and one pinned editor may stay mounted" + (List.length (children renderer window) <= 33)) [100; 9_980; 0] + ) [ios_backend; kotlin_backend] + let kotlin_large_pages_mount_only_the_requested_window () = let application = App.create (kotlin_backend ()) in start application; @@ -8326,6 +8355,25 @@ let distant_outline_contents_are_loaded_without_replacing_the_page () = (descendant_with_identifier renderer (root ()) "outliner.block.content-9999" <> -1) ) [false; true] +let empty_cached_rows_remain_renderable_during_hydration () = + let rows = List.init 96 (fun index -> outline_row ("empty-cache-" ^ string_of_int index) + (if index mod 2 = 0 then "" else "Block")) in + let projection = { (empty_core_projection ()) with + Model.graph_name = Some "Work"; selected_graph_id = Some "graph"; outliner_rows = rows } in + let current = Model.update (Model.initial ()) (Model.ApplyCoreSnapshot projection) in + let shells = List.mapi (fun index row -> if index < 32 then row else + { row with Model.row_title = ""; row_loaded = false }) rows in + let refreshed = Model.update current (Model.ApplyCoreSnapshot { projection with Model.outliner_rows = shells }) in + let cached = List.nth (View_rows.retained_outliner_rows refreshed) 40 in + check ~msg:"an already loaded empty block must not become a loading skeleton" + (View_rows.page_entry_ready (View_rows.Outline_entry cached)); + check ~msg:"cached empty blocks still request authoritative content" + (View_rows.unloaded_entry_id (View_rows.Outline_entry cached) = Some "empty-cache-40"); + let fresh = Model.update (Model.initial ()) (Model.ApplyCoreSnapshot { projection with Model.outliner_rows = shells }) in + let unavailable = List.nth (View_rows.retained_outliner_rows fresh) 40 in + check ~msg:"a genuinely unloaded block still uses a loading skeleton" + (not (View_rows.page_entry_ready (View_rows.Outline_entry unavailable))) + let returning_to_a_scrolled_page_keeps_hydrated_rows_mounted () = let application = App.create (ios_backend ()) in let rows = List.init 96 (fun index -> outline_row @@ -9495,8 +9543,8 @@ let kotlin_outliner_autocomplete_stacks_above_the_editor_toolbar () = "stretch"; check_eq ~msg:"suggestions start below their shared header" (property_string renderer autocomplete_column Lui_protocol.MainAlignment) "start"; - check_eq ~msg:"candidate buttons keep their fixed height inside grouped suggestions" - (property_float renderer candidate_button Lui_protocol.GrowValue) 0.0; + check_eq ~msg:"candidate buttons fill the bounded horizontal suggestion row" + (property_float renderer candidate_button Lui_protocol.GrowValue) 1.0; check_eq ~msg:"Kotlin does not flex rows along an unbounded scroll axis" (property_float renderer @@ -10455,8 +10503,10 @@ let cases = case "core-snapshot-renders-keyed-outliner-rows" core_snapshot_renders_keyed_outliner_rows; case "saving-nested-block-preserves-native-row-indentation" saving_nested_block_preserves_native_row_indentation; case "reselecting-the-current-page-preserves-loaded-rows" reselecting_the_current_page_preserves_loaded_rows; + case "empty-cached-rows-remain-renderable-during-hydration" empty_cached_rows_remain_renderable_during_hydration; case "large-node-pages-virtualize-each-nested-block" large_node_pages_virtualize_each_nested_block; case "scrolling-prefetches-content-before-mounting-the-next-window" scrolling_prefetches_content_before_mounting_the_next_window; + case "scrolling-keeps-the-active-editor-outside-the-visible-window" scrolling_keeps_the_active_editor_outside_the_visible_window; case "kotlin-large-pages-mount-only-the-requested-window" kotlin_large_pages_mount_only_the_requested_window; case "ten-thousand-blocks-mount-only-the-requested-window" ten_thousand_blocks_mount_only_the_requested_window; case "distant-outline-contents-load-without-replacing-the-page" distant_outline_contents_are_loaded_without_replacing_the_page; diff --git a/shared/test/logseq/block_clipboard_test.ml b/shared/test/logseq/block_clipboard_test.ml new file mode 100644 index 00000000..44fdbe56 --- /dev/null +++ b/shared/test/logseq/block_clipboard_test.ml @@ -0,0 +1,245 @@ +open Test_util +module D = Datascript +module J = Yojson.Basic +module R = Result_view_test +module Q = Query_block_test +let member = Q.member +let text = R.text +let child = "81000000-0000-4000-8000-000000000001" +let grandchild = "81000000-0000-4000-8000-000000000002" +let target = R.gamma +let event session fields = Rpc_test.dispatch_json session "outlinerEvent" (J.to_string (`Assoc fields)) +let success result = check_eq ~msg:(J.to_string result) (member "ok" result) (`Bool true) +let block_event session typ uuid = success (event session ["type", `String typ; "uuid", `String uuid]) +let value runtime uuid attr = Editor_command_test.value runtime uuid attr +let rec semantic db = function + | D.Ref eid -> (match Graph_read.uuid_for_eid db eid with Some uuid -> Pending_ops.Ref_uuid uuid + | None -> (match Graph_read.value db eid "db/ident" with Some (D.Keyword ident) -> Pending_ops.Ref_ident ident | _ -> fail "Missing ref")) + | D.Set xs -> Pending_ops.Set_value (List.map (semantic db) xs) + | v -> (match Pending_ops.semantic_value_from_datascript v with Ok v -> v | Error message -> fail message) +let title runtime uuid = match value runtime uuid "block/title" with Some (D.String s) -> s | _ -> "" +let insert runtime uuid parent order content = check_ok (Graph_runtime.stage runtime + {Pending_ops.operation_id = Graph_runtime.fresh_uuid (); base_t = 42; state = Pending_ops.Queued; + intent = Pending_ops.Insert_block {uuid; page_uuid = Q.page; parent_uuid = parent; order; title = content; created_at = 1234}}) +let set runtime uuid attr v = check_ok (Graph_runtime.stage runtime + {Pending_ops.operation_id = Graph_runtime.fresh_uuid (); base_t = 42; state = Pending_ops.Queued; + intent = Pending_ops.Set_property {uuid; attr; expected = Option.map (semantic (Graph_runtime.db runtime)) (value runtime uuid attr); value = Some v}}) +let rename runtime uuid content = check_ok (Graph_runtime.stage runtime + {Pending_ops.operation_id = Graph_runtime.fresh_uuid (); base_t = 42; state = Pending_ops.Queued; + intent = Pending_ops.Save_title {uuid; expected_title = title runtime uuid; title = content}}) +let children runtime parent = Graph_runtime.blocks_for_page runtime Q.page + |> List.filter (fun (b : Cache_model.block) -> b.parent_id = Some parent) + |> List.sort (fun (a : Cache_model.block) b -> compare a.order b.order) +let ids blocks = List.map (fun (b : Cache_model.block) -> b.uuid) blocks +let subtree runtime uuid = + let rec collect uuid = uuid :: List.concat_map (fun (b : Cache_model.block) -> collect b.uuid) (children runtime uuid) in + collect uuid +let copy session selected = + (match selected with [] -> assert false | first :: rest -> + block_event session "longPressBlock" first; + List.iter (block_event session "tapBlock") rest); + let result = event session ["type", `String "toolbar"; "action", `String "copy"] in + success result; + match List.find_opt (fun item -> member "type" item = `String "setClipboardBlocks") + (Q.items "outlinerCommands" (member "result" result)) with + | Some command -> J.from_string (text (member "text" command)) + | None -> fail "Copy must export native rich blocks and Markdown, not flatten selected titles" +let paste ?(rich = "") ?(start = 0) ?(length = 0) session uuid before plain = + block_event session "tapBlock" uuid; + event session ["type", `String "pasteContent"; "uuid", `String uuid; + "beforeTitle", `String before; "selectionStart", `Int start; "selectionLength", `Int length; + "plainText", `String plain; "blocksEDN", `String rich] +let paste_copy session runtime copied = paste ~rich:(text (member "blocksEDN" copied)) + session target (title runtime target) (text (member "plainText" copied)) + +let copies_nested_snapshots_without_moving_sources () = R.with_runtime (fun session runtime conn -> + insert runtime child Q.alpha "a0" "Nested\nsecond line"; + insert runtime grandchild child "a0" "Deep child"; + set runtime child "logseq.property/collapsed?" (Pending_ops.Bool_value true); + let original = subtree runtime Q.alpha in + let copied = copy session [Q.alpha; child; Q.beta] in + let markdown = text (member "plainText" copied) in + check ~msg:"Markdown export preserves inline formatting" (String_kit.index_of ~sub:"- Alpha **result**" ~start:0 markdown <> None); + check ~msg:"plain text export retains nested Markdown structure" (String_kit.index_of ~sub:"\t- Nested\n\t second line" ~start:0 markdown <> None); + check ~msg:"plain text export includes collapsed descendants" (String_kit.index_of ~sub:"Deep child" ~start:0 markdown <> None); + rename runtime child "Changed after Copy"; + rename runtime target ""; + let result = paste_copy session runtime copied in success result; + check_eq ~msg:"the first pasted root replaces the empty target identity" (title runtime target) (title runtime Q.alpha); + let pasted_children = children runtime target in + check_eq (List.length pasted_children) 1; + let pasted_child = List.hd pasted_children in + check_eq ~msg:"clipboard is a snapshot, not a live lookup" pasted_child.title "Nested\nsecond line"; + check ~msg:"copy creates new child identities" (not (List.mem pasted_child.uuid original)); + check_eq (List.map (fun (b : Cache_model.block) -> b.title) (children runtime pasted_child.uuid)) ["Deep child"]; + check_eq ~msg:"the original tree is never reparented" (subtree runtime Q.alpha) original; + check_eq (title runtime child) "Changed after Copy"; + let copied_roots = children runtime Q.page |> List.filter (fun (b : Cache_model.block) -> b.uuid = target || b.title = title runtime Q.beta) in + check_eq ~msg:"selected descendants are not duplicated as roots" (List.length copied_roots) 3; + let restored = Graph_runtime.create runtime.path 42 conn {Graph_runtime.default_options with auto_create_today = false} in + check_eq ~msg:"nested paste survives pending-log restart" (subtree restored target) (subtree runtime target); + Composer_tag_test.sync_all runtime conn) + +let preserves_typed_properties_and_references () = R.with_runtime (fun session runtime _ -> + insert runtime child Q.alpha "a0" "Value child"; + let db = Graph_runtime.db runtime in + (* An existing ref property pointing inside the copied tree must follow the copy. *) + set runtime Q.alpha R.summary (Pending_ops.Ref_uuid child); + set runtime Q.alpha "logseq.property/status" (Pending_ops.Ref_ident "logseq.property/status.todo"); + rename runtime child ("Ref [[" ^ Q.alpha ^ "]]" ); + let copied = copy session [Q.alpha] in + rename runtime target ""; + success (paste_copy session runtime copied); + let copied_child = List.hd (children runtime target) in + check_eq ~msg:"property refs within the tree are remapped" (value runtime target R.summary) + (Some (D.Ref (Option.get (D.entid (Graph_runtime.db runtime) "block/uuid" (D.Uuid copied_child.uuid))))); + List.iter (fun attr -> + check_eq ~msg:("typed value retained: " ^ attr) + (Option.map (semantic db) (value runtime Q.alpha attr)) + (Option.map (semantic (Graph_runtime.db runtime)) (value runtime target attr))) + [R.score; R.ready; "block/tags"; "logseq.property/status"]; + check_eq ~msg:"normal copied inline references continue to refer to their sources, like master" + copied_child.title ("Ref [[" ^ Q.alpha ^ "]]")) + +let pasted_roots_keep_selection_order () = R.with_runtime (fun session runtime _ -> + let copied = copy session [Q.beta; Q.alpha] in + success (paste_copy session runtime copied); + let siblings = children runtime Q.page in + let rec after = function [] -> [] | (b : Cache_model.block) :: rest -> if b.uuid = target then rest else after rest in + let added = after siblings in + check_eq ~msg:"copy order follows the outliner rather than tap order" (List.map (fun (b : Cache_model.block) -> b.title) added) + [title runtime Q.alpha; title runtime Q.beta]; + check_eq ~msg:"a nonempty paste target is preserved" (title runtime target) "Gamma result"; + let editing = Outliner_state.editing_uuid (Session_types.state session).outliner_state in + check_eq ~msg:"the last inserted block receives editing focus" editing (Some (List.hd (List.rev added)).uuid)) + +let pastes_into_parent_before_existing_children () = R.with_runtime (fun session runtime _ -> + insert runtime child target "a0" "Existing child"; + let copied = copy session [Q.alpha; Q.beta] in + success (paste_copy session runtime copied); + let result = children runtime target in + check_eq ~msg:"a target with children receives pasted blocks as first children" + (List.map (fun (b : Cache_model.block) -> b.title) result) + [title runtime Q.alpha; title runtime Q.beta; "Existing child"]; + check_eq (List.hd (List.rev result)).uuid child) + +let plain_text_preserves_inline_and_paragraph_semantics () = + List.iter (fun (plain, expected, start, length, caret) -> R.with_runtime (fun session runtime _ -> + rename runtime target "A😀Z"; + let before = List.length (Graph_runtime.blocks_for_page runtime Q.page) in + success (paste ~start ~length session target "A😀Z" plain); + check_eq (title runtime target) expected; + check_eq ~msg:"ordinary single-newline text is not split into blocks" (List.length (Graph_runtime.blocks_for_page runtime Q.page)) before; + let state = (Session_types.state session).outliner_state in + check_eq (Option.map (fun (d : Outliner_state.editor_draft) -> d.caret) state.editing) (Some caret); + check_eq ~msg:"paste markers do not open completion" state.autocomplete None)) + ["hello/", "Ahello/Z", 1, 2, 7; "one\r\ntwo", "Aone\ntwoZ", 1, 2, 8; + "[[", "A😀[[Z", 3, 0, 5; "#", "#A😀Z", 0, 0, 1]; + R.with_runtime (fun session runtime _ -> + rename runtime target ""; + success (paste session target "" "First\nline\n\nSecond paragraph"); + check_eq (title runtime target) "First\nline"; + let siblings = children runtime Q.page in + check_eq (List.hd (List.rev siblings)).title "Second paragraph") + +let markdown_paste_preserves_hierarchy_and_code () = R.with_runtime (fun session runtime _ -> + rename runtime target ""; + success (paste session target "" "- Parent\n - Child\n - Grandchild\n- Tail\n continued"); + check_eq (title runtime target) "Parent"; + let nested = List.hd (children runtime target) in + check_eq nested.title "Child"; + check_eq (List.hd (children runtime nested.uuid)).title "Grandchild"; + check_eq (List.hd (List.rev (children runtime Q.page))).title "Tail\ncontinued"); + R.with_runtime (fun session runtime _ -> + rename runtime target ""; + success (paste session target "" "```clojure\n- literal\n\n# still code\n```\n- After code"); + check_eq (title runtime target) "- literal\n\n# still code"; + check_eq (value runtime target "logseq.property.node/display-type") (Some (D.Keyword "code")); + check_eq (List.hd (List.rev (children runtime Q.page))).title "After code") + +let code_editors_paste_literal_text () = R.with_runtime (fun session runtime _ -> + set runtime target "logseq.property.node/display-type" (Pending_ops.Keyword_value "code"); + rename runtime target ""; + let before = List.length (Graph_runtime.blocks_for_page runtime Q.page) in + success (paste session target "" "- First\n\n- Second"); + check_eq (title runtime target) "- First\n\n- Second"; + check_eq (List.length (Graph_runtime.blocks_for_page runtime Q.page)) before) + +let empty_targets_with_children_match_master () = + List.iter (fun kind -> R.with_runtime (fun session runtime _ -> + insert runtime child target "a0" "Keep child"; + rename runtime target ""; + if kind = "nested" then insert runtime grandchild Q.alpha "a0" "Nested copy"; + let copied = copy session (if kind = "single" then [Q.alpha] else [Q.alpha; Q.beta]) in + success (paste_copy session runtime copied); + if kind = "single" then begin + check_eq (title runtime target) (title runtime Q.alpha); + check_eq (ids (children runtime target)) [child] + end else if kind = "flat" then begin + check_eq (title runtime target) ""; + check_eq (List.map (fun (b : Cache_model.block) -> b.title) (children runtime target)) + [title runtime Q.alpha; title runtime Q.beta; "Keep child"] + end else begin + check_eq ~msg:"nested clipboard inserts beside an empty parent without stealing its children" (title runtime target) ""; + check_eq (ids (children runtime target)) [child]; + let reversed = List.rev (children runtime Q.page) in + check_eq (List.hd reversed).uuid target; + check_eq (List.nth reversed 1).title (title runtime Q.beta); + let root = List.nth reversed 2 in + check_eq root.title (title runtime Q.alpha); + check_eq (List.hd (children runtime root.uuid)).title "Nested copy" + end)) ["single"; "flat"; "nested"] + +let selected_links_and_wrapped_references_match_master () = + List.iter (fun (before, start, length, plain, expected) -> R.with_runtime (fun session runtime _ -> + rename runtime target before; + success (paste ~start ~length session target before plain); + check_eq (title runtime target) expected)) + ["A😀Z", 1, 2, "https://example.com", "A[😀](https://example.com)Z"; + "https://old.example", 0, 19, "https://new.example", "https://new.example"; + "[Label](https://old.example)", 8, 19, "https://new.example", "[Label](https://new.example)"; + "(())", 2, 0, "((" ^ Q.alpha ^ "))", "((" ^ Q.alpha ^ "))"] + +let invalid_and_foreign_clipboard_use_plain_text () = R.with_runtime (fun session runtime _ -> + let copied = copy session [Q.alpha; Q.beta] in + let rich = text (member "blocksEDN" copied) in + let foreign = Str.global_replace (Str.regexp_string "plain-1") "another-graph" rich in + List.iter (fun data -> + block_event session "cancelEditing" target; + rename runtime target ""; + success (paste ~rich:data session target "" "Plain fallback"); + check_eq (title runtime target) "Plain fallback") [foreign; "{broken"; "{:blocks [true]}"]) + +let invalid_tree_and_failed_storage_are_atomic () = R.with_runtime (fun session runtime _ -> + let copied = copy session [Q.alpha; Q.beta] in + let before = Graph_runtime.pending_operations runtime in + let cyclic = Printf.sprintf "{:graph \"plain-1\" :op :copy :blocks [{:block/uuid #uuid \"%s\" :block/title \"Cycle\" :block/parent [:block/uuid #uuid \"%s\"]}]}" Q.alpha Q.alpha in + let response = paste ~rich:cyclic session target (title runtime target) "Cycle" in + check_eq (member "ok" response) (`Bool false); + check_eq ~msg:"invalid clipboard leaves graph and pending operations unchanged" (Graph_runtime.pending_operations runtime) before; + check_eq (title runtime target) "Gamma result"; + let failing = Rpc_session.create_session {Rpc_session.default_options with + load_graph_catalog = Some (fun () -> Some Rpc_test.plain_graph_catalog); + sync_cursor = Some (fun () -> Some 42); + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); + graph_blocks = Some (fun () -> Some (Graph_runtime.blocks_for_page runtime Q.page)); + prepare_operation = Some (Graph_runtime.prepare_sync runtime); + stage_operation = Some (fun _ -> Error "Storage failed")} |> Rpc_test.configure_plain_session in + let response = paste_copy failing runtime copied in + check_eq (member "ok" response) (`Bool false); + check_eq (Graph_runtime.pending_operations runtime) before; + check_eq ~msg:"failed insertion keeps the target draft available" (Outliner_state.editing_title (Session_types.state failing).outliner_state) (Some "Gamma result")) + +let cases = [ + case "nested copy is a snapshot and syncs without moving sources" copies_nested_snapshots_without_moving_sources; + case "typed properties and copied references survive" preserves_typed_properties_and_references; + case "paste preserves outliner order and focuses the last block" pasted_roots_keep_selection_order; + case "parent targets receive first children" pastes_into_parent_before_existing_children; + case "plain text keeps inline and paragraph semantics" plain_text_preserves_inline_and_paragraph_semantics; + case "Markdown keeps hierarchy and literal fenced code" markdown_paste_preserves_hierarchy_and_code; + case "code editors paste literal text" code_editors_paste_literal_text; + case "empty targets with children keep master placement" empty_targets_with_children_match_master; + case "selected links and wrapped refs keep master semantics" selected_links_and_wrapped_references_match_master; + case "foreign and malformed clipboard use plain text" invalid_and_foreign_clipboard_use_plain_text; + case "invalid trees and failed storage have no partial writes" invalid_tree_and_failed_storage_are_atomic; +] diff --git a/shared/test/logseq/composer_tag_test.ml b/shared/test/logseq/composer_tag_test.ml new file mode 100644 index 00000000..1be5c9f9 --- /dev/null +++ b/shared/test/logseq/composer_tag_test.ml @@ -0,0 +1,246 @@ +open Test_util +module J = Yojson.Basic +module Q = Query_block_test +module R = Result_view_test +let member = Q.member +let items = Q.items +let text = R.text +let captured = "80000000-0000-4000-8000-000000000001" +let tags selected created = `Assoc ["selectedValues", `List (List.map (fun value -> `String value) selected); + "newValues", `List (List.map (fun value -> `String value) created)] +let send session ?(uuid = captured) ?(body = "Captured note") selection = + Rpc_test.dispatch_json session "sendTagged" (J.to_string (`Assoc ["uuid", `String uuid; + "text", `String body; "now", `Int 1792719000000; "tags", selection])) +let succeed response = check_eq ~msg:(J.to_string response) (member "ok" response) (`Bool true) +let tag_titles runtime uuid = + let db = Graph_runtime.db runtime in + match Datascript.entid db "block/uuid" (Datascript.Uuid uuid) with + | None -> [] + | Some eid -> Graph_read.entity_summaries (fun value -> Ok value) db eid "block/tags" + |> List.map (fun (tag : Cache_model.entity_summary) -> tag.title) |> List.sort String.compare + +let sync_all runtime conn = + List.iter (fun operation -> + let before = (Graph_runtime.state runtime).server_t in + let _, tx = match Graph_runtime.prepare_sync runtime operation with Ok value -> value | Error message -> fail message in + let journal = match Journal_sync.decode (J.to_string (`Assoc ["type", `String "tx/batch/ok"; + "t-before", `Int before; "t", `Int (before + 1); "txs", `List [`Assoc ["t", `Int (before + 1); + "tx-id", `String operation.Pending_ops.operation_id; "tx", `String tx]]])) with + | Ok value -> value | Error message -> fail message in + check_ok (Graph_runtime.stage runtime {operation with Pending_ops.state = Pending_ops.Accepted (before + 1)}); + let ids, changed = match Journal_sync.apply (fun value -> Ok value) conn before journal with + | Ok value -> value | Error message -> fail message in + Graph_runtime.rebase runtime journal.t ids changed) (Graph_runtime.pending_operations runtime); + check_eq ~msg:"tagged captures round-trip through confirmed sync" (Graph_runtime.pending_operations runtime) [] + +let fresh_journal_captures_preserve_the_note () = R.with_runtime (fun session runtime _ -> + succeed (Rpc_test.dispatch_json session "send" (J.to_string (`Assoc ["uuid", `String captured; + "text", `String "First capture"; "now", `Int 1792719000000]))); + check_eq ~msg:"creating a new journal preserves the captured text" + (Editor_command_test.value runtime captured "block/title") (Some (Datascript.String "First capture")); + let day = Cache_model.journal_day_for_ms 1792719000000 in + let page = Rpc_wire.journal_page_uuid day in + check_eq ~msg:"the journal page has its calendar title, not the captured note" + (Editor_command_test.value runtime page "block/title") (Some (Datascript.String (Rpc_wire.journal_day_title day)))) + +let capture_assigns_existing_and_new_tags () = R.with_runtime (fun session runtime conn -> + succeed (send session (tags [Q.project; Q.project] ["Mobile"; " mobile "])); + check_eq ~msg:"capture keeps the source text separate from selected tags" + (Editor_command_test.value runtime captured "block/title") (Some (Datascript.String "Captured note")); + check_eq ~msg:"existing and newly created tags are assigned once" (tag_titles runtime captured) ["Mobile"; "Project"]; + let same_name = "80000000-0000-4000-8000-000000000002" in + succeed (send session ~uuid:same_name (tags [] ["mobile"])); + check_eq (tag_titles runtime same_name) ["Mobile"]; + let restored = Graph_runtime.create runtime.path 42 conn {Graph_runtime.default_options with auto_create_today = false} in + check_eq ~msg:"pending tagged journal captures survive restart" + (Editor_command_test.value restored captured "block/title") (Some (Datascript.String "Captured note")); + check_eq (tag_titles restored captured) ["Mobile"; "Project"]; + sync_all runtime conn) + +let invalid_tags_do_not_capture_or_create () = R.with_runtime (fun session runtime _ -> + List.iter (fun selection -> + let before = List.length (Graph_runtime.pending_operations runtime) in + let response = send session selection in + check_eq ~msg:"reject invalid selected tags" (member "ok" response) (`Bool false); + check_eq ~msg:"invalid tags leave no partial capture or new tag" (List.length (Graph_runtime.pending_operations runtime)) before; + check_eq (Datascript.entid (Graph_runtime.db runtime) "block/uuid" (Datascript.Uuid captured)) None) + [tags [Q.alpha] ["Must not exist"]; tags ["missing"] []; tags [] [" "]]) + +let inline_tags_and_status_survive () = R.with_runtime (fun session runtime _ -> + let status = `Assoc ["uuid", `String "00000002-7975-1727-0000-000000000001"; + "ident", `String "logseq.property/status.todo"; "title", `String "Todo"] in + let response = Rpc_test.dispatch_json session "sendTagged" (J.to_string (`Assoc [ + "uuid", `String captured; "text", `String "Note #Project"; "now", `Int 1792719000000; + "status", status; "tags", tags [] ["Mobile"]])) in + succeed response; + check_eq ~msg:"selected tags preserve tags typed in the note" (tag_titles runtime captured) ["Mobile"; "Project"]; + check ~msg:"tagged tasks retain their status" (Editor_command_test.value runtime captured "logseq.property/status" <> None)) + +let assets_assign_tags_without_changing_the_filename () = R.with_runtime (fun session runtime conn -> + let payload = ["uuid", `String captured; "title", `String "Photo.png"; "now", `Int 1792719000000; + "assetType", `String "png"; "assetSize", `Int 12; "assetChecksum", `String "abc"; + "localPath", `String "/tmp/Photo.png"; "targetBlockId", `String Q.alpha; + "tags", tags [Q.project] ["Photos"]] in + succeed (Rpc_test.dispatch_json session "addAsset" (J.to_string (`Assoc payload))); + check_eq (Editor_command_test.value runtime captured "block/title") (Some (Datascript.String "Photo.png")); + check_eq (tag_titles runtime captured) ["Asset"; "Photos"; "Project"]; + (* Binary upload completion activates the original asset transaction for sync. *) + let asset = List.find (fun (operation : Pending_ops.pending_operation) -> + match operation.intent with Pending_ops.Create_asset _ -> true | _ -> false) (Pending_ops.list runtime.path) in + check_eq asset.state Pending_ops.Applied; + check_ok (Graph_runtime.stage runtime {asset with state = Pending_ops.Queued}); + sync_all runtime conn) + +let catalog_uses_graph_tags_without_writes () = R.with_runtime (fun session runtime _ -> + let before = Graph_runtime.pending_operations runtime in + let response = Rpc_test.dispatch_json session "composerTags" "" in + succeed response; + let choices = items "composerTagChoices" (member "result" response) in + check ~msg:"the picker offers graph tag objects" (List.exists (fun tag -> member "uuid" tag = `String Q.project) choices); + check ~msg:"ordinary blocks are not tag candidates" (not (List.exists (fun tag -> member "uuid" tag = `String Q.alpha) choices)); + check_eq ~msg:"opening a picker does not mutate the graph" (Graph_runtime.pending_operations runtime) before) + +let shared_picker_stages_and_removes_tags () = + List.iter (fun backend -> R.with_runtime (fun session runtime _ -> + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + let click id = let node = find id in check ~msg:("Missing control: " ^ id) (node <> -1); + App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in + App_test.send app Model.ExpandComposer; App_test.flush app; + let before = List.length (Graph_runtime.pending_operations runtime) in + let choices = Rpc_test.dispatch_json session "composerTags" "" |> J.to_string in + let projection = match Response_snapshot.decode_response choices with Ok value -> value | Error message -> fail message in + let open_tags () = click "button.composer.tags"; + App_test.send app (Model.ApplyCoreSnapshot projection); App_test.flush app in + open_tags (); + check ~msg:"tags reuse the shared property editor sheet" (find "view.edit.sheet.composer.tags" <> -1); + click ("view.edit.option.composer.tags." ^ Q.project); + click "view.edit.save.composer.tags"; + check ~msg:"selected tags appear as removable composer chips" (find ("button.composer.tag.remove." ^ Q.project) <> -1); + let saved = Model.ui_session (App.model app) in + let encoded_session = J.from_string (Native_bridge.encode_ui_session saved) in + check ~msg:"unsent tags persist with the UI session" (items "composerTags" encoded_session <> []); + click ("button.composer.tag.remove." ^ Q.project); + App_test.send app (Model.RestoreUISession saved); App_test.flush app; + check ~msg:"restoring the same graph recovers staged tags" (find ("button.composer.tag.remove." ^ Q.project) <> -1); + open_tags (); + let input = find "view.edit.value.composer.tags" in + App_test.dispatch app (Lui_protocol.TextChanged (input, "Mobile")); App_test.flush app; + click "view.edit.create.composer.tags"; + click "view.edit.save.composer.tags"; + check_eq ~msg:"staging tags never writes before Send" (List.length (Graph_runtime.pending_operations runtime)) before; + click ("button.composer.tag.remove." ^ Q.project); + check_eq ~msg:"remove only the chosen chip" (find ("button.composer.tag.remove." ^ Q.project)) (-1); + open_tags (); + click "view.edit.cancel.composer.tags"; + App_test.send app (Model.ChangeComposerDraft "Keep this note"); + App_test.send app Model.SendComposer; App_test.flush app; + let encoded = List.map (fun eff -> J.from_string (Native_bridge.encode_effect eff)) (App.model app).pending_effects in + let tagged = List.find_opt (fun eff -> member "kind" eff = `String "send-tagged") encoded in + check ~msg:"Send carries typed tag selection separately from text" (tagged <> None); + let eff = Option.get tagged in + let payload = J.from_string (text (member "text" eff)) in + check_eq (member "text" payload) (`String "Keep this note"); + check_eq (items "newValues" (member "tags" payload)) [`String "Mobile"]; + check_eq (items "selectedValues" (member "tags" payload)) []; + let effect_id = match member "id" eff with `Int id -> id | _ -> fail "Missing effect id" in + check_eq ~msg:"sending clears staged tags for the next capture" + (items "composerTags" (J.from_string (Native_bridge.encode_ui_session (Model.ui_session (App.model app))))) []; + App_test.send app (Model.DequeueEffect effect_id); + App_test.send app (Model.ResolveEffect (effect_id, false, "Try again")); App_test.flush app; + check_eq ~msg:"failed tagged captures recover the draft" (App.model app).composer_draft "Keep this note"; + check ~msg:"failed tagged captures recover tags" + (items "composerTags" (J.from_string (Native_bridge.encode_ui_session (Model.ui_session (App.model app)))) <> []); + let old_choices = projection in + let changed = match J.from_string (Rpc_session.snapshot_visible session) with + | `Assoc fields -> (match member "result" (`Assoc fields) with + | `Assoc result -> `Assoc (("result", `Assoc (("selectedGraphId", `String "other") :: List.remove_assoc "selectedGraphId" result)) :: List.remove_assoc "result" fields) + | _ -> fail "Missing result") + | _ -> fail "Missing envelope" in + let changed = match Response_snapshot.decode_response (J.to_string changed) with Ok value -> value | Error message -> fail message in + App_test.send app (Model.ApplyCoreSnapshot changed); App_test.flush app; + check_eq ~msg:"switching graphs clears staged tag references" + (items "composerTags" (J.from_string (Native_bridge.encode_ui_session (Model.ui_session (App.model app))))) []; + App_test.send app (Model.ApplyCoreSnapshot old_choices); App_test.flush app; + check_eq ~msg:"a stale tag catalog never switches graphs" (App.model app).selected_graph_id (Some "other")))) + [App_test.ios_backend; App_test.kotlin_backend] + +let projection_for_graph response graph_id catalog = + let envelope = J.from_string response in + let fields = match member "result" envelope with `Assoc fields -> fields | _ -> fail "Missing snapshot" in + let fields = ("selectedGraphId", `String graph_id) :: ("isGraphCatalogPatch", `Bool catalog) + :: List.remove_assoc "selectedGraphId" (List.remove_assoc "isGraphCatalogPatch" fields) in + match Response_snapshot.decode_response (J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc fields])) with + | Ok value -> value | Error message -> fail message + +let graph_changes_reset_the_tag_picker () = + List.iter (fun backend -> List.iter (fun change -> R.with_runtime (fun session _ _ -> + let snapshot = Rpc_session.snapshot_visible session in + Q.with_ui backend snapshot (fun app -> + let old_graph = (App.model app).selected_graph_id in + App_test.send app (Model.SetComposerTags [{Model.uuid = Q.project; title = "Project"}]); + App_test.send app Model.OpenComposerTags; App_test.flush app; + let load = List.find (function Model.LoadComposerTagsEffect _ -> true | _ -> false) (App.model app).pending_effects in + let load_id = match member "id" (J.from_string (Native_bridge.encode_effect load)) with `Int id -> id | _ -> fail "Missing load id" in + App_test.send app (Model.DequeueEffect load_id); + if change = "open" then begin + let graphs = [{Model.id = "other"; name = "Other"; is_encrypted = false; is_ready = true; role = None}] in + let catalog = { (projection_for_graph snapshot (Option.get old_graph) true) with Model.graphs } in + App_test.send app (Model.ApplyCoreSnapshot catalog); + App_test.send app (Model.RequestOpenGraph "other"); App_test.flush app; + let eff = List.find (function Model.OpenGraphEffect _ -> true | _ -> false) (App.model app).pending_effects in + let id = match eff with Model.OpenGraphEffect (id, _) -> id | _ -> assert false in + App_test.send app (Model.DequeueEffect id); + App_test.send app (Model.ResolveEffect (id, true, "{}")) + end else App_test.send app (Model.ApplyCoreSnapshot (projection_for_graph snapshot "other" (change = "catalog"))); + App_test.flush app; + let current = App.model app in + check_eq ~msg:(change ^ " must clear graph-specific staged tags") current.composer_tags []; + check_eq ~msg:(change ^ " must close the previous graph's picker") current.composer_tags_open false; + check_eq ~msg:(change ^ " must release the loading state") current.composer_tags_loading false; + App_test.send app Model.OpenComposerTags; App_test.flush app; + App_test.send app (Model.ResolveEffect (load_id, false, "Old graph request failed")); App_test.flush app; + check_eq ~msg:"a stale failure cannot close the new graph's picker" (App.model app).composer_tags_open true; + check_eq ~msg:"a stale failure cannot end the new graph's loading request" (App.model app).composer_tags_loading true))) + ["snapshot"; "catalog"; "open"]) [App_test.ios_backend; App_test.kotlin_backend] + +let failed_asset_captures_recover_only_in_their_graph () = + List.iter (fun backend -> List.iter (fun switch -> R.with_runtime (fun session _ _ -> + let snapshot = Rpc_session.snapshot_visible session in + Q.with_ui backend snapshot (fun app -> + let asset = {Model.uuid = captured; title = "Photo.png"; local_path = "/tmp/Photo.png"; payload = "{}"} in + App_test.send app (Model.SetComposerTags [{Model.uuid = Q.project; title = "Project"}]); + App_test.send app (Model.StageComposerAsset asset); + App_test.send app Model.SendComposer; App_test.flush app; + let eff = List.find (function Model.SendAssetEffect _ -> true | _ -> false) (App.model app).pending_effects in + let encoded = J.from_string (Native_bridge.encode_effect eff) in + let id = match member "id" encoded with `Int id -> id | _ -> fail "Missing asset effect" in + App_test.send app (Model.DequeueEffect id); + if switch then App_test.send app (Model.ApplyCoreSnapshot (projection_for_graph snapshot "other" false)); + App_test.flush app; + if switch then check_eq ~msg:"old in-flight assets cannot be saved in another graph's session" (Model.ui_session (App.model app)).assets []; + App_test.send app (Model.ResolveEffect (id, false, "Local persistence failed")); App_test.flush app; + let current = App.model app in + check_eq ~msg:"only the capture graph can recover an asset" (List.length current.composer_assets) (if switch then 0 else 1); + check_eq ~msg:"asset retries must show their tags" (List.map (fun (tag : Model.sidebar_page) -> tag.uuid) current.composer_tags) + (if switch then [] else [Q.project]); + if not switch then begin + App_test.send app (Model.RemoveComposerTag Q.project); + App_test.send app Model.SendComposer; App_test.flush app; + let retry = List.find (function Model.SendAssetEffect _ -> true | _ -> false) (App.model app).pending_effects in + let payload = J.from_string (text (member "text" (J.from_string (Native_bridge.encode_effect retry)))) in + check_eq ~msg:"removing a recovered chip removes it from the retry" (items "selectedValues" (member "tags" payload)) [] + end))) [false; true]) [App_test.ios_backend; App_test.kotlin_backend] + +let cases = [ + case "graph changes reset the tag picker" graph_changes_reset_the_tag_picker; + case "asset failures recover in the capture graph" failed_asset_captures_recover_only_in_their_graph; + case "fresh journal captures preserve note text" fresh_journal_captures_preserve_the_note; + case "assets assign tags and preserve filenames" assets_assign_tags_without_changing_the_filename; + case "capture assigns existing and new tags" capture_assigns_existing_and_new_tags; + case "invalid tags leave no partial capture" invalid_tags_do_not_capture_or_create; + case "inline tags and task status survive" inline_tags_and_status_survive; + case "catalog is lazy and read-only" catalog_uses_graph_tags_without_writes; + case "shared native picker stages and removes tags" shared_picker_stages_and_removes_tags; +] diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 581f813e..130ddb17 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -262,7 +262,7 @@ let return_selects_completion_and_code_keeps_line_breaks () = R.with_runtime (fu let code_in_text_creates_a_separate_block () = R.with_runtime (fun session runtime _ -> ignore (start session "Keep this /"); ignore (command session "Code block"); - check_eq ~msg:"a code command preserves nonempty prose" (value runtime Q.alpha "block/title") (Some (Ds.String "Keep this ")); + check_eq ~msg:"a code command preserves prose and trims its edges on leaving editing" (value runtime Q.alpha "block/title") (Some (Ds.String "Keep this")); check_eq (value runtime Q.alpha "logseq.property.node/display-type") None; check ~msg:"the code editor gets its own sibling" (List.exists (fun (b : Cache_model.block) -> b.uuid <> Q.alpha && b.display_type = Some "code") (Graph_runtime.blocks_for_page runtime Q.page))) diff --git a/shared/test/logseq/editor_exit_test.ml b/shared/test/logseq/editor_exit_test.ml new file mode 100644 index 00000000..c029abb8 --- /dev/null +++ b/shared/test/logseq/editor_exit_test.ml @@ -0,0 +1,74 @@ +open Test_util +open Rpc_test + +let leaving_editor_trims_block_edges () = + let exits = [ + {|{"type":"toolbar","action":"hideKeyboard"}|}; + {|{"type":"cancelEditing"}|}; + {|{"type":"tapBlock","uuid":"other"}|}; + {|{"type":"longPressBlock","uuid":"other"}|}; + {|{"type":"toggleCollapsed","uuid":"editing"}|}; + {|{"type":"zoomIn","uuid":"editing"}|}; + {|{"type":"zoomOut"}|}; + {|{"type":"returnPressed"}|} + ] in + List.iter (fun event -> + List.iter (fun (initial, draft, expected) -> + Result_view_test.with_runtime (fun session runtime _ -> + let uuid = Query_block_test.alpha in + let rename = {Pending_ops.operation_id = Graph_runtime.fresh_uuid (); base_t = 42; + state = Pending_ops.Queued; intent = Pending_ops.Save_title {uuid; + expected_title = Option.value ~default:"" (Option.bind + (Editor_command_test.value runtime uuid "block/title") + (function Datascript.String value -> Some value | _ -> None)); title = initial}} in + check_ok (Graph_runtime.stage runtime rename); + ignore (outliner_block_event session "tapBlock" uuid); + ignore (outliner_event session (Json.to_string (`Assoc ["type", `String "textChanged"; + "title", `String draft; "caretUTF16Offset", `Int (String.length draft)]))); + let payload = Json.from_string event |> function + | `Assoc fields -> `Assoc (List.map (function + | "uuid", `String "editing" -> "uuid", `String uuid + | "uuid", `String "other" -> "uuid", `String Query_block_test.beta + | field -> field) fields) + | _ -> assert false in + let response = dispatch_json session "outlinerEvent" (Json.to_string payload) in + check_eq ~msg:("Exit succeeds: " ^ event) (member "ok" response) (`Bool true); + check_eq ~msg:("Leaving editing trims only the block edges: " ^ event) + (Editor_command_test.value runtime uuid "block/title") (Some (Datascript.String expected)))) + ["Original", " \tHello\n child\nworld \n", "Hello\n child\nworld"; + "Original", " \t\n ", ""; + " Original ", " Original ", "Original"; + "Original", "Original", "Original"]) exits + +let autosave_preserves_spaces_until_editor_is_left () = + Result_view_test.with_runtime (fun session runtime _ -> + let uuid = Query_block_test.alpha in + ignore (outliner_block_event session "tapBlock" uuid); + ignore (outliner_event session {|{"type":"textChanged","title":" Hello ","caretUTF16Offset":9}|}); + ignore (outliner_event session {|{"type":"saveEditing"}|}); + check_eq ~msg:"Autosave must preserve spaces while typing" + (Editor_command_test.value runtime uuid "block/title") (Some (Datascript.String " Hello ")); + check_eq (Outliner.editing_title (Types.state session).outliner_state) (Some " Hello "); + ignore (outliner_event session {|{"type":"cancelEditing"}|}); + check_eq ~msg:"An already autosaved draft is still trimmed on exit" + (Editor_command_test.value runtime uuid "block/title") (Some (Datascript.String "Hello"))) + +let failed_trim_save_retains_the_live_draft () = + let live = synced_block "editing" "Original" in + let session = configure_plain_session (Session.create_session {Session.default_options with + load_graph_catalog = Some (fun () -> Some plain_graph_catalog); + sync_cursor = Some (fun () -> Some 92); graph_blocks = Some (fun () -> Some [live]); + prepare_operation = Some prepare_operation; + stage_operation = Some (fun _ -> Error "Local save failed")}) in + ignore (outliner_block_event session "tapBlock" "editing"); + ignore (outliner_event session {|{"type":"textChanged","title":" Keep draft ","caretUTF16Offset":14}|}); + let response = dispatch_json session "outlinerEvent" {|{"type":"cancelEditing"}|} in + check_eq (member "ok" response) (`Bool false); + check_eq ~msg:"A failed trim save keeps the original draft available for retry" + (Outliner.editing_title (Types.state session).outliner_state) (Some " Keep draft ") + +let cases = [ + case "leaving editor trims block edges" leaving_editor_trims_block_edges; + case "autosave preserves spaces until editor is left" autosave_preserves_spaces_until_editor_is_left; + case "failed trim save retains the live draft" failed_trim_save_retains_the_live_draft; +] diff --git a/shared/test/logseq/outliner_state_test.ml b/shared/test/logseq/outliner_state_test.ml index 090cc459..6deb642e 100644 --- a/shared/test/logseq/outliner_state_test.ml +++ b/shared/test/logseq/outliner_state_test.ml @@ -572,7 +572,7 @@ let selection_copy_and_unselect_close_selection () = check_eq (selected next) []; check_eq commands [ expected; State.Haptic State.Impact ]) [ - (State.Copy, State.Copy_text "Alpha"); + (State.Copy, State.Copy_blocks ["a"]); (State.Copy_reference, State.Copy_references [ "a" ]); (State.Copy_url, State.Copy_urls [ "a" ]); ]; diff --git a/shared/test/logseq/pending_ops_test.ml b/shared/test/logseq/pending_ops_test.ml index cf17e7b2..3c6a5c29 100644 --- a/shared/test/logseq/pending_ops_test.ml +++ b/shared/test/logseq/pending_ops_test.ml @@ -113,7 +113,7 @@ let page_intents : Ops.pending_intent list = Ops.Create_journal { page_uuid = "page"; - block_uuid = "block"; + block_uuid = "block"; block_title = ""; title = "Journal"; journal_day = 20260915; created_at = 43; diff --git a/shared/test/logseq/pending_projection_test.ml b/shared/test/logseq/pending_projection_test.ml index 61a65a1e..dcee30c1 100644 --- a/shared/test/logseq/pending_projection_test.ml +++ b/shared/test/logseq/pending_projection_test.ml @@ -726,7 +726,7 @@ let journal_intent = Ops.Create_journal { Ops.page_uuid = "today-page"; - block_uuid = "today-block"; + block_uuid = "today-block"; block_title = ""; title = "Aug 22nd, 2026"; journal_day = 20260822; created_at = 99; diff --git a/shared/test/logseq/query_block_test.ml b/shared/test/logseq/query_block_test.ml index 37b9559b..b2164533 100644 --- a/shared/test/logseq/query_block_test.ml +++ b/shared/test/logseq/query_block_test.ml @@ -333,7 +333,15 @@ let cross_page_result_actions () = ignore (event ["type", `String "longPressBlock"; "uuid", `String alpha]); let copied = event ["type", `String "toolbar"; "action", `String "copy"] in check ~msg:"select bar copies the actual result from another page" - (List.exists (fun command -> member "text" command = `String "Alpha **result**") + (List.exists (fun command -> + if member "type" command <> `String "setClipboardBlocks" then false else + match member "text" command with + | `String payload -> + let payload = Json.from_string payload in + let markdown = match member "plainText" payload with `String text -> text | _ -> "" in + String_kit.index_of ~sub:"- Alpha **result**" ~start:0 markdown <> None + && String_kit.index_of ~sub:"\t- Beta result" ~start:0 markdown <> None + | _ -> false) (items "outlinerCommands" copied)); let edited = event ["type", `String "tapBlock"; "uuid", `String alpha] in check_eq ~msg:"result editing resolves blocks outside the visible page" diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index 5db17bb7..57db5441 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -645,8 +645,7 @@ let native_direct_value_selection_and_creation () = App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in click ("view.tab." ^ table_view); click ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/title"); - check_eq ~msg:"the Name editor does not offer Clear value" (find ("view.edit.clear." ^ table_view)) (-1); - click ("view.edit.cancel." ^ table_view); + check_eq ~msg:"Name is read-only in a table" (find ("view.edit.sheet." ^ table_view)) (-1); click ("view.cell." ^ table_view ^ "." ^ alpha ^ "." ^ score); check ~msg:"other properties retain Clear value" (find ("view.edit.clear." ^ table_view) <> -1); check ~msg:"a cell immediately opens its searchable choices" @@ -932,7 +931,68 @@ let compact_local_timestamps_and_columns () = check ~msg:"updated timestamps remain available for master-compatible filters and sorting" (List.exists (fun p -> member "attr" p = `String "block/updated-at") (items "properties" view)) +let table_identity_and_timestamps_are_read_only () = with_runtime (fun session runtime conn -> + ignore (Ds.transact_conn conn [put alpha "block/created-at" (Ds.Int64 1792632600000L); + put alpha "block/updated-at" (Ds.Int64 1792633600000L)]); + Graph_runtime.rebase runtime (Graph_runtime.state runtime).server_t [] [alpha]; + let view = find_view table_view (read session) in + List.iter (fun attr -> + let metadata = List.find (fun column -> member "attr" column = `String attr) (items "properties" view) in + check_eq ~msg:(attr ^ " cannot be edited in a table") (member "editable" metadata) (`Bool false); + let id = Option.get (Ds.entid (Graph_runtime.db runtime) "block/uuid" (Ds.Uuid alpha)) in + let before = Graph_read.value (Graph_runtime.db runtime) id attr in + let result = action session (base_action "editCell" table_view @ ["blockUuid", `String alpha; + "attr", `String attr; "expected", member "raw" (Result_view.property_json + (Graph_runtime.db runtime) ~owner:alpha ~attr ~encode_block:(fun _ -> `Null)); + "value", `String "Changed"] ) in + check_eq ~msg:"RPC rejects read-only table edits" (member "ok" result) (`Bool false); + check_eq ~msg:"rejected edits leave the graph unchanged" + (Graph_read.value (Graph_runtime.db runtime) id attr) before) + ["block/title"; "block/created-at"; "block/updated-at"]) + +let table_tags_are_editable_and_keep_typed_references () = with_runtime (fun session runtime _ -> + let view = find_view table_view (read session) in + let tags = List.find (fun column -> member "attr" column = `String "block/tags") (items "properties" view) in + check_eq ~msg:"Tags can be edited like other table properties" (member "editable" tags) (`Bool true); + let expected = member "raw" (Result_view.property_json (Graph_runtime.db runtime) + ~owner:alpha ~attr:"block/tags" ~encode_block:(fun _ -> `Null)) in + let result = action session (base_action "editCell" table_view @ ["blockUuid", `String alpha; + "attr", `String "block/tags"; "expected", expected; "value", `String ""; + "selectedValues", `List [`String Q.project]; "newValues", `List [`String "Table tag"]; "create", `Bool true]) in + check_eq ~msg:(Json.to_string result) (member "ok" result) (`Bool true); + let db = Graph_runtime.db runtime in + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid alpha)) in + let titles = Graph_read.entity_summaries (fun value -> Ok value) db id "block/tags" + |> List.map (fun (tag : Cache_model.entity_summary) -> tag.title) |> List.sort String.compare in + check_eq ~msg:"selecting and creating tags stores reference values" titles ["Project"; "Table tag"]; + List.iter (fun operation -> check_ok ~msg:"typed table tag edits compile for sync" + (Graph_runtime.prepare_sync runtime operation)) (Graph_runtime.pending_operations runtime)) + +let table_cells_have_column_dividers_on_both_hosts () = + List.iter (fun backend -> with_runtime (fun session _ _ -> + Q.with_ui backend (Session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + App_test.dispatch app (Lui_protocol.Press (find ("view.tab." ^ table_view))); App_test.flush app; + let view = find_view table_view (read session) in + let attrs = List.map (fun column -> text (member "attr" column)) (items "columns" view) in + List.iter (fun row -> List.iteri (fun index attr -> if index > 0 then ( + let divider = find ("view.cell.separator." ^ table_view ^ "." ^ row ^ "." ^ attr) in + check ~msg:"adjacent table cells have a visible column divider" (divider <> -1); + check_eq (App_test.property_string host divider Lui_protocol.OrientationValue) "vertical")) attrs) + ["header"; alpha; beta; gamma]; + App_test.dispatch app (Lui_protocol.Press (find ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/title"))); + App_test.flush app; + check_eq ~msg:"read-only Name never opens a value editor" (find ("view.edit.sheet." ^ table_view)) (-1); + App_test.dispatch app (Lui_protocol.Press (find ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/tags"))); + App_test.flush app; + check ~msg:"Tags opens the shared native selector" (find ("view.edit.sheet." ^ table_view) <> -1)))) + [App_test.ios_backend; App_test.kotlin_backend] + let cases = [ + case "table identity and timestamps remain read-only at the RPC boundary" table_identity_and_timestamps_are_read_only; + case "table tags edit typed references and create tag objects" table_tags_are_editable_and_keep_typed_references; + case "table cells have column dividers and typed editors on both hosts" table_cells_have_column_dividers_on_both_hosts; case "compact local timestamps and mobile table metadata order" compact_local_timestamps_and_columns; case "empty table cells have accessible names" empty_table_cells_have_accessible_names; case "pushed query type changes refresh the visible renderer" pushed_query_type_changes_refresh_the_visible_renderer; diff --git a/shared/test/logseq/rpc_test.ml b/shared/test/logseq/rpc_test.ml index d673c7b5..8de08cb5 100644 --- a/shared/test/logseq/rpc_test.ml +++ b/shared/test/logseq/rpc_test.ml @@ -1931,7 +1931,8 @@ let encrypted_task_stages_journal_before_status_and_drains_both_requests () = (match (List.nth !staged 0).Ops.intent with | Ops.Create_journal journal -> check_eq journal.block_uuid "encrypted-async"; - check_eq journal.title "Secret task" + check_eq journal.block_title "Secret task"; + check_eq journal.title (Rpc_wire.journal_day_title journal.journal_day) | _ -> check false); (match (List.nth !staged 1).Ops.intent with | Ops.Set_property property -> diff --git a/shared/test/logseq/test_main.ml b/shared/test/logseq/test_main.ml index 0491683d..cafec9ba 100644 --- a/shared/test/logseq/test_main.ml +++ b/shared/test/logseq/test_main.ml @@ -41,6 +41,9 @@ let () = "mobile_graph", Mobile_graph_test.cases; "outliner_state", Outliner_state_test.cases; "editor_commands", Editor_command_test.cases; + "composer_tags", Composer_tag_test.cases; + "block_clipboard", Block_clipboard_test.cases; + "editor_exit", Editor_exit_test.cases; "pending_projection", Pending_projection_test.cases; "graph_runtime", Graph_runtime_test.cases; "rpc", Rpc_test.cases; From dd8bdd500a6036feb24141e224e8c2bb1c45601a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 03:14:51 +0800 Subject: [PATCH 11/25] Support graph date formats in native settings and journal displays --- .../com/logseq/app/AndroidDateFormatTest.kt | 79 +++++++ apple/Tests/UI/settings-control-colors.swift | 54 ++++- .../016-mobile-interaction-regressions.md | 20 +- shared/src/logseq/core/core_effect.ml | 1 + shared/src/logseq/core/dev_query.ml | 125 +--------- shared/src/logseq/core/editor_command.ml | 7 +- .../src/logseq/core/editor_command_effects.ml | 4 +- shared/src/logseq/core/graph_read.ml | 22 +- shared/src/logseq/core/graph_read.mli | 2 + shared/src/logseq/core/graph_runtime.ml | 6 +- shared/src/logseq/core/graph_sqlite.ml | 2 +- shared/src/logseq/core/journal.ml | 160 +++++++++++-- shared/src/logseq/core/journal.mli | 21 +- shared/src/logseq/core/pending_pump.ml | 8 +- shared/src/logseq/core/result_view.ml | 7 +- shared/src/logseq/core/rpc_ops.ml | 2 +- shared/src/logseq/core/rpc_session.ml | 70 ++++-- shared/src/logseq/core/rpc_wire.ml | 28 +-- shared/src/logseq/core/search_index.ml | 16 +- shared/src/logseq/model.ml | 19 +- shared/src/logseq/model.mli | 4 + shared/src/logseq/native_bridge.ml | 2 + shared/src/logseq/response_snapshot.ml | 1 + shared/src/logseq/view_settings.ml | 26 +++ shared/test/logseq/app_test.ml | 1 + shared/test/logseq/graph_read_test.ml | 17 +- shared/test/logseq/graph_runtime_test.ml | 3 + shared/test/logseq/journal_format_test.ml | 221 ++++++++++++++++++ shared/test/logseq/test_main.ml | 1 + 29 files changed, 711 insertions(+), 218 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt create mode 100644 shared/test/logseq/journal_format_test.ml diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt new file mode 100644 index 00000000..6a5134d1 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt @@ -0,0 +1,79 @@ +package com.logseq.app + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.logseq.app.runtime.LogseqNativeBridge +import com.logseq.app.runtime.LuiPatchFilter +import com.logseq.app.runtime.dispatchLuiEvent +import com.logseq.app.runtime.logseqIconResolver +import com.logseq.app.ui.LogseqTheme +import com.logseq.app.ui.extensions.logseqExtensionRegistry +import dev.lui.LuiBackend +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidDateFormatTest { + @get:Rule val ui = createComposeRule() + private lateinit var bridge: LogseqNativeBridge + private lateinit var filter: LuiPatchFilter + + @After fun closeBridge() { if (::bridge.isInitialized) ui.runOnIdle { bridge.close() } } + + private fun snapshot(graph: String, format: String?): String = JSONObject().put("ok", true) + .put("result", JSONObject().put("selectedGraphId", graph).put("graphName", graph) + .apply { if (format != null) put("journalTitleFormat", format) }).toString() + + @Test fun dateFormatUsesNativeChoicesAndStaysScopedToTheGraph() { + val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, + extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) + filter = LuiPatchFilter(backend) + bridge = LogseqNativeBridge(onPatch = { if (it.isNotEmpty()) filter.applyJson(it) }) + val root = bridge.initialize(authenticationCode = 3) + filter.applyJson(bridge.applySnapshot(snapshot("demo", "yyyy-MM-dd"))) + bridge.applyHostUpdate("graph-loading", "false").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + bridge.applyHostUpdate("restore-ui-session", """{"graphId":"demo","destination":"journals","assets":[],"composerExpanded":false,"searchOpen":false,"query":"","appPath":[],"searchPath":[],"selectedPageId":null,"settingsOpen":true,"recentGraphIds":[]}""") + .takeIf { it.isNotEmpty() }?.let(filter::applyJson) + ui.setContent { + MaterialExpressiveTheme(colorScheme = LogseqTheme.darkScheme, typography = LogseqTheme.typography) { + Surface(Modifier.fillMaxSize(), color = LogseqTheme.darkScheme.background) { backend.Content(root) } + } + } + ui.onNodeWithTag("picker.settings.date-format").assertIsDisplayed().performClick() + ui.onNodeWithText("dd-MM-yyyy").assertIsDisplayed().performClick() + var selected: JSONObject? = null + ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + repeat(24) { + val raw = bridge.takeEffect() + if (raw.isNotEmpty()) JSONObject(raw).optJSONObject("effect")?.let { effect -> + if (effect.optString("kind") == "set-journal-title-format") selected = effect + } + } + } + selected != null + } + val payload = JSONObject(selected!!.getString("text")) + assertEquals("demo", payload.getString("graphId")) + assertEquals("dd-MM-yyyy", payload.getString("format")) + ui.runOnIdle { + bridge.applySnapshot(snapshot("demo", "dd-MM-yyyy")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) + bridge.resolveEffect(selected!!.getLong("id"), true, "").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + } + ui.onNodeWithText("dd-MM-yyyy").assertIsDisplayed() + ui.runOnIdle { bridge.applySnapshot(snapshot("other", null)).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithText("MMM do, yyyy").assertIsDisplayed() + } +} diff --git a/apple/Tests/UI/settings-control-colors.swift b/apple/Tests/UI/settings-control-colors.swift index a660dffb..ee06fde8 100644 --- a/apple/Tests/UI/settings-control-colors.swift +++ b/apple/Tests/UI/settings-control-colors.swift @@ -1,18 +1,22 @@ import SwiftUI import UIKit +import Vision @testable import LUIAppleBackend @testable import Logseq @MainActor private final class SettingsColorEffects: LGEffectExecuting { + var formats: [String] = [] func execute(_ effect: LGEffect) async -> LGEffectResolution { - LGEffectResolution(succeeded: true, message: "", output: .discard) + if effect.kind == "set-journal-title-format" { formats.append(effect.text) } + return LGEffectResolution(succeeded: true, message: "", output: .discard) } } @main struct SettingsControlColorsCheck { @MainActor static func main() async throws { - let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: SettingsColorEffects()) - try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"settings-colors","graphName":"Settings colors","blocks":[],"outlinerRows":[]}}"#) + let effects = SettingsColorEffects() + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"settings-colors","graphName":"Settings colors","journalTitleFormat":"yyyy-MM-dd","blocks":[],"outlinerRows":[]}}"#) try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") try runtime.applyHostUpdate(kind: "restore-ui-session", payload: #"{"graphId":"settings-colors","destination":"journals","assets":[],"composerExpanded":false,"searchOpen":false,"query":"","appPath":[],"searchPath":[],"selectedPageId":null,"settingsOpen":true,"recentGraphIds":[]}"#) @@ -33,7 +37,6 @@ import UIKit return node } try await Task.sleep(for: .milliseconds(600)) - await runtime.drainEffectsForTesting() try await Task.sleep(for: .milliseconds(500)) window.rootViewController?.presentedViewController?.sheetPresentationController?.selectedDetentIdentifier = .large try await Task.sleep(for: .milliseconds(500)) @@ -53,12 +56,27 @@ import UIKit let id = try find(identifier) if backend.model(id: id)?.isChecked != true { try backend.performToggle(node: id, checked: true) } } - await runtime.drainEffectsForTesting() for (style, name) in [(UIUserInterfaceStyle.light, "settings-colors-light.png"), (.dark, "settings-colors-dark.png")] { window.overrideUserInterfaceStyle = style try await Task.sleep(for: .milliseconds(500)) let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(name)) + let recognition = VNRecognizeTextRequest() + recognition.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: image.cgImage!, options: [:]).perform([recognition]) + func labelRect(_ text: String) throws -> CGRect { + guard let observation = recognition.results?.first(where: { $0.topCandidates(1).first?.string == text }) else { + throw failure("Missing visible settings label: " + text) + } + return observation.boundingBox + } + let date = try labelRect("Date format") + let spell = try labelRect("Spell check") + let correction = try labelRect("Auto-correction") + guard abs(date.minX - spell.minX) * window.bounds.width <= 3, + abs(date.midY - spell.midY) <= abs(spell.midY - correction.midY) + 3 / window.bounds.height else { + throw failure("Date format must align with adjacent native editor settings and retain their row spacing") + } func coloredPixels(in rect: CGRect, green: Bool) -> Int { let pixelRect = CGRect(x: rect.minX * image.scale, y: rect.minY * image.scale, width: rect.width * image.scale, height: rect.height * image.scale) @@ -81,12 +99,34 @@ import UIKit let rect = control.convert(control.bounds, to: window) guard coloredPixels(in: rect, green: false) > 100 && coloredPixels(in: rect, green: true) == 0 else { throw failure("Native settings switches must use Logseq teal instead of system green") } } - for identifier in ["picker.settings.appearance", "picker.settings.language"] { + for identifier in ["picker.settings.appearance", "picker.settings.language", "picker.settings.date-format"] { guard let rect = frames[try find(identifier)] else { throw failure("Missing picker frame: " + identifier) } guard coloredPixels(in: rect, green: false) == 0 else { throw failure("Picker value must use text color: " + identifier) } } } - print("PASS: native settings switches use Logseq teal and picker values use text color in light and dark appearances") + let picker = try find("picker.settings.date-format") + guard backend.model(id: try find("settings.date-format.option.yyyy-MM-dd"))?.isChecked == true, + let frame = frames[picker], frame.width > 80, frame.maxX < window.bounds.maxX else { + throw failure("The date format must show the current graph preference within the settings row") + } + let option = try find("settings.date-format.option.dd-MM-yyyy") + try backend.performChange(node: option) + for _ in 0..<100 { + if !effects.formats.isEmpty { break } + try await Task.sleep(for: .milliseconds(20)) + } + guard effects.formats.count == 1, + let data = effects.formats[0].data(using: .utf8), + let payload = try JSONSerialization.jsonObject(with: data) as? [String: String], + payload["graphId"] == "settings-colors", payload["format"] == "dd-MM-yyyy" else { + throw failure("A date-format choice must reach the graph-scoped mutation boundary") + } + try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"settings-colors","graphName":"Settings colors","journalTitleFormat":"dd-MM-yyyy","blocks":[],"outlinerRows":[]}}"#) + try await Task.sleep(for: .milliseconds(200)) + guard backend.model(id: try find("settings.date-format.option.dd-MM-yyyy"))?.isChecked == true else { + throw failure("The retained native picker must update from the confirmed graph preference") + } + print("PASS: native settings retain theme colors and expose graph-scoped date format choices with confirmed updates") } static func failure(_ message: String) -> NSError { NSError(domain: "SettingsControlColors", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index af0c7b6a..f98fd01e 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -37,7 +37,7 @@ NOTE: I will write *all* tests before I add any implementation behavior. | 4 | Trim on leaving editing | Shared RPC tests cover hide, switch, selection, collapse, zoom, Return, autosave, blank content, and failed storage; full core gate passes. | | 5 | Nested/search long press | The feature branch already contains the destination-toolbar fix from `bb4575f`. Native direct/nested/search selection now traverses the effect boundary, visibly renders the bar and dispatches Copy/Unselect successfully. Complete the remaining Recent/root-path audit and physical gesture check. | | 6 | Master-compatible clipboard | Eleven graph-backed round-trip cases pass: Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, empty/nonempty/parent placement, UTF-16 selection, paragraphs, fenced code, foreign graphs and atomic storage failure. Both iOS and Android native clipboard/selection/rapid-typing/failed-save checks pass. Audit remaining master cases before final delivery. | -| 7 | Date-format preference | Audit master settings and existing formatter support, add persistence and consistent rendering without changing journal identity. | +| 7 | Date-format preference | Implemented the master graph property, shared 27-format lexer/formatter, all journal creation paths, live journal/reference/property/navigation labels and graph-scoped native settings menus. Twelve graph/shared-host cases and the full 1,036-test gate pass. Coverage includes storage failure, restart/sync, graph changes, all formats, invalid legacy days, UUID date commands and search-index refresh. Native iOS theme/alignment/confirmed-choice checks and all 19 Android query/editor/property/outliner/settings checks pass. | | 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name/timestamps are read-only; Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | | 9 | TestFlight crash | Read the specified crash, identify the crashed build and symbolicated stack, reproduce its cause, and add a regression check before fixing it. | | 10 | Delivery | Run final gates, update PRs, and non-destructively install and launch the final build on the user's iPhone. | @@ -103,10 +103,22 @@ The cached-empty-row native regression now passes all five refresh cycles. Persi A diagnostic build must explicitly define `LOGSEQ_INTERACTION_DIAGNOSTICS`; Release does not define `DEBUG`. Verify `Library/Caches/interaction-debug.log` can be pulled from the phone before asking for a repro. Clipboard tests must round-trip actual copied blocks rather than merely validating an intermediate JSON shape. Final verification includes an iOS build, Android build/instrumentation, and a non-destructive iPhone installation. -At the user's interim installation request, the latest iOS build (build number `1792693092`) was installed and launched successfully at 02:18 on the paired iPhone, preserving the app container. It adds the tested table policy/dividers and composer graph-isolation/asset-retry fixes to the confirmed jitter fix. Installation/launch evidence is in `/tmp/logseq-table-recovery-install.json` and `/tmp/logseq-table-recovery-launch.json`. Clipboard parity, date-format settings, final native checks and the crash investigation remain open. +At the user's earlier interim installation request, the iOS build (build number `1792693092`) was installed and launched successfully at 02:18 on the paired iPhone, preserving the app container. It adds the tested table policy/dividers and composer graph-isolation/asset-retry fixes to the confirmed jitter fix. Installation/launch evidence is in `/tmp/logseq-table-recovery-install.json` and `/tmp/logseq-table-recovery-launch.json`. Clipboard parity, date-format settings, final native checks and the crash investigation remain open. ## Implementation Details +Date-format implementation notes from the master audit: + +- Persist the graph property, not a device-only setting. Scope the mutation by graph ID and reject stale requests. +- Preserve journal UUIDs and stored titles when changing the preference; derive displayed journal titles, reference labels and date-property text from journal day. Native day-picker `editorValue` stays ISO. +- Share the 27 built-in formats already present in `Dev_query.Date_time_util`. `Dev_query` depends on `Graph_read`, so `Graph_read` cannot call back into `Dev_query`: move the pure formatter/token support into the existing pure `Journal` module and reuse it from both. +- Update creation paths in `Graph_runtime.ensure_today_journal`, `Rpc_session.ensure_journal`, `Rpc_ops`, `Pending_pump`, `Editor_command_effects` and date-property creation in `Result_view`. +- Expose the graph preference in snapshots and both native settings menus; preserve graph isolation and avoid changing active date-picker wire values. +- Date-format TDD evidence: `/tmp/logseq-journal-format-red-7.log` reproduces the missing API/menu; `/tmp/logseq-journal-format-surfaces-red.log` reproduces stale open destination titles. Ten cases pass in `/tmp/logseq-journal-format-surfaces-green.log`, and the complete gate passes 1,034 tests in `/tmp/logseq-date-format-full-core-final.log`. +- The added date-command/search cases first fail in `/tmp/logseq-date-command-search-red-fixed.log` and then pass with all twelve date cases in `/tmp/logseq-date-command-search-green.log`. Date commands now insert stable journal UUIDs. Search refresh includes journals and their referring blocks when the graph format changes; the persisted index fingerprint is versioned to rebuild older labels. +- Final date-format verification: `/tmp/logseq-date-picker-complete-core.log` passes all 1,036 core tests. `/tmp/logseq-date-settings-alignment-red.log` reproduces the double-inset/taller custom menu row; reusing the existing native picker contract passes `/tmp/logseq-date-settings-native-final.log`, including light/dark OCR alignment and spacing, colors and confirmed selection. Screenshots in `/tmp/logseq-date-settings-native-final-artifacts` were visually inspected. `/tmp/logseq-date-format-android-all.log` passes all 19 native checks (78.098s), including real dropdown selection and graph isolation. +- Existing journal read tests now assert derived calendar titles. The 10k-row check verifies physical reuse of the derived journal metadata and no stored-title decryption; the sidebar rename-cache check explicitly uses an ordinary page so it retains coverage of actual renames. + - Preserve the user's `apple/Package.resolved` origin hash and pre-existing `duniverse/lui` edits. - Never reset or uninstall the user's graph data. - Keep all code and comments in English. @@ -123,3 +135,7 @@ No product clarification blocks the interaction fixes. Crash investigation may require the existing App Store Connect session and matching symbols; access has not yet been checked because the user requested that investigation last. --- + +## Latest interim device install + +At the user's next "install first" request, build `1792695320` from mobile commit `89b151a` was built, signed, installed and launched at 02:56 on the paired iPhone. Process `23154` was verified alive. The install preserves the data container and includes the structured multi-block clipboard changes. Date-format work is subsequent and was not included. Evidence: `/tmp/logseq-latest-device-build.log`, `/tmp/logseq-latest-device-install.json`, `/tmp/logseq-latest-device-launch.json` and `/tmp/logseq-latest-device-processes.json`. diff --git a/shared/src/logseq/core/core_effect.ml b/shared/src/logseq/core/core_effect.ml index d436c5ca..ba93a56e 100644 --- a/shared/src/logseq/core/core_effect.ml +++ b/shared/src/logseq/core/core_effect.ml @@ -39,6 +39,7 @@ let request ~now ~fresh_id payload = | "send-asset" -> raw "addAsset" | "result-view" -> raw "resultView" | "node-property" -> raw "nodeProperty" + | "set-journal-title-format" -> raw "setJournalTitleFormat" | "recording-transcribed" -> let* uuid = string fields "uuid" in let* text = text () in json "completeAssetTranscription" ["parentId", `String uuid; "text", `String text] | "load-page-references" -> raw "loadPageReferences" diff --git a/shared/src/logseq/core/dev_query.ml b/shared/src/logseq/core/dev_query.ml index f3bd8308..0465a7bf 100644 --- a/shared/src/logseq/core/dev_query.ml +++ b/shared/src/logseq/core/dev_query.ml @@ -909,42 +909,12 @@ let capitalize_all (s : string) : string = ^ Unicode.lowercase (String.sub w 1 (String.length w - 1))) |> String.concat " " -let month_short = - [| "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; "Jul"; "Aug"; "Sep"; "Oct"; - "Nov"; "Dec" |] - -let month_long = - [| "January"; "February"; "March"; "April"; "May"; "June"; "July"; - "August"; "September"; "October"; "November"; "December" |] - -let weekday_names = - [| "mon"; "tue"; "wed"; "thu"; "fri"; "sat"; "sun"; "monday"; "tuesday"; - "wednesday"; "thursday"; "friday"; "saturday"; "sunday" |] - -(* common-date/built-in-journal-title-formatters *) -let built_in_journal_title_formatters = - [ "do MMM yyyy"; "do MMMM yyyy"; "MMM do, yyyy"; "MMMM do, yyyy"; - "E, dd-MM-yyyy"; "E, dd.MM.yyyy"; "E, MM/dd/yyyy"; "E, yyyy/MM/dd"; - "EEE, dd-MM-yyyy"; "EEE, dd.MM.yyyy"; "EEE, MM/dd/yyyy"; - "EEE, yyyy/MM/dd"; "EEEE, dd-MM-yyyy"; "EEEE, dd.MM.yyyy"; - "EEEE, MM/dd/yyyy"; "EEEE, yyyy/MM/dd"; "dd-MM-yyyy"; "MM/dd/yyyy"; - "MM-dd-yyyy"; "MM_dd_yyyy"; "yyyy/MM/dd"; "yyyy-MM-dd"; - "yyyy-MM-dd EEE"; "yyyy-MM-dd EEEE"; "yyyy_MM_dd"; "yyyyMMdd"; - "yyyy\xE5\xB9\xB4MM\xE6\x9C\x88dd\xE6\x97\xA5" (* yyyy年MM月dd日 *) ] - -(* common-date/journal-title-formatters — cljs (distinct (cons f - built-in)) keeps the first occurrence, so f always leads even when it - duplicates a built-in. *) -let journal_title_formatters (date_formatter : string option) : string list = - match date_formatter with - | None -> built_in_journal_title_formatters - | Some f -> f :: List.filter (fun x -> x <> f) built_in_journal_title_formatters - -(* ---- formatter lexer ---- *) - -(* Token widths recorded for the inverse (format) direction: - yy vs yyyy, MMM vs MMMM, E/EEE vs EEEE. *) -type tok = +let month_short = Journal.month_short +let month_long = Journal.month_long +let weekday_names = Journal.weekday_names +let built_in_journal_title_formatters = Journal.built_in_journal_title_formatters +let journal_title_formatters = Journal.journal_title_formatters +type tok = Journal.tok = | TokYear of int | TokMonthNum | TokMonthName of int @@ -952,28 +922,7 @@ type tok = | TokDayOrd | TokWeekday of int | TokLit of char - -let tokens_of_formatter (fmt : string) : tok list = - let n = String.length fmt in - let try_at i t tok = - let l = String.length t in - if i + l <= n && String.sub fmt i l = t then Some (tok, i + l) else None - in - let rec lex i acc = - if i >= n then List.rev acc - else - match - List.find_map Fun.id - [ try_at i "yyyy" (TokYear 4); try_at i "yy" (TokYear 2); - try_at i "MMMM" (TokMonthName 4); try_at i "MMM" (TokMonthName 3); - try_at i "MM" TokMonthNum; try_at i "dd" TokDay; - try_at i "do" TokDayOrd; try_at i "EEEE" (TokWeekday 4); - try_at i "EEE" (TokWeekday 3); try_at i "E" (TokWeekday 3) ] - with - | Some (t, i') -> lex i' (t :: acc) - | None -> lex (i + 1) (TokLit fmt.[i] :: acc) - in - lex 0 [] +let tokens_of_formatter = Journal.tokens_of_formatter let is_digit c = c >= '0' && c <= '9' @@ -1457,62 +1406,10 @@ let utc_ms_to_journal_day (ms : int64) : int = (* ---------- journal title formatting ---------- *) -let ordinal_suffix d = - if d >= 11 && d <= 13 then "th" - else match d mod 10 with - | 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th" - -(* Zeller-congruence weekday index aligned with weekday_names: - 0 = Mon .. 6 = Sun. *) -let weekday_index (y, m, d) = - let y', m' = if m < 3 then (y - 1, m + 12) else (y, m) in - let k = y' mod 100 and j = y' / 100 in - (* h: 0 = Saturday *) - let h = - (d + ((13 * (m' + 1)) / 5) + k + (k / 4) + (j / 4) + (5 * j)) mod 7 - in - let h = ((h mod 7) + 7) mod 7 in - (h + 5) mod 7 - -(* tf/format inverse of date_of_formatter for the tokenized - journal-title formatters. *) -let formatter_of_date (fmt : string) (year, month, day : int * int * int) - : string = - let pad2 n = Printf.sprintf "%02d" n in - let b = Buffer.create 32 in - List.iter - (fun tok -> - match tok with - | TokLit c -> Buffer.add_char b c - | TokYear w -> - if w = 2 then Buffer.add_string b (pad2 (year mod 100)) - else Buffer.add_string b (Printf.sprintf "%04d" year) - | TokMonthNum -> Buffer.add_string b (pad2 month) - | TokMonthName w -> - Buffer.add_string - b - (if w = 4 then month_long.(month - 1) - else month_short.(month - 1)) - | TokDay -> Buffer.add_string b (pad2 day) - | TokDayOrd -> - Buffer.add_string - b - (string_of_int day ^ ordinal_suffix day) - | TokWeekday w -> - let i = weekday_index (year, month, day) in - let name = - if w = 4 then weekday_names.(i + 7) else weekday_names.(i) - in - (* tf/format emits capitalized English weekday names *) - Buffer.add_string b (String.capitalize_ascii name)) - (tokens_of_formatter fmt); - Buffer.contents b - -(* common-date/int->journal-title *) -let int_to_journal_title (day : int) (date_formatter : string) : string = - formatter_of_date - date_formatter - (day / 10000, (day / 100) mod 100, day mod 100) +let ordinal_suffix = Journal.ordinal_suffix +let weekday_index = Journal.weekday_index +let formatter_of_date = Journal.formatter_of_date +let int_to_journal_title = Journal.int_to_journal_title end diff --git a/shared/src/logseq/core/editor_command.ml b/shared/src/logseq/core/editor_command.ml index 7864affa..6ec5e016 100644 --- a/shared/src/logseq/core/editor_command.ml +++ b/shared/src/logseq/core/editor_command.ml @@ -73,10 +73,9 @@ let candidates db query = let journal_reference db ms = let day = Cache_model.journal_day_for_ms ms in - let title = Option.bind db (fun db -> Option.bind (Read.journal_page_uuid db day) - (fun uuid -> Option.bind (Ds.entid db "block/uuid" (Ds.Uuid uuid)) - (fun eid -> Read.string_value (Read.value db eid "block/title")))) in - "[[" ^ Option.value ~default:(Journal.day_title day) title ^ "]]" + let uuid = Option.bind db (fun db -> Read.journal_page_uuid db day) + |> Option.value ~default:(Journal.page_uuid day) in + "[[" ^ uuid ^ "]]" let date_ms value = try diff --git a/shared/src/logseq/core/editor_command_effects.ml b/shared/src/logseq/core/editor_command_effects.ml index 04b25484..077fc376 100644 --- a/shared/src/logseq/core/editor_command_effects.ml +++ b/shared/src/logseq/core/editor_command_effects.ml @@ -119,8 +119,8 @@ let plan db ~now ~fresh_uuid operation = | Ensure_journal ms -> let day = Cache_model.journal_day_for_ms ms in if Read.journal_page_uuid db day <> None then [] else - [Ops.Create_journal {page_uuid = Printf.sprintf "00000001-%04d-%04d-0000-000000000000" (day / 10000) (day mod 10000); - block_uuid = fresh_uuid (); block_title = ""; title = Journal.day_title day; journal_day = day; created_at = now}] + [Ops.Create_journal {page_uuid = Journal.page_uuid day; + block_uuid = fresh_uuid (); block_title = ""; title = Journal.title db day; journal_day = day; created_at = now}] | Insert_code (uuid, language) -> let target = eid db uuid in let page = match Read.ref_eids db target "block/page" with p :: _ -> Option.get (Read.uuid_for_eid db p) | _ -> invalid_arg "Missing page" in diff --git a/shared/src/logseq/core/graph_read.ml b/shared/src/logseq/core/graph_read.ml index c8ce1af6..b6c6dda4 100644 --- a/shared/src/logseq/core/graph_read.ml +++ b/shared/src/logseq/core/graph_read.ml @@ -36,6 +36,11 @@ let protected_string decrypt_title value = | Error message -> failwith ("decrypt graph title: " ^ message)) | None -> None +let entity_title decrypt_title db eid = + match int_value (value db eid "block/journal-day") with + | Some day when Journal.valid_day day -> Some (Journal.title db day) + | _ -> protected_string decrypt_title (value db eid "block/title") + let uuid_for_eid db eid = uuid_value (value db eid "block/uuid") let has_ref db eid attr target_eid = @@ -135,13 +140,13 @@ let display_title decrypt_title db title = | Some eid -> Option.map (fun source -> let title = if depth >= 16 || List.mem uuid seen then "…" else resolve (uuid :: seen) (depth + 1) source in - {Model.uuid; title}) (protected_string decrypt_title (value db eid "block/title"))) in + {Model.uuid; title}) (entity_title decrypt_title db eid)) in Markup.plain_text (Markup.parse summaries summaries title) in resolve [] 0 title let entity_summary decrypt_title db eid = let uuid = uuid_for_eid db eid in - let title = protected_string decrypt_title (value db eid "block/title") in + let title = entity_title decrypt_title db eid in match (uuid, title) with | Some uuid, Some title -> Some { Model.uuid; title = display_title decrypt_title db title } | _ -> None @@ -236,7 +241,7 @@ let property_choices decrypt_title db property = |> List.filter_map (fun eid -> Option.map (fun uuid -> let ident = match value db eid "db/ident" with Some (Ds.Keyword ident) -> Some ident | _ -> None in - let title = Option.value ~default:uuid (protected_string decrypt_title (value db eid "block/title")) in + let title = Option.value ~default:uuid (entity_title decrypt_title db eid) in let icon_field field = match value db eid "logseq.property/icon" with | Some (Ds.Map fields) -> (match List.assoc_opt (Ds.Keyword field) fields with | Some (Ds.String text) | Some (Ds.Keyword text) -> Some text | _ -> None) @@ -264,7 +269,7 @@ let read_block ?(with_breadcrumbs = true) ?page_info ?fields decrypt_title db ei if entity = eid then Hashtbl.find_opt attributes attr else original_value db entity attr in let uuid = uuid_value (value db eid "block/uuid") in - let title = protected_string decrypt_title (value db eid "block/title") in + let title = entity_title decrypt_title db eid in let name = value db eid "block/name" in match name with | Some _ -> None @@ -300,8 +305,7 @@ let read_block ?(with_breadcrumbs = true) ?page_info ?fields decrypt_title db ei let title = match ancestors with | [] -> - protected_string decrypt_title - (value db page_eid "block/title") + entity_title decrypt_title db page_eid | _ -> List.find_map (fun (summary : Model.entity_summary) -> @@ -372,7 +376,7 @@ let library_entity db eid = let page_summary decrypt_title db eid = let uuid = uuid_for_eid db eid in - let title = protected_string decrypt_title (value db eid "block/title") in + let title = entity_title decrypt_title db eid in let name = string_value (value db eid "block/name") in match (uuid, title, name) with | Some uuid, Some title, Some name -> @@ -964,7 +968,7 @@ let read_page_window ?(offset = 0) ?(limit = 32) decrypt_title reader = | Some eid -> (match int_value (value db eid "block/journal-day") with | Some day -> Option.map (fun title -> title, day) - (protected_string decrypt_title (value db eid "block/title")) + (entity_title decrypt_title db eid) | None -> None) | None -> None in tree.page_uuid, journal) in @@ -999,7 +1003,7 @@ let blocks_for_page decrypt_title db uuid = | Some eid -> (match int_value (value db eid "block/journal-day") with | Some day -> Option.map (fun title -> title, day) - (protected_string decrypt_title (value db eid "block/title")) + (entity_title decrypt_title db eid) | None -> None) | None -> None in uuid, journal) in diff --git a/shared/src/logseq/core/graph_read.mli b/shared/src/logseq/core/graph_read.mli index 84ebdb56..7f353bac 100644 --- a/shared/src/logseq/core/graph_read.mli +++ b/shared/src/logseq/core/graph_read.mli @@ -156,3 +156,5 @@ val node_icon : Datascript.db -> string -> Yojson.Basic.t option val property_choices : (string -> (string, string) result) -> Datascript.db -> string -> Cache_model.status list val display_title : (string -> (string, string) result) -> Datascript.db -> string -> string + +val entity_title : (string -> (string, string) result) -> Datascript.db -> int -> string option diff --git a/shared/src/logseq/core/graph_runtime.ml b/shared/src/logseq/core/graph_runtime.ml index 0d491687..a7272171 100644 --- a/shared/src/logseq/core/graph_runtime.ml +++ b/shared/src/logseq/core/graph_runtime.ml @@ -544,11 +544,9 @@ let ensure_today_journal runtime = (queued_operation runtime (fresh_uuid ()) (Ops.Create_journal { - Ops.page_uuid = - Printf.sprintf "00000001-%04d-%04d-0000-000000000000" - (day / 10000) (day mod 10000); + Ops.page_uuid = Journal.page_uuid day; block_uuid = fresh_uuid (); block_title = ""; - title = Journal.day_title day; + title = Journal.title (db runtime) day; journal_day = day; created_at; })) diff --git a/shared/src/logseq/core/graph_sqlite.ml b/shared/src/logseq/core/graph_sqlite.ml index 4db2fef4..ea45a10b 100644 --- a/shared/src/logseq/core/graph_sqlite.ml +++ b/shared/src/logseq/core/graph_sqlite.ml @@ -365,7 +365,7 @@ let search_source_version path = (fun row -> (Db.column_text row 0, Db.column_int row 1, Db.column_text row 2, Db.column_text row 3)) in Some (Digest.to_hex (Digest.string - (Marshal.to_string ("search-index-v1", roots, pending) [])))) + (Marshal.to_string ("search-index-v2", roots, pending) [])))) let search_version path = with_search_db path (fun connection -> diff --git a/shared/src/logseq/core/journal.ml b/shared/src/logseq/core/journal.ml index 5c122749..3cd513ad 100644 --- a/shared/src/logseq/core/journal.ml +++ b/shared/src/logseq/core/journal.ml @@ -1,14 +1,148 @@ -let month_names = - [ "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; "Jul"; "Aug"; "Sep"; "Oct"; - "Nov"; "Dec" ] - -let day_title journal_day = - let year = journal_day / 10000 in - let month = journal_day / 100 mod 100 in - let day = journal_day mod 100 in - let suffix = - if 11 <= day && day <= 13 then "th" - else match day mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th" +module D = Datascript + +let default_format = "MMM do, yyyy" +let format_attr = "logseq.property.journal/title-format" + +let month_short = + [| "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; "Jul"; "Aug"; "Sep"; "Oct"; + "Nov"; "Dec" |] + +let month_long = + [| "January"; "February"; "March"; "April"; "May"; "June"; "July"; + "August"; "September"; "October"; "November"; "December" |] + +let weekday_names = + [| "mon"; "tue"; "wed"; "thu"; "fri"; "sat"; "sun"; "monday"; "tuesday"; + "wednesday"; "thursday"; "friday"; "saturday"; "sunday" |] + +(* common-date/built-in-journal-title-formatters *) +let built_in_journal_title_formatters = + [ "do MMM yyyy"; "do MMMM yyyy"; "MMM do, yyyy"; "MMMM do, yyyy"; + "E, dd-MM-yyyy"; "E, dd.MM.yyyy"; "E, MM/dd/yyyy"; "E, yyyy/MM/dd"; + "EEE, dd-MM-yyyy"; "EEE, dd.MM.yyyy"; "EEE, MM/dd/yyyy"; + "EEE, yyyy/MM/dd"; "EEEE, dd-MM-yyyy"; "EEEE, dd.MM.yyyy"; + "EEEE, MM/dd/yyyy"; "EEEE, yyyy/MM/dd"; "dd-MM-yyyy"; "MM/dd/yyyy"; + "MM-dd-yyyy"; "MM_dd_yyyy"; "yyyy/MM/dd"; "yyyy-MM-dd"; + "yyyy-MM-dd EEE"; "yyyy-MM-dd EEEE"; "yyyy_MM_dd"; "yyyyMMdd"; + "yyyy\xE5\xB9\xB4MM\xE6\x9C\x88dd\xE6\x97\xA5" (* yyyy年MM月dd日 *) ] + +(* common-date/journal-title-formatters — cljs (distinct (cons f + built-in)) keeps the first occurrence, so f always leads even when it + duplicates a built-in. *) +let journal_title_formatters (date_formatter : string option) : string list = + match date_formatter with + | None -> built_in_journal_title_formatters + | Some f -> f :: List.filter (fun x -> x <> f) built_in_journal_title_formatters + +(* ---- formatter lexer ---- *) + +(* Token widths recorded for the inverse (format) direction: + yy vs yyyy, MMM vs MMMM, E/EEE vs EEEE. *) +type tok = + | TokYear of int + | TokMonthNum + | TokMonthName of int + | TokDay + | TokDayOrd + | TokWeekday of int + | TokLit of char + +let tokens_of_formatter (fmt : string) : tok list = + let n = String.length fmt in + let try_at i t tok = + let l = String.length t in + if i + l <= n && String.sub fmt i l = t then Some (tok, i + l) else None + in + let rec lex i acc = + if i >= n then List.rev acc + else + match + List.find_map Fun.id + [ try_at i "yyyy" (TokYear 4); try_at i "yy" (TokYear 2); + try_at i "MMMM" (TokMonthName 4); try_at i "MMM" (TokMonthName 3); + try_at i "MM" TokMonthNum; try_at i "dd" TokDay; + try_at i "do" TokDayOrd; try_at i "EEEE" (TokWeekday 4); + try_at i "EEE" (TokWeekday 3); try_at i "E" (TokWeekday 3) ] + with + | Some (t, i') -> lex i' (t :: acc) + | None -> lex (i + 1) (TokLit fmt.[i] :: acc) in - Printf.sprintf "%s %d%s, %04d" (List.nth month_names (month - 1)) day suffix - year + lex 0 [] + +let ordinal_suffix d = + if d >= 11 && d <= 13 then "th" + else match d mod 10 with + | 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th" + +(* Zeller-congruence weekday index aligned with weekday_names: + 0 = Mon .. 6 = Sun. *) +let weekday_index (y, m, d) = + let y', m' = if m < 3 then (y - 1, m + 12) else (y, m) in + let k = y' mod 100 and j = y' / 100 in + (* h: 0 = Saturday *) + let h = + (d + ((13 * (m' + 1)) / 5) + k + (k / 4) + (j / 4) + (5 * j)) mod 7 + in + let h = ((h mod 7) + 7) mod 7 in + (h + 5) mod 7 + +(* tf/format inverse of date_of_formatter for the tokenized + journal-title formatters. *) +let formatter_of_date (fmt : string) (year, month, day : int * int * int) + : string = + let pad2 n = Printf.sprintf "%02d" n in + let b = Buffer.create 32 in + List.iter + (fun tok -> + match tok with + | TokLit c -> Buffer.add_char b c + | TokYear w -> + if w = 2 then Buffer.add_string b (pad2 (year mod 100)) + else Buffer.add_string b (Printf.sprintf "%04d" year) + | TokMonthNum -> Buffer.add_string b (pad2 month) + | TokMonthName w -> + Buffer.add_string + b + (if w = 4 then month_long.(month - 1) + else month_short.(month - 1)) + | TokDay -> Buffer.add_string b (pad2 day) + | TokDayOrd -> + Buffer.add_string + b + (string_of_int day ^ ordinal_suffix day) + | TokWeekday w -> + let i = weekday_index (year, month, day) in + let name = + if w = 4 then weekday_names.(i + 7) else weekday_names.(i) + in + (* tf/format emits capitalized English weekday names *) + Buffer.add_string b (String.capitalize_ascii name)) + (tokens_of_formatter fmt); + Buffer.contents b + +(* common-date/int->journal-title *) +let int_to_journal_title (day : int) (date_formatter : string) : string = + formatter_of_date + date_formatter + (day / 10000, (day / 100) mod 100, day mod 100) + +let month_names = Array.to_list month_short + +let valid_day day = + let year = day / 10000 and month = day / 100 mod 100 and date = day mod 100 in + let leap = year mod 4 = 0 && (year mod 100 <> 0 || year mod 400 = 0) in + let days = match month with 2 -> if leap then 29 else 28 | 4 | 6 | 9 | 11 -> 30 | _ -> 31 in + year >= 1 && year <= 9999 && month >= 1 && month <= 12 && date >= 1 && date <= days + +let format db = + Option.bind (D.entid db "db/ident" (D.Keyword "logseq.class/Journal")) (fun eid -> + match Seq.uncons (D.Db.datoms db D.Eavt ~e:eid ~a:format_attr ()) with + | Some ({D.v = D.String value; _}, _) when String.trim value <> "" -> Some value + | _ -> None) + |> Option.value ~default:default_format + +let day_title day = int_to_journal_title day default_format +let title db day = int_to_journal_title day (format db) + +let page_uuid day = + Printf.sprintf "00000001-%04d-%04d-0000-000000000000" (day / 10000) (day mod 10000) diff --git a/shared/src/logseq/core/journal.mli b/shared/src/logseq/core/journal.mli index 970ecd20..f8bff115 100644 --- a/shared/src/logseq/core/journal.mli +++ b/shared/src/logseq/core/journal.mli @@ -1,4 +1,21 @@ -(** Journal title formatting. *) - +(** Journal title formatting shared by graph reads, writes, and queries. *) +val default_format : string +val format_attr : string val month_names : string list +val month_short : string array +val month_long : string array +val weekday_names : string array +val built_in_journal_title_formatters : string list +val journal_title_formatters : string option -> string list +type tok = TokYear of int | TokMonthNum | TokMonthName of int | TokDay | TokDayOrd | TokWeekday of int | TokLit of char +val tokens_of_formatter : string -> tok list +val ordinal_suffix : int -> string +val weekday_index : int * int * int -> int +val formatter_of_date : string -> int * int * int -> string +val int_to_journal_title : int -> string -> string +val valid_day : int -> bool +val format : Datascript.db -> string val day_title : int -> string +val title : Datascript.db -> int -> string + +val page_uuid : int -> string diff --git a/shared/src/logseq/core/pending_pump.ml b/shared/src/logseq/core/pending_pump.ml index 0075a5c8..a08ef583 100644 --- a/shared/src/logseq/core/pending_pump.ml +++ b/shared/src/logseq/core/pending_pump.ml @@ -128,13 +128,17 @@ let prepare_pending_creation session (pump : Types.pending_sync) (block : Model. | Some page -> prepare_pending_create_request session pump block title (Some page) | None -> let page = Rpc.journal_page_uuid day in - let journal_title = Rpc.journal_day_title day in + let journal_title = + let db = match (Types.host session).graph_db with + | Some read -> read () | None -> Some (Types.state session).model.db in + Option.fold ~none:(Rpc.journal_day_title day) ~some:(fun db -> Journal.title db day) db in + let journal_name = String.lowercase_ascii journal_title in let* journal_title = encrypted_title session pump.config journal_title in let* journal_name = encrypted_title session pump.config - (String.lowercase_ascii (Rpc.journal_day_title day)) + journal_name in set_pending_active session pump (Types.Json_request diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 8130bd21..726a5ff1 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -66,7 +66,10 @@ let temporal_value db p value = else "" let property_display db p value = - if p.typ = "date" then temporal_value db p value + if p.typ = "date" then + (match Option.bind (ref_id db value) (fun id -> G.int_value (G.value db id "block/journal-day")) with + | Some day when Journal.valid_day day -> Journal.title db day + | _ -> "") else if p.typ = "datetime" || List.mem p.attr ["block/created-at"; "block/updated-at"] then match number (content db value) with | Some millis when abs_float millis <= 8640000000000000. -> @@ -354,7 +357,7 @@ let edit_property ?(text_value = false) ?(allow_builtin = false) db ~now ~fresh_ | None -> let uuid = Rpc_wire.journal_page_uuid day in created := Ops.Create_journal {page_uuid = uuid; block_uuid = fresh_uuid (); block_title = ""; - title = Rpc_wire.journal_day_title day; journal_day = day; created_at = now} :: !created; + title = Journal.title db day; journal_day = day; created_at = now} :: !created; Ops.Ref_uuid uuid)) else if List.mem p.typ ["number"; "datetime"; "default"; "url"] then let content = if p.typ = "number" || p.typ = "datetime" then numeric text else Ops.String_value text in diff --git a/shared/src/logseq/core/rpc_ops.ml b/shared/src/logseq/core/rpc_ops.ml index 555596e2..5821527e 100644 --- a/shared/src/logseq/core/rpc_ops.ml +++ b/shared/src/logseq/core/rpc_ops.ml @@ -129,7 +129,7 @@ let capture_operation base_t uuid title now load_context journal_page_id { Ops.page_uuid = Wire.journal_page_uuid journal_day; block_uuid = uuid; block_title = title; - title = Wire.journal_day_title journal_day; + title = (match (load_context ()).db with Some db -> Journal.title db journal_day | None -> Wire.journal_day_title journal_day); journal_day; created_at = now; })) diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index 46c399d8..1094f9a6 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -92,6 +92,16 @@ let query_db session = | Some load -> load () | None -> Some (state session).model.db +let current_summary session (summary : Model.entity_summary) = + match Option.bind (query_db session) (fun db -> + Option.bind (Datascript.entid db "block/uuid" (Datascript.Uuid summary.uuid)) (fun eid -> + Option.bind (Graph_read.int_value (Graph_read.value db eid "block/journal-day")) (fun day -> + if Journal.valid_day day then Some (Journal.title db day) else None))) with + | Some title -> {summary with title} + | None -> summary + +let summary_json session summary = Rpc.summary_json (current_summary session summary) + let result_block_encoder db = let presentation = Node_presentation.decorate db in let pages = Hashtbl.create 4 in @@ -121,8 +131,8 @@ let result_block_encoder db = let query_for session db uuid = let current_page = match active_node_route session with - | Some route -> Some route.Types.page.title - | None -> Option.map (fun (page : Model.entity_summary) -> page.title) + | Some route -> Some (current_summary session route.Types.page).title + | None -> Option.map (fun (page : Model.entity_summary) -> (current_summary session page).title) (state session).selected_sidebar_page in let encode_tree = result_block_encoder db in Query_block.render ?current_page db uuid @@ -258,13 +268,13 @@ let node_routes_json session serialize serialize_plain = Rpc.json_object [ ("uuid", `String route.uuid) - ; ("breadcrumbs", Rpc.json_list (List.map Rpc.summary_json (route_breadcrumbs context route))) + ; ("breadcrumbs", Rpc.json_list (List.map (summary_json session) (route_breadcrumbs context route))) ; ("scrollTarget", match route.scroll_target with Some uuid -> `String uuid | None -> `Null) ; ("isTag", `Bool route.is_tag) ; ("isProperty", `Bool route.is_property) ; ("resultViews", object_views_for session route.uuid route.is_tag route.is_property) ; ("nodeProperties", node_properties_for session route.uuid) - ; ("page", Rpc.summary_json route.page) + ; ("page", summary_json session route.page) ; ( "blocks" , Rpc.json_list (List.map serialize (projection_blocks session (if (host session).Types.outliner_projection_limit = None then context.Outliner.blocks else ordered))) @@ -375,7 +385,8 @@ let snapshot session context_blocks blocks = | Some name -> `String name | None -> `Null) | None -> `Null ) - ; ( "selectedGraphId" + ; ("journalTitleFormat", `String (Option.fold ~none:Journal.default_format ~some:Journal.format (query_db session))) + ; ( "selectedGraphId" , match config with | Some config -> if config.Api.graph_id = "" then `Null @@ -385,14 +396,14 @@ let snapshot session context_blocks blocks = , Rpc.json_list (List.map Rpc.graph_json s.available_graphs) ) ; ( "favorites" , Rpc.json_list - (List.map Rpc.summary_json sidebar.Graph_read.favorites) ) + (List.map (summary_json session) sidebar.Graph_read.favorites) ) ; ( "recentPages" , Rpc.json_list - (List.map Rpc.summary_json sidebar.Graph_read.recent_pages) + (List.map (summary_json session) sidebar.Graph_read.recent_pages) ) ; ( "selectedPage" , match s.selected_sidebar_page with - | Some page -> Rpc.summary_json page + | Some page -> summary_json session page | None -> `Null ) ; ( "isGraphEncrypted" , `Bool (selected_graph_is_encrypted session) ) @@ -543,6 +554,7 @@ let graph_catalog_snapshot session = | Some name -> `String name | None -> `Null) | None -> `Null ) + ; ("journalTitleFormat", `String (Option.fold ~none:Journal.default_format ~some:Journal.format (query_db session))) ; ( "selectedGraphId" , match s.config with | Some config -> @@ -1073,16 +1085,9 @@ let graph_asset session fields downloaded = let ensure_journal session payload = let day = Option.bind payload int_of_string_opt in - let valid day = - let year = day / 10000 and month = day / 100 mod 100 and date = day mod 100 in - let leap = year mod 4 = 0 && (year mod 100 <> 0 || year mod 400 = 0) in - let days = [|31; (if leap then 29 else 28); 31; 30; 31; 30; 31; 31; 30; 31; 30; 31|] in - year >= 1 && year <= 9999 && month >= 1 && month <= 12 - && date >= 1 && date <= days.(month - 1) - in match day with | None -> Rpc.failure "invalid_params" "A journal date is required" - | Some day when not (valid day) -> Rpc.failure "invalid_params" "The journal date is invalid" + | Some day when not (Journal.valid_day day) -> Rpc.failure "invalid_params" "The journal date is invalid" | Some day -> let existing = Option.bind (host session).Types.journal_page_id (fun find -> find day) in let result = match existing, projection_server_t session, (state session).config with @@ -1092,7 +1097,7 @@ let ensure_journal session payload = let operation = { Ops.operation_id = fresh_squuid (); base_t; state = Ops.Queued; intent = Ops.Create_journal { Ops.page_uuid = uuid; block_uuid = fresh_squuid (); block_title = ""; - title = Rpc.journal_day_title day; journal_day = day; created_at = now_ms () } } in + title = (match query_db session with Some db -> Journal.title db day | None -> Rpc.journal_day_title day); journal_day = day; created_at = now_ms () } } in Result.map (fun () -> uuid) (enqueue_semantic session operation) | None, _, _ -> Error "Select a graph before creating a journal" in @@ -1100,6 +1105,34 @@ let ensure_journal session payload = | Ok uuid -> success session (Rpc.json_object ["uuid", `String uuid]) | Error message -> Rpc.failure "journal_creation_failed" message +let set_journal_title_format session fields = + let result = + let ( let* ) = Result.bind in + let* graph_id = Rpc.required_string fields "graphId" in + let* format = Rpc.required_string fields "format" in + let* () = match (state session).config with + | Some config when config.Api.graph_id = graph_id -> Ok () + | _ -> Error "The active graph changed. Reopen settings to change its date format" in + let* () = if List.mem format Journal.built_in_journal_title_formatters then Ok () + else Error "Choose a supported date format" in + let* db = Option.to_result ~none:"The graph is unavailable" (query_db session) in + let* eid = Option.to_result ~none:"The Journal class is missing" + (Datascript.entid db "db/ident" (Datascript.Keyword "logseq.class/Journal")) in + let* uuid = Option.to_result ~none:"The Journal class has no UUID" (Graph_read.uuid_for_eid db eid) in + let* base_t = match editing_cursor session with Ok value -> Ok value | Error (_, message) -> Error message in + let* expected = match Graph_read.value db eid Journal.format_attr with + | None -> Ok None + | Some (Datascript.String value) -> Ok (Some (Ops.String_value value)) + | _ -> Error "The existing journal format is invalid" in + let value = Some (Ops.String_value format) in + if expected = value then Ok () else + enqueue_semantic session {Ops.operation_id = fresh_squuid (); base_t; state = Ops.Queued; + intent = Ops.Set_property {uuid; attr = Journal.format_attr; expected; value}} + in + match result with + | Ok () -> snapshot_visible session + | Error message -> Rpc.failure "date_format_failed" message + let select_page session payload = match ( payload @@ -1824,6 +1857,9 @@ let rec dispatch session action payload = snapshot_visible session) | "selectGraph" -> select_graph session payload | "ensureJournal" -> ensure_journal session payload + | "setJournalTitleFormat" -> + with_object "setJournalTitleFormat" payload "A date format is required" + (fun _ fields -> set_journal_title_format session fields) | "selectPage" -> select_page session payload | "hydrateOutlinerRows" -> hydrate_outliner_rows session payload | "queryBlock" -> diff --git a/shared/src/logseq/core/rpc_wire.ml b/shared/src/logseq/core/rpc_wire.ml index baeaab53..0c7a2805 100644 --- a/shared/src/logseq/core/rpc_wire.ml +++ b/shared/src/logseq/core/rpc_wire.ml @@ -6,32 +6,8 @@ module Outliner = Outliner_state module Cards = Flashcards module Api_ = Api -let journal_page_uuid journal_day = - Printf.sprintf "00000001-%04d-%04d-0000-000000000000" - (journal_day / 10000) (journal_day mod 10000) - -let journal_day_title journal_day = - let months = - [| - "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; "Jul"; "Aug"; "Sep" - ; "Oct"; "Nov"; "Dec" - |] - in - let year = journal_day / 10000 in - let month = journal_day / 100 mod 100 in - let day = journal_day mod 100 in - let suffix = - if day mod 100 >= 11 && day mod 100 <= 13 then "th" - else - match day mod 10 with - | 1 -> "st" - | 2 -> "nd" - | 3 -> "rd" - | _ -> "th" - in - if month >= 1 && month <= Array.length months then - Printf.sprintf "%s %d%s, %04d" months.(month - 1) day suffix year - else invalid_arg "invalid journal month" +let journal_page_uuid = Journal.page_uuid +let journal_day_title = Journal.day_title let fields_of entries = List.fold_left diff --git a/shared/src/logseq/core/search_index.ml b/shared/src/logseq/core/search_index.ml index f6d493ad..fca21e38 100644 --- a/shared/src/logseq/core/search_index.ml +++ b/shared/src/logseq/core/search_index.ml @@ -149,14 +149,14 @@ let row_of_eid db eid = let title_for_uuid uuid = match Ds.entid db "block/uuid" (Ds.Uuid uuid) with | Some eid -> - (match Graph.string_value (Graph.value db eid "block/title") with + (match Graph.entity_title (fun value -> Ok value) db eid with | Some title when String.trim title <> "" -> Some title | _ -> None) | None -> None in match Graph.uuid_value (Graph.value db eid "block/uuid") with | Some uuid -> - (match Graph.string_value (Graph.value db eid "block/title") with + (match Graph.entity_title (fun value -> Ok value) db eid with | Some title -> let hidden = Graph.value db eid "logseq.property/built-in?" = Some (Ds.Bool true) @@ -216,6 +216,12 @@ let referring_uuids db uuid = | None -> [] let refresh_uuids index before after uuids = + let uuids = + if Journal.format before = Journal.format after then uuids + else + Seq.fold_left (fun uuids (datom : Ds.datom) -> + match Graph.uuid_for_eid after datom.e with Some uuid -> uuid :: uuids | None -> uuids) + uuids (Ds.Db.datoms after Ds.Aevt ~a:"block/journal-day" ()) in let seen = Hashtbl.create 32 in let affected = List.concat_map @@ -445,7 +451,11 @@ let hydrate_hits db results = in { uuid = result.uuid; - title = result.title; + title = (match entity_eid result.uuid with + | Some eid when Option.fold ~none:false ~some:Journal.valid_day + (Graph.int_value (Graph.value db eid "block/journal-day")) -> + Option.value ~default:result.title (Graph.entity_title plain db eid) + | _ -> result.title); is_page = result.is_page; page; breadcrumbs; diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index bd346885..e9968363 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -223,6 +223,7 @@ type core_projection = { tag_choices_update : sidebar_page list option ; graph_name : string option ; selected_graph_id : string option + ; journal_title_format : string option ; graphs : graph list ; is_graph_encrypted : bool ; is_graph_unlocked : bool @@ -272,6 +273,7 @@ type composer_submission = type logseq_effect = | ResultViewEffect of int * string * string | NodePropertyEffect of int * string * string + | SetJournalTitleFormatEffect of int * string | LoadComposerTagsEffect of int * string option | SendTaggedCaptureEffect of int * composer_submission | SendCaptureEffect of int * string @@ -335,6 +337,7 @@ type logseq_effect = type logseq_model = { selected_graph : string option ; selected_graph_id : string option + ; journal_title_format : string ; graphs : graph list ; local_graph_ids : string list ; recent_graph_ids : string list @@ -564,6 +567,7 @@ type logseq_action = | OpenSettingsLanguageMenu | CloseSettingsLanguageMenu | ChooseSettingsLanguage of string + | ChooseJournalTitleFormat of string | ToggleSpellCheck of bool | ToggleAutoCorrection of bool | ToggleSidebarTab of string @@ -679,6 +683,7 @@ let initial () = { selected_graph = None; selected_graph_id = None; + journal_title_format = Journal.default_format; graphs = []; local_graph_ids = []; recent_graph_ids = []; @@ -821,7 +826,7 @@ let navigation_route_uuid route = let effect_id eff = match eff with - | ResultViewEffect (id, _, _) | NodePropertyEffect (id, _, _) -> id + | ResultViewEffect (id, _, _) | NodePropertyEffect (id, _, _) | SetJournalTitleFormatEffect (id, _) -> id | LoadComposerTagsEffect (id, _) -> id | SendTaggedCaptureEffect (id, _) -> id | SendCaptureEffect (id, _) -> id @@ -2791,6 +2796,12 @@ let rec update (current : logseq_model) action = settings_language_menu_open = false; } | None -> { current with settings_language_menu_open = false }) + | ChooseJournalTitleFormat format -> + (match current.selected_graph_id with + | Some graph_id when List.mem format Journal.built_in_journal_title_formatters -> + let payload = Yojson.Basic.to_string (`Assoc ["graphId", `String graph_id; "format", `String format]) in + enqueue_effect current (SetJournalTitleFormatEffect (current.next_effect_id, payload)) + | _ -> current) | ToggleSpellCheck enabled -> persist_settings_change current { current with spell_check = enabled } | ToggleAutoCorrection enabled -> @@ -2969,6 +2980,9 @@ and apply_core_snapshot (current : logseq_model) current with selected_graph = projection.graph_name; selected_graph_id = projection.selected_graph_id; + journal_title_format = Option.value + ~default:(if current.selected_graph_id = projection.selected_graph_id then current.journal_title_format else Journal.default_format) + projection.journal_title_format; graphs = projection.graphs; is_graph_encrypted = projection.is_graph_encrypted; is_graph_unlocked = projection.is_graph_unlocked; @@ -3126,6 +3140,9 @@ and apply_core_snapshot (current : logseq_model) search_navigation_path; selected_graph = projection.graph_name; selected_graph_id = projection.selected_graph_id; + journal_title_format = Option.value + ~default:(if current.selected_graph_id = projection.selected_graph_id then current.journal_title_format else Journal.default_format) + projection.journal_title_format; composer_tags = (if projection.selected_graph_id = current.selected_graph_id then current.composer_tags else []); composer_tag_choices = (if projection.selected_graph_id = current.selected_graph_id then current.composer_tag_choices else []); composer_tags_open = current.composer_tags_open && projection.selected_graph_id = current.selected_graph_id; diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index 6f4b46f4..4e673b41 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -220,6 +220,7 @@ type core_projection = { tag_choices_update : sidebar_page list option ; graph_name : string option ; selected_graph_id : string option + ; journal_title_format : string option ; graphs : graph list ; is_graph_encrypted : bool ; is_graph_unlocked : bool @@ -269,6 +270,7 @@ type composer_submission = type logseq_effect = | ResultViewEffect of int * string * string | NodePropertyEffect of int * string * string + | SetJournalTitleFormatEffect of int * string | LoadComposerTagsEffect of int * string option | SendTaggedCaptureEffect of int * composer_submission | SendCaptureEffect of int * string @@ -332,6 +334,7 @@ type logseq_effect = type logseq_model = { selected_graph : string option ; selected_graph_id : string option + ; journal_title_format : string ; graphs : graph list ; local_graph_ids : string list ; recent_graph_ids : string list @@ -561,6 +564,7 @@ type logseq_action = | OpenSettingsLanguageMenu | CloseSettingsLanguageMenu | ChooseSettingsLanguage of string + | ChooseJournalTitleFormat of string | ToggleSpellCheck of bool | ToggleAutoCorrection of bool | ToggleSidebarTab of string diff --git a/shared/src/logseq/native_bridge.ml b/shared/src/logseq/native_bridge.ml index 9c2077ae..a6abf90e 100644 --- a/shared/src/logseq/native_bridge.ml +++ b/shared/src/logseq/native_bridge.ml @@ -281,6 +281,8 @@ let encode_effect (eff : Model.logseq_effect) = ^ Lui_wire.quoted uuid ^ "}" | ResultViewEffect (id, _, payload) -> Yojson.Basic.to_string (`Assoc ["id", `Int id; "kind", `String "result-view"; "text", `String payload]) + | SetJournalTitleFormatEffect (id, payload) -> + Yojson.Basic.to_string (`Assoc ["id", `Int id; "kind", `String "set-journal-title-format"; "text", `String payload]) | NodePropertyEffect (id, _, payload) -> Yojson.Basic.to_string (`Assoc ["id", `Int id; "kind", `String "node-property"; "text", `String payload]) | SyncNowEffect id -> diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index e1ba2e0b..508e98b2 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -449,6 +449,7 @@ let core_projection fields = | Some (`List choices) -> Some (List.filter_map sidebar_page choices) | _ -> None); graph_name = string_member "graphName" fields; selected_graph_id = string_member "selectedGraphId" fields; + journal_title_format = string_member "journalTitleFormat" fields; graphs = List.filter_map graph (list_member "graphs" fields); is_graph_encrypted = bool_member "isGraphEncrypted" fields; is_graph_unlocked = bool_member "isGraphUnlocked" fields; diff --git a/shared/src/logseq/view_settings.ml b/shared/src/logseq/view_settings.ml index 04ffd119..471e5ff0 100644 --- a/shared/src/logseq/view_settings.ml +++ b/shared/src/logseq/view_settings.ml @@ -12,6 +12,30 @@ let with_native_control_tint role (control : t) : t = tweak | _ -> control context parent +let settings_date_format_control context model_source send : t = + let formats = List.sort String.compare Journal.built_in_journal_title_formatters in + let selected format = Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format = format) model_source in + let choose format _ = ignore (send (Model.ChooseJournalTitleFormat format)) in + if Lui_ui.host context <> KotlinHost then + radio_group ~label:"Date format" ~style_class:"menu" + ~accessibility_identifier:"picker.settings.date-format" + (List.map (fun format -> radio ~text:format ~checked_signal:(selected format) + ~accessibility_identifier:("settings.date-format.option." ^ format) + ~on_change:(choose format) []) formats) + |> with_native_control_tint "picker" + else row ~min_height:44 ~padding_horizontal:16 ~cross:`center + [text ~value:"Date format" []; spacer []; + menu ~label:"Date format" ~foreground:"secondary" + ~text_signal:(Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format) model_source) + ~disabled_signal:(Signal.map (fun (model : Model.logseq_model) -> model.selected_graph_id = None) model_source) + ~accessibility_identifier:"picker.settings.date-format" + (List.map (fun format -> + menu_item ~text:format + ~selected_signal:(Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format = format) model_source) + ~accessibility_identifier:("settings.date-format.option." ^ format) + ~on_press:(fun _ -> ignore (send (Model.ChooseJournalTitleFormat format))) []) + formats)] + let settings_language_choice_radio model_source (choice_source : Model.settings_language_choice Signal.signal) send : radio_el = @@ -755,6 +779,7 @@ let settings_kotlin_screen (context : Lui_ui.ui_context) model_source text ~style_class:"subheadline semibold" ~padding_horizontal:16 ~foreground:"primary" ~accessibility_identifier:"label.settings.editor" ~value:"Editor" []; + settings_date_format_control context model_source send; settings_editor_card context (Signal.map View_base.settings_spell_check model_source) (Signal.map View_base.settings_auto_correction model_source) @@ -882,6 +907,7 @@ let settings_form_screen (context : Lui_ui.ui_context) model_source send ]; heading ~value:"Editor" ~foreground:"muted-foreground" ~accessibility_identifier:"label.settings.editor" []; + settings_date_format_control context model_source send; with_native_control_tint "switch" (toggle ~checked_signal: (Signal.map View_base.settings_spell_check model_source) diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index b2e637bf..817cc1ae 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -334,6 +334,7 @@ let empty_core_projection () = Model.tag_choices_update = None; graph_name = None; selected_graph_id = None; + journal_title_format = None; graphs = []; is_graph_encrypted = false; is_graph_unlocked = false; diff --git a/shared/test/logseq/graph_read_test.ml b/shared/test/logseq/graph_read_test.ml index cc847b2e..2bdf73ac 100644 --- a/shared/test/logseq/graph_read_test.ml +++ b/shared/test/logseq/graph_read_test.ml @@ -190,7 +190,7 @@ let incremental_projection_refreshes_dependencies_and_evicts_recycled_journals check (!decrypted <= 4); check_eq (List.map (fun (b : Model.block) -> b.journal) (Projection.blocks current)) - [ Some ("Journal updated", 20260816); Some ("Journal updated", 20260816) ]; + [ Some ("Aug 16th, 2026", 20260816); Some ("Aug 16th, 2026", 20260816) ]; transact conn [ Ds.RetractEntity (Ds.Entity_id (eid (Ds.conn_db conn) first_id)) ]; Projection.update current (Ds.conn_db conn) (change 4 [] [ wire_id first_id ]); @@ -450,7 +450,7 @@ let graph_blocks_preserve_identities_instants_order_and_decrypt_journal_titles check_eq block.page_id page_id; check_eq block.order (Some "a1"); check_eq block.created_at 1776000000000; - check_eq block.journal (Some ("Page", 20260815)); + check_eq block.journal (Some ("Aug 15th, 2026", 20260815)); add conn block_id "block/title" (Ds.String "cipher-block"); add conn page_id "block/title" (Ds.String "cipher-page"); let decrypt value = @@ -466,7 +466,7 @@ let graph_blocks_preserve_identities_instants_order_and_decrypt_journal_titles [ "Decrypted block" ]; check_eq (List.map (fun (b : Model.block) -> b.journal) blocks) - [ Some ("Decrypted journal", 20260815) ] + [ Some ("Aug 15th, 2026", 20260815) ] let reference_target_renames_refresh_projections_and_public_tags_stay_visible () = @@ -691,7 +691,7 @@ let restored_raw_numeric_references_remain_navigable () = let block = List.hd blocks in check_eq block.page_id "raw-page"; check_eq block.parent_id (Some "raw-page"); - check_eq block.journal (Some ("Raw journal", 20260817)) + check_eq block.journal (Some ("Aug 17th, 2026", 20260817)) let title_normalization_resolves_unique_names_and_shares_new_tags_across_titles () = @@ -911,10 +911,11 @@ let large_pages_decrypt_their_shared_journal_metadata_once () = in let blocks = Read.blocks_for_page decrypt (Ds.conn_db conn) page_id in check_eq (List.length blocks) 10_000; - check_eq ~msg:"the same page metadata must not be queried and decrypted for every row" - !page_reads 1; - check (List.for_all (fun (block : Model.block) -> - block.page_id = page_id && block.journal = Some ("Shared journal", 20261019)) blocks) + check_eq ~msg:"derived journal titles do not decrypt the stored page title" !page_reads 0; + let shared = (List.hd blocks).Model.journal in + check_eq shared (Some ("Oct 19th, 2026", 20261019)); + check ~msg:"10k rows reuse the same derived page metadata" + (List.for_all (fun (block : Model.block) -> block.page_id = page_id && block.journal == shared) blocks) let window_conn () = new_conn ["block/page"; "block/parent"; "block/order"; "block/created-at"; diff --git a/shared/test/logseq/graph_runtime_test.ml b/shared/test/logseq/graph_runtime_test.ml index 8271d834..9895cf85 100644 --- a/shared/test/logseq/graph_runtime_test.ml +++ b/shared/test/logseq/graph_runtime_test.ml @@ -1294,6 +1294,9 @@ let persisted_offline_pages_reopen_for_every_transport_state () = let sidebar_cache_reuses_unchanged_reads_and_invalidates_on_rebase () = with_runtime (fun path conn current -> + (* Ordinary page renames invalidate labels; journal labels derive from the calendar day. *) + ignore (Ds.transact_conn conn [Ds.Retract (Ds.Entity_id 1, "block/journal-day", Some (Ds.Int64 20260816L))]); + Runtime.rebase current 42 [] []; let initial = Runtime.sidebar_pages current in Gc.full_major (); let before = Gc.allocated_bytes () in diff --git a/shared/test/logseq/journal_format_test.ml b/shared/test/logseq/journal_format_test.ml new file mode 100644 index 00000000..8d47ded2 --- /dev/null +++ b/shared/test/logseq/journal_format_test.ml @@ -0,0 +1,221 @@ +open Test_util +module D = Datascript +module J = Yojson.Basic +module R = Result_view_test +module Q = Query_block_test +let member = Q.member +let format_attr = "logseq.property.journal/title-format" +let journal_class db = Option.get (D.entid db "db/ident" (D.Keyword "logseq.class/Journal")) +let stored_format runtime = Graph_read.value (Graph_runtime.db runtime) (journal_class (Graph_runtime.db runtime)) format_attr +let set_format ?(graph = "plain-1") session format = Rpc_test.dispatch_json session "setJournalTitleFormat" + (J.to_string (`Assoc ["graphId", `String graph; "format", `String format])) +let success response = check_eq ~msg:(J.to_string response) (member "ok" response) (`Bool true) +let stored_title db uuid = + let id = Option.get (D.entid db "block/uuid" (D.Uuid uuid)) in + match D.Db.datoms db D.Eavt ~e:id ~a:"block/title" () |> Seq.uncons with + | Some (d, _) -> d.D.v | None -> D.Nil +let display_title db uuid = + let id = Option.get (D.entid db "block/uuid" (D.Uuid uuid)) in + Option.map (fun (node : Cache_model.entity_summary) -> node.title) (Graph_read.entity_summary (fun s -> Ok s) db id) + +let existing_journals_reformat_without_identity_changes () = R.with_runtime (fun session runtime _ -> + success (Rpc_test.dispatch_json session "ensureJournal" "20261023"); + let uuid = Rpc_wire.journal_page_uuid 20261023 in + let raw = stored_title (Graph_runtime.db runtime) uuid in + let response = set_format session "yyyy-MM-dd" in success response; + check_eq ~msg:"the preference is the master's graph property" (stored_format runtime) (Some (D.String "yyyy-MM-dd")); + check_eq ~msg:"changing display format never renames or duplicates stored journals" (stored_title (Graph_runtime.db runtime) uuid) raw; + check_eq ~msg:"existing journal objects read through the chosen format" (display_title (Graph_runtime.db runtime) uuid) (Some "2026-10-23"); + check_eq ~msg:"settings receive the graph preference" (member "journalTitleFormat" (member "result" response)) (`String "yyyy-MM-dd"); + success (set_format session "yyyy年MM月dd日"); + check_eq (display_title (Graph_runtime.db runtime) uuid) (Some "2026年10月23日")) + +let new_captures_and_restart_keep_the_graph_format () = R.with_runtime (fun session runtime conn -> + success (set_format session "yyyy-MM-dd"); + let now = 1792719000000 in + let day = Cache_model.journal_day_for_ms now in + let expected = Printf.sprintf "%04d-%02d-%02d" (day / 10000) (day / 100 mod 100) (day mod 100) in + let uuid = "82000000-0000-4000-8000-000000000001" in + success (Rpc_test.dispatch_json session "send" (J.to_string (`Assoc ["uuid", `String uuid; "text", `String "Format capture"; "now", `Int now]))); + let page = Rpc_wire.journal_page_uuid day in + check_eq ~msg:"new journal creation uses the same preference" (stored_title (Graph_runtime.db runtime) page) (D.String expected); + check_eq (Editor_command_test.value runtime uuid "block/title") (Some (D.String "Format capture")); + let restored = Graph_runtime.create runtime.path 42 conn {Graph_runtime.default_options with auto_create_today = false} in + check_eq ~msg:"the preference survives pending-log restart" (stored_format restored) (Some (D.String "yyyy-MM-dd")); + check_eq (display_title (Graph_runtime.db restored) page) (Some expected); + Composer_tag_test.sync_all runtime conn; + check_eq ~msg:"confirmed sync retains the graph property" (stored_format runtime) (Some (D.String "yyyy-MM-dd"))) + +let property_values_and_reference_labels_share_the_format () = R.with_runtime (fun session runtime _ -> + success (set_format session "dd-MM-yyyy"); + let db = Graph_runtime.db runtime in + let property = Result_view.property_json db ~owner:Q.alpha ~attr:R.due ~encode_block:(fun _ -> `Null) in + check_eq ~msg:"date property labels follow the graph preference" (member "text" property) (`String "01-10-2026"); + check_eq ~msg:"native day-picker wire values remain calendar dates" (member "editorValue" property) (`String "2026-10-01"); + let title = Graph_read.display_title (fun s -> Ok s) db ("[[" ^ R.day_a ^ "]]") in + check ~msg:"journal references display the preferred title" (String_kit.index_of ~sub:"01-10-2026" ~start:0 title <> None); + check ~msg:"journal reference labels never show UUIDs" (String_kit.index_of ~sub:R.day_a ~start:0 title = None)) + +let invalid_and_stale_changes_never_write () = R.with_runtime (fun session runtime _ -> + let before = stored_format runtime in + List.iter (fun (graph, format) -> + let pending = Graph_runtime.pending_operations runtime in + let result = set_format ~graph session format in + check_eq ~msg:"reject unsupported or stale graph preferences" (member "ok" result) (`Bool false); + check_eq (Graph_runtime.pending_operations runtime) pending; + check_eq (stored_format runtime) before) + ["other-graph", "yyyy-MM-dd"; "plain-1", ""; "plain-1", "Unsupported"; "plain-1", "yyyy-MM"]) + +let all_journal_creation_paths_use_the_preference () = R.with_runtime (fun session runtime conn -> + success (set_format session "yyyy-MM-dd"); + success (Rpc_test.dispatch_json session "ensureJournal" "20261102"); + check_eq (stored_title (Graph_runtime.db runtime) (Rpc_wire.journal_page_uuid 20261102)) (D.String "2026-11-02"); + let response = Rpc_test.dispatch_json session "nodeProperty" (J.to_string (`Assoc [ + "blockUuid", `String Q.alpha; + "attr", `String R.due; "value", `String "2026-11-03"; + "expected", member "raw" (Result_view.property_json (Graph_runtime.db runtime) ~owner:Q.alpha ~attr:R.due ~encode_block:(fun _ -> `Null))])) in + success response; + let db = Graph_runtime.db runtime in + let page = Option.get (Graph_read.journal_page_uuid db 20261103) in + check_eq (stored_title db page) (D.String "2026-11-03"); + Composer_tag_test.sync_all runtime conn; + let restored = Graph_runtime.create runtime.path (Graph_runtime.state runtime).server_t conn {Graph_runtime.default_options with auto_create_today = true} in + let day = Cache_model.journal_day_for_ms (int_of_float (Unix.gettimeofday () *. 1000.)) in + let page = Option.get (Graph_read.journal_page_uuid (Graph_runtime.db restored) day) in + check_eq ~msg:"automatic today creation uses the graph preference" (display_title (Graph_runtime.db restored) page) + (Some (Printf.sprintf "%04d-%02d-%02d" (day / 10000) (day / 100 mod 100) (day mod 100)))) + +let failed_storage_keeps_the_previous_graph_preference () = R.with_runtime (fun _ runtime _ -> + let before = stored_format runtime in + let session = Rpc_session.create_session {Rpc_session.default_options with + load_graph_catalog = Some (fun () -> Some Rpc_test.plain_graph_catalog); + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); sync_cursor = Some (fun () -> Some 42); + prepare_operation = Some (Graph_runtime.prepare_sync runtime); + stage_operation = Some (fun _ -> Error "Storage unavailable")} |> Rpc_test.configure_plain_session in + let response = set_format session "yyyy-MM-dd" in + check_eq (member "ok" response) (`Bool false); + check ~msg:"date-format failures report the actual storage error" + (String_kit.index_of ~sub:"Storage unavailable" ~start:0 (J.to_string response) <> None); + check_eq (stored_format runtime) before) + +let settings_share_native_date_format_choices () = + List.iter (fun backend -> R.with_runtime (fun session _ _ -> + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + App_test.send app Model.OpenSettings; App_test.flush app; + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + check ~msg:"both hosts expose an editable date-format preference" (find "picker.settings.date-format" <> -1); + let option = find "settings.date-format.option.yyyy-MM-dd" in + check ~msg:"format choices use the shared native menu" (option <> -1); + App_test.dispatch app (if App_test.node host option = Some Lui_protocol.Radio then Lui_protocol.Change option else Lui_protocol.Press option); App_test.flush app; + let effects = (App.model app).pending_effects |> List.map (fun eff -> J.from_string (Native_bridge.encode_effect eff)) in + let mutation = List.find_opt (fun eff -> member "kind" eff = `String "set-journal-title-format") effects in + check ~msg:"choosing a date format uses the graph mutation boundary" (mutation <> None); + let payload = J.from_string (R.text (member "text" (Option.get mutation))) in + check_eq (member "graphId" payload) (`String "plain-1"); + check_eq (member "format" payload) (`String "yyyy-MM-dd")))) [App_test.ios_backend; App_test.kotlin_backend] + +let open_destinations_refresh_without_reopening () = R.with_runtime (fun _ runtime _ -> + let day = 20261023 in + let uuid = Rpc_wire.journal_page_uuid day in + let session = Rpc_session.create_session {Rpc_session.default_options with + load_graph_catalog = Some (fun () -> Some Rpc_test.plain_graph_catalog); + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); + sync_cursor = Some (fun () -> Some (Graph_runtime.state runtime).server_t); + stage_operation = Some (Graph_runtime.stage runtime); prepare_operation = Some (Graph_runtime.prepare_sync runtime); + graph_node_destination = Some (fun uuid -> Graph_read.node_destination (fun s -> Ok s) (Graph_runtime.db runtime) uuid); + graph_sidebar_pages = Some (fun () -> Some {Graph_read.favorites = []; + recent_pages = Option.to_list (Option.bind (D.entid (Graph_runtime.db runtime) "block/uuid" (D.Uuid uuid)) + (Graph_read.entity_summary (fun s -> Ok s) (Graph_runtime.db runtime)))}); + graph_page_blocks = Some (fun uuid -> Some (Graph_runtime.blocks_for_page runtime uuid)); + graph_blocks = Some (fun () -> Some (Graph_runtime.blocks_for_page runtime uuid))} + |> Rpc_test.configure_plain_session in + success (Rpc_test.dispatch_json session "ensureJournal" (string_of_int day)); + success (Rpc_test.dispatch_json session "selectPage" uuid); + success (Rpc_test.dispatch_json session "openNode" (J.to_string (`Assoc ["uuid", `String uuid]))); + let response = member "result" (set_format session "dd-MM-yyyy") in + check_eq ~msg:"the already selected sidebar page updates immediately" + (member "title" (member "selectedPage" response)) (`String "23-10-2026"); + let route = List.hd (Q.items "nodeRoutes" response) in + check_eq ~msg:"an existing navigation destination updates immediately" + (member "title" (member "page" route)) (`String "23-10-2026"); + let rows = Q.items "outlinerRows" route in + check ~msg:"journal block headers update with the page title" + (List.for_all (fun row -> member "journalTitle" (member "block" row) = `String "23-10-2026") rows)) + +let all_master_formats_and_invalid_legacy_days_are_safe () = R.with_runtime (fun session runtime conn -> + List.iter (fun format -> + success (set_format session format); + check ~msg:("master format renders the journal: " ^ format) + (Option.fold ~none:false ~some:(fun title -> String.trim title <> "") (display_title (Graph_runtime.db runtime) R.day_a))) + Journal.built_in_journal_title_formatters; + ignore (D.transact_conn conn [Q.put R.day_a "block/journal-day" (D.Int64 20261399L)]); + Graph_runtime.rebase runtime 42 [] [R.day_a]; + check_eq ~msg:"invalid legacy journal days retain the readable stored title" + (display_title (Graph_runtime.db runtime) R.day_a) (Some "October 1st, 2026")) + +let graph_switch_resets_preferences_but_partial_updates_preserve_them () = + let projection fields = Result.get_ok (Response_snapshot.decode_response (J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc fields]))) in + let apply current projection = Model.update current (Model.ApplyCoreSnapshot projection) in + let initial = Model.initial () in + let first = apply initial (projection ["selectedGraphId", `String "a"; "journalTitleFormat", `String "yyyy-MM-dd"]) in + check_eq first.journal_title_format "yyyy-MM-dd"; + let patched = apply first (projection ["selectedGraphId", `String "a"; "isPendingSyncPatch", `Bool true]) in + check_eq patched.journal_title_format "yyyy-MM-dd"; + let switched = apply patched (projection ["selectedGraphId", `String "b"]) in + check_eq ~msg:"a graph cannot inherit the previous graph's date preference" switched.journal_title_format Journal.default_format + +let date_commands_keep_stable_journal_references_after_format_changes () = R.with_runtime (fun session runtime _ -> + success (set_format session "dd-MM-yyyy"); + List.iter (fun (date, uuid, label) -> + ignore (Editor_command_test.run session "Date picker"); + let response = Editor_command_test.submit session date in + let title = member "title" (Editor_command_test.draft response) in + check_eq ~msg:"date commands insert stable references even when the stored title uses an older format" + title (`String ("[[" ^ uuid ^ "]]")); + Editor_command_test.save session; + let block = List.find (fun (block : Cache_model.block) -> block.uuid = Q.alpha) + (Graph_runtime.blocks_for_page runtime Q.page) in + check ~msg:"saved date references remain navigable and display the graph format" + (List.exists (fun (node : Cache_model.entity_summary) -> node.uuid = uuid && node.title = label) block.references)) + ["2026-10-01", R.day_a, "01-10-2026"; + "2026-11-05", Rpc_wire.journal_page_uuid 20261105, "05-11-2026"]) + +let search_reindexes_journals_and_references_when_format_changes () = + Graph_runtime_test.with_search_runtime (R.fixture ()) (fun _ conn runtime -> + let session = Rpc_session.create_session {Rpc_session.default_options with + load_graph_catalog = Some (fun () -> Some Rpc_test.plain_graph_catalog); + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); + sync_cursor = Some (fun () -> Some (Graph_runtime.state runtime).server_t); + stage_operation = Some (Graph_runtime.stage runtime); prepare_operation = Some (Graph_runtime.prepare_sync runtime)} + |> Rpc_test.configure_plain_session in + success (Rpc_test.dispatch_json session "ensureJournal" "20261023"); + let page = Rpc_wire.journal_page_uuid 20261023 in + Composer_tag_test.sync_all runtime conn; + ignore (D.transact_conn conn [Q.put Q.alpha "block/title" (D.String ("See [[" ^ page ^ "]]")); + Q.put Q.alpha "block/refs" (D.Ref_to (Q.ref_uuid page))]); + Graph_runtime.rebase runtime (Graph_runtime.state runtime).server_t [] [Q.alpha]; + let before, _ = Graph_runtime.search_page runtime ~offset:0 ~limit:20 "20261023" in + check ~msg:"the search index is already live before changing format" + (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = page) before); + success (set_format session "dd-MM-yyyy"); + let hits, _ = Graph_runtime.search_page runtime ~offset:0 ~limit:20 "23-10-2026" in + check ~msg:"the new journal format becomes searchable immediately" + (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = page && hit.title = "23-10-2026") hits); + check ~msg:"references in indexed block titles follow the chosen journal format" + (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = Q.alpha && String_kit.includes ~sub:"23-10-2026" hit.title) hits)) + +let cases = [ + case "search refreshes journal and reference labels on format changes" search_reindexes_journals_and_references_when_format_changes; + case "date commands preserve existing and new journal identities" date_commands_keep_stable_journal_references_after_format_changes; + case "open destinations refresh date labels immediately" open_destinations_refresh_without_reopening; + case "all master formats and malformed legacy days render safely" all_master_formats_and_invalid_legacy_days_are_safe; + case "date format preferences remain scoped to the graph" graph_switch_resets_preferences_but_partial_updates_preserve_them; + case "existing journals reformat without changing identity" existing_journals_reformat_without_identity_changes; + case "captures and restart retain graph date format" new_captures_and_restart_keep_the_graph_format; + case "properties and journal references share date format" property_values_and_reference_labels_share_the_format; + case "invalid and stale date preferences never write" invalid_and_stale_changes_never_write; + case "all journal creation paths use the chosen format" all_journal_creation_paths_use_the_preference; + case "failed storage preserves the graph preference" failed_storage_keeps_the_previous_graph_preference; + case "native settings share graph date format choices" settings_share_native_date_format_choices; +] diff --git a/shared/test/logseq/test_main.ml b/shared/test/logseq/test_main.ml index cafec9ba..6ad64d89 100644 --- a/shared/test/logseq/test_main.ml +++ b/shared/test/logseq/test_main.ml @@ -43,6 +43,7 @@ let () = "editor_commands", Editor_command_test.cases; "composer_tags", Composer_tag_test.cases; "block_clipboard", Block_clipboard_test.cases; + "journal_format", Journal_format_test.cases; "editor_exit", Editor_exit_test.cases; "pending_projection", Pending_projection_test.cases; "graph_runtime", Graph_runtime_test.cases; From 59d14e6b2e09e1903c935fc57c6f2e9e69827d3c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 03:32:28 +0800 Subject: [PATCH 12/25] Keep native navigation synchronized and verify composer tag flows --- .../com/logseq/app/AndroidComposerMenuTest.kt | 105 ++++++++++++ .../Logseq/LGNavigationExtension.swift | 27 +++- apple/Tests/UI/composer-tags.swift | 153 ++++++++++++++++++ .../UI/navigation-content-stability.swift | 113 +++++++++++++ .../016-mobile-interaction-regressions.md | 6 + shared/src/logseq/view_query.ml | 12 +- 6 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 apple/Tests/UI/composer-tags.swift create mode 100644 apple/Tests/UI/navigation-content-stability.swift diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidComposerMenuTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidComposerMenuTest.kt index 93ad032b..f98fca44 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidComposerMenuTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidComposerMenuTest.kt @@ -30,6 +30,7 @@ import com.logseq.app.ui.LogseqTheme import com.logseq.app.ui.extensions.logseqExtensionRegistry import dev.lui.LuiBackend import dev.lui.LuiEvent +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -41,6 +42,7 @@ import org.junit.runner.RunWith class AndroidComposerMenuTest { @get:Rule val ui = createComposeRule() private var bridge: LogseqNativeBridge? = null + private var patchFilter: LuiPatchFilter? = null private val events = mutableListOf() @After fun closeBridge() { bridge?.let { ui.runOnIdle { it.close() } } } @@ -53,6 +55,7 @@ class AndroidComposerMenuTest { icons = logseqIconResolver, ) val filter = LuiPatchFilter(backend) + patchFilter = filter native = LogseqNativeBridge(onPatch = { filter.applyJson(it) }) bridge = native val root = native.initialize(authenticationCode = 3) @@ -144,6 +147,108 @@ class AndroidComposerMenuTest { ui.onNodeWithTag("field.composer").assertTextEquals("Unsent draft 中文") } + private fun effect(kind: String): JSONObject { + var found: JSONObject? = null + ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + repeat(24) { + val raw = bridge!!.takeEffect() + if (raw.isNotEmpty()) { + val result = JSONObject(raw) + result.optString("patch").takeIf { it.isNotEmpty() }?.let { patchFilter!!.applyJson(it) } + result.optJSONObject("effect")?.let { if (it.optString("kind") == kind) found = it } + } + } + } + found != null + } + return found!! + } + + private fun resolve(effect: JSONObject, succeeded: Boolean = true) = ui.runOnIdle { + bridge!!.resolveEffect(effect.getLong("id"), succeeded, if (succeeded) "" else "Storage failed") + .takeIf { it.isNotEmpty() }?.let { patchFilter!!.applyJson(it) } + } + + private fun tagCatalog(load: JSONObject, graph: String = "composer-test", tag: String = "project", title: String = "Project") { + ui.runOnIdle { + bridge!!.applySnapshot(JSONObject().put("ok", true).put("result", JSONObject() + .put("selectedGraphId", graph).put("composerTagChoices", org.json.JSONArray() + .put(JSONObject().put("uuid", tag).put("title", title)))).toString()) + .takeIf { it.isNotEmpty() }?.let { patchFilter!!.applyJson(it) } + } + resolve(load) + } + + @Test fun tagsUseTheSharedNativePickerAndRecoverFailedCaptures() { + composer() + val attachment = ui.onNodeWithTag("button.attachment").fetchSemanticsNode().boundsInRoot + val status = ui.onNodeWithTag("button.task-status").fetchSemanticsNode().boundsInRoot + val tags = ui.onNodeWithTag("button.composer.tags").fetchSemanticsNode().boundsInRoot + assertTrue("Hashtag follows attachment and task status", attachment.left < status.left && status.left < tags.left) + fun openTags() { + ui.onNodeWithTag("button.composer.tags").performClick() + tagCatalog(effect("load-composer-tags")) + ui.onNodeWithTag("view.edit.value.composer.tags").assertIsDisplayed() + } + openTags() + ui.onNodeWithTag("view.edit.option.composer.tags.project").performClick() + ui.onNodeWithTag("view.edit.save.composer.tags").performClick() + ui.onNodeWithTag("button.composer.tag.remove.project").assertIsDisplayed() + openTags() + ui.onNodeWithTag("view.edit.value.composer.tags").performTextInput("Mobile") + ui.onNodeWithTag("view.edit.create.composer.tags").performClick() + ui.onNodeWithTag("view.edit.save.composer.tags").performClick() + ui.onNodeWithTag("button.composer.tag.remove.new:mobile").assertIsDisplayed() + ui.onNodeWithTag("button.composer.tag.remove.project").performClick() + openTags() + ui.onNodeWithTag("view.edit.value.composer.tags").performTextInput("Discard") + ui.onNodeWithTag("view.edit.create.composer.tags").performClick() + ui.onNodeWithTag("view.edit.cancel.composer.tags").performClick() + ui.onNodeWithTag("button.composer.tag.remove.new:discard").assertDoesNotExist() + ui.onNodeWithTag("field.composer").assertTextEquals("Unsent draft 中文") + ui.onNodeWithTag("button.send").performClick() + val send = effect("send-tagged") + val payload = JSONObject(send.getString("text")) + assertEquals("Unsent draft 中文", payload.getString("text")) + assertEquals("Mobile", payload.getJSONObject("tags").getJSONArray("newValues").getString(0)) + assertEquals(0, payload.getJSONObject("tags").getJSONArray("selectedValues").length()) + resolve(send, succeeded = false) + ui.onNodeWithTag("field.composer").assertTextEquals("Unsent draft 中文") + ui.onNodeWithTag("button.composer.tag.remove.new:mobile").assertIsDisplayed().performClick() + ui.onNodeWithTag("button.send").performClick() + val retry = effect("send-capture") + assertEquals("Unsent draft 中文", retry.getString("text")) + resolve(retry) + ui.onNodeWithTag("field.composer").assert(SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, androidx.compose.ui.text.AnnotatedString(""))) + ui.onNodeWithTag("button.composer.tag.remove.new:mobile").assertDoesNotExist() + } + + @Test fun changingGraphsDismissesOldTagsAndIgnoresAnOldCatalogFailure() { + composer() + ui.onNodeWithTag("button.composer.tags").performClick() + tagCatalog(effect("load-composer-tags")) + ui.onNodeWithTag("view.edit.option.composer.tags.project").performClick() + ui.onNodeWithTag("view.edit.save.composer.tags").performClick() + ui.onNodeWithTag("button.composer.tags").performClick() + val old = effect("load-composer-tags") + ui.runOnIdle { + bridge!!.applySnapshot("""{"ok":true,"result":{"selectedGraphId":"other","graphName":"Other","isGraphUnlocked":true}}""") + .takeIf { it.isNotEmpty() }?.let { patchFilter!!.applyJson(it) } + } + ui.onNodeWithTag("button.composer.tag.remove.project").assertDoesNotExist() + ui.onNodeWithTag("view.edit.value.composer.tags").assertDoesNotExist() + ui.onNodeWithTag("button.composer.tags").performClick() + val current = effect("load-composer-tags") + resolve(old, succeeded = false) + tagCatalog(current, graph = "other", tag = "book", title = "Book") + ui.onNodeWithTag("view.edit.option.composer.tags.project").assertDoesNotExist() + ui.onNodeWithTag("view.edit.option.composer.tags.book").assertIsDisplayed().performClick() + ui.onNodeWithTag("view.edit.save.composer.tags").performClick() + ui.onNodeWithTag("button.composer.tag.remove.book").assertIsDisplayed() + } + private fun retainedMenu(enabled: Boolean = true) { val backend = LuiBackend(onEvent = { events.add(it) }, icons = logseqIconResolver) assertTrue(backend.applyJson("""{"generation":1,"ops":[ diff --git a/apple/Sources/Logseq/LGNavigationExtension.swift b/apple/Sources/Logseq/LGNavigationExtension.swift index 025703f1..22460010 100644 --- a/apple/Sources/Logseq/LGNavigationExtension.swift +++ b/apple/Sources/Logseq/LGNavigationExtension.swift @@ -472,6 +472,15 @@ private struct LGSearchSession: View { } @MainActor +private struct LGNativeNavigationPop { + let original: [Int] + let target: [Int] + + func awaitsAcknowledgement(_ desired: [Int]) -> Bool { + desired.count >= target.count && original.starts(with: desired) && desired != target + } +} + private struct LGNavigationContent: View { let context: LUIAppleExtensionViewContext let rootIndex: Int @@ -480,7 +489,7 @@ private struct LGNavigationContent: View { let toolbarStartIndex: Int? let bottomChromeIndex: Int? let rootTransform: ((AnyView) -> AnyView)? - @State private var path: [Int] = [] + @State private var nativePop: LGNativeNavigationPop? #if os(iOS) @State private var transitions = LGNavigationTransitions() #endif @@ -506,6 +515,14 @@ private struct LGNavigationContent: View { return value } + // A mirrored @State path changes navigation twice in the same render frame. + // Read core routes directly, retaining only native pops awaiting core acknowledgement. + private var path: [Int] { + let desired = desiredPath + if let nativePop, nativePop.awaitsAcknowledgement(desired) { return nativePop.target } + return desired + } + @ViewBuilder var body: some View { let _ = context.revision @@ -612,13 +629,13 @@ private struct LGNavigationContent: View { #endif .onAppear { LGInteractionDiagnostics.event("navigation.appear", "node=\(context.nodeID) path=\(path) desired=\(desiredPath)") - path = desiredPath } + .animation(.default, value: desiredPath) .onChange(of: desiredPath) { let newPath = desiredPath LGInteractionDiagnostics.event("navigation.change", "node=\(context.nodeID) path=\(path) desired=\(newPath) revision=\(context.revision)") - if path != newPath { - withAnimation { path = newPath } + if let nativePop, !nativePop.awaitsAcknowledgement(newPath) { + self.nativePop = nil } } } @@ -770,8 +787,8 @@ private struct LGNavigationContent: View { set: { newPath in LGInteractionDiagnostics.event("navigation.binding", "node=\(context.nodeID) path=\(path) new=\(newPath) desired=\(desiredPath)") let removedCount = max(0, path.count - newPath.count) - path = newPath if removedCount > 0 { + nativePop = LGNativeNavigationPop(original: nativePop?.original ?? desiredPath, target: newPath) #if os(iOS) // Keep the outgoing route alive until UIKit finishes its pop. transitions.afterPop { emitBack(count: removedCount) } diff --git a/apple/Tests/UI/composer-tags.swift b/apple/Tests/UI/composer-tags.swift new file mode 100644 index 00000000..cff569cf --- /dev/null +++ b/apple/Tests/UI/composer-tags.swift @@ -0,0 +1,153 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class ComposerTagEffects: LGEffectExecuting { + var tagged: [[String: Any]] = [] + var plain: [String] = [] + var failTagged = true + + func execute(_ effect: LGEffect) async -> LGEffectResolution { + switch effect.kind { + case "load-composer-tags": + return LGEffectResolution(succeeded: true, message: #"{"ok":true,"result":{"selectedGraphId":"composer-tags","composerTagChoices":[{"uuid":"project","title":"Project"}]}}"#) + case "send-tagged": + tagged.append(try! JSONSerialization.jsonObject(with: Data(effect.text.utf8)) as! [String: Any]) + return LGEffectResolution(succeeded: !failTagged, message: failTagged ? "Storage failed" : "", output: .discard) + case "send-capture": plain.append(effect.text) + default: break + } + return LGEffectResolution(succeeded: true, message: "", output: .discard) + } +} + +@main struct ComposerTagsCheck { + @MainActor static func main() async throws { + let effects = ComposerTagEffects() + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"composer-tags","graphName":"Composer tags","isGraphUnlocked":true,"outlinerRows":[]}}"#) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + try runtime.applyHostUpdate(kind: "restore-ui-session", payload: #"{"graphId":"composer-tags","destination":"journals","assets":[],"composerExpanded":true,"searchOpen":false,"query":"","appPath":[],"searchPath":[],"selectedPageId":null,"settingsOpen":false,"recentGraphIds":[]}"#) + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func descendants(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(descendants).first { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier + } + } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<150 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message) + } + func press(_ identifier: String) throws { + guard let id = find(identifier) else { throw failure("Missing control: " + identifier) } + do { try backend.performPress(node: id) } + catch { throw failure("Cannot press " + identifier + ": " + String(describing: error)) } + } + func nativeComposer() -> UITextView? { + views(window).compactMap { $0 as? UITextView }.first + } + try await wait("The composer must use a native text view") { nativeComposer() != nil } + let composer = nativeComposer()! + composer.becomeFirstResponder() + composer.insertText("Native note 中文") + try await Task.sleep(for: .milliseconds(350)) + guard let attachment = frames[find("button.attachment")!], let status = frames[find("button.task-status")!], + let tagFrame = frames[find("button.composer.tags")!], attachment.minX < status.minX, status.minX < tagFrame.minX else { + throw failure("Hashtag must follow attachment and task status controls") + } + func openTags() async throws { + try press("button.composer.tags") + try await wait("The shared tag picker must present native search") { + find("view.edit.option.composer.tags.project") != nil && views(window).contains { $0 is UISearchTextField } + } + } + func create(_ title: String) async throws { + guard let search = views(window).compactMap({ $0 as? UISearchTextField }).first else { + throw failure("Tags must use the native property search control") + } + search.becomeFirstResponder() + search.insertText(title) + try await wait("Typing must offer explicit tag creation") { find("view.edit.create.composer.tags") != nil } + try press("view.edit.create.composer.tags") + } + func closeTags(_ action: String) async throws { + try press("view.edit." + action + ".composer.tags") + try await wait("The native picker must dismiss") { + find("view.edit.value.composer.tags") == nil && window.rootViewController?.presentedViewController == nil + } + } + try await openTags() + try press("view.edit.option.composer.tags.project") + try await closeTags("save") + try await wait("Selected tags must become removable chips") { find("button.composer.tag.remove.project") != nil } + try await openTags() + try await create("Mobile") + try await closeTags("save") + try await wait("New tags must become removable chips") { find("button.composer.tag.remove.new:mobile") != nil } + try press("button.composer.tag.remove.project") + try await openTags() + try await create("Discard") + try await closeTags("cancel") + guard find("button.composer.tag.remove.new:discard") == nil, + nativeComposer() === composer, composer.text == "Native note 中文" else { + throw failure("Cancel and tag editing must preserve the native composer and its draft") + } + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("composer-tag-chip.png")) + try press("button.send") + try await wait("Send must carry the staged tags") { effects.tagged.count == 1 } + guard effects.tagged[0]["text"] as? String == "Native note 中文", + let tags = effects.tagged[0]["tags"] as? [String: Any], tags["newValues"] as? [String] == ["Mobile"], + tags["selectedValues"] as? [String] == [] else { + throw failure("Tag selection must travel separately from the unchanged capture text") + } + try await wait("Failed captures must recover their draft and chips") { + nativeComposer()?.text == "Native note 中文" && find("button.composer.tag.remove.new:mobile") != nil + } + try press("button.composer.tag.remove.new:mobile") + try press("button.send") + try await wait("Retry must honor removal of recovered tags") { effects.plain == ["Native note 中文"] } + try await wait("Successful retry must clear the composer") { nativeComposer()?.text == "" } + effects.failTagged = false + try await openTags() + try press("view.edit.option.composer.tags.project") + try await closeTags("save") + nativeComposer()!.insertText("Accepted tagged note") + try await wait("Native typing must enable Send") { + find("button.send").flatMap { backend.model(id: $0)?.isEnabled } == true + } + try press("button.send") + try await wait("An accepted tagged capture clears its draft and chips") { + effects.tagged.count == 2 && nativeComposer()?.text == "" && find("button.composer.tag.remove.project") == nil + } + try await openTags() + try press("view.edit.option.composer.tags.project") + try await closeTags("save") + try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"other","graphName":"Other","isGraphUnlocked":true,"outlinerRows":[]}}"#) + try await wait("Switching graphs must clear the previous graph's tag chips") { find("button.composer.tag.remove.project") == nil } + guard runtime.lastError == nil else { throw failure(runtime.lastError!) } + print("PASS: native composer tags select, create, cancel, remove, recover failed captures, retry without removed tags and stay graph-scoped") + } + + private static func failure(_ message: String) -> NSError { + NSError(domain: "ComposerTags", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/navigation-content-stability.swift b/apple/Tests/UI/navigation-content-stability.swift new file mode 100644 index 00000000..87696699 --- /dev/null +++ b/apple/Tests/UI/navigation-content-stability.swift @@ -0,0 +1,113 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@main struct NavigationContentStabilityCheck { + @MainActor static func main() async throws { + let rows: [[String: Any]] = (0..<96).map { index in + ["depth": index % 3, "hasChildren": index % 3 < 2, "isCollapsed": false, + "block": ["uuid": "row-\(index)", "title": "Row \(index)", "pageId": "journal"]] + } + var routes: [String] = [] + func snapshot() throws -> String { + let destinations: [[String: Any]] = routes.map { uuid in + ["uuid": uuid, "page": ["uuid": uuid, "title": uuid], + "outlinerRows": [["depth": 0, "block": ["uuid": uuid + "-child", "title": "Child", "pageId": uuid]]]] + } + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": rows, + "nodeRoutes": destinations] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in + if let data = request.params.payload?.data(using: .utf8), + let effect = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if effect["kind"] as? String == "open-node", let uuid = effect["text"] as? String { routes.append(uuid) } + if effect["kind"] as? String == "close-node" { _ = routes.popLast() } + } + return try! snapshot() + })) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func controllers(_ controller: UIViewController) -> [UIViewController] { + [controller] + controller.children.flatMap(controllers) + } + try await Task.sleep(for: .milliseconds(700)) + let firstRow = views(window).first { $0.accessibilityIdentifier == "outliner.block-action.row-0" }! + var ancestor = firstRow.superview + while ancestor != nil && !(ancestor is UIScrollView) { ancestor = ancestor?.superview } + let scroll = ancestor as! UIScrollView + scroll.setContentOffset(CGPoint(x: 0, y: 120), animated: false) + try await Task.sleep(for: .milliseconds(300)) + let offset = scroll.contentOffset + var navigation: UINavigationController? + var failures: [String] = [] + for opening in (0..<24).map({ $0.isMultiple(of: 2) }) { + if opening { try runtime.applyHostUpdate(kind: "open-node", payload: #""destination""#) } + else { _ = navigation!.popViewController(animated: true) } + for _ in 0..<35 { + try await Task.sleep(for: .milliseconds(16)) + for guide in views(window).compactMap({ $0 as? OutlinerIndentGuideView }) where !guide.isHidden { + if let shape = guide.layer as? CAShapeLayer, + let presented = shape.presentation(), let path = presented.path, + path.boundingBox.height > guide.bounds.height + 1 { + failures.append("Guide animates outside its row: \(path.boundingBox.height) > \(guide.bounds.height)") + } + } + } + for _ in 0..<70 { + let stacks = controllers(window.rootViewController!).compactMap { $0 as? UINavigationController } + let ready = opening ? stacks.contains(where: { $0.viewControllers.count == 2 && $0.transitionCoordinator == nil }) + : navigation?.viewControllers.count == 1 && navigation?.transitionCoordinator == nil && routes.isEmpty + if ready { break } + try await Task.sleep(for: .milliseconds(20)) + } + let navigations = controllers(window.rootViewController!).compactMap { $0 as? UINavigationController } + if opening { navigation = navigations.first { $0.viewControllers.count == 2 } } + guard navigation?.viewControllers.count == (opening ? 2 : 1) else { + LGInteractionDiagnostics.event("test.navigation.fail", "opening=\(opening) routes=\(routes)") + try await Task.sleep(for: .milliseconds(350)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("navigation-failure.png")) + throw failure("The test must exercise real native push and pop transitions: \(navigations.map { $0.viewControllers.count })") + } + guard opening || (views(window).contains(where: { $0 === scroll }) && scroll.contentOffset == offset) else { + throw failure("Switching pages changes retained scroll: opening=\(opening), retained=\(views(window).contains(where: { $0 === scroll })), offset=\(scroll.contentOffset), expected=\(offset), insets=\(scroll.adjustedContentInset), content=\(scroll.contentSize), bounds=\(scroll.bounds)") + } + } + func waitForDepth(_ depth: Int, _ message: String) async throws { + for _ in 0..<150 { + if navigation?.viewControllers.count == depth && navigation?.transitionCoordinator == nil + && routes.count == depth - 1 { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message + ": native=\(navigation?.viewControllers.count ?? -1), routes=\(routes)") + } + for destination in ["First", "Second"] { + try runtime.applyHostUpdate(kind: "open-node", payload: "\"" + destination + "\"") + try await waitForDepth(destination == "First" ? 2 : 3, "Nested pushes must retain native destinations") + } + _ = navigation!.popToRootViewController(animated: true) + try await waitForDepth(1, "A native pop to root must acknowledge every removed route") + try runtime.applyHostUpdate(kind: "open-node", payload: #""After pop to root""#) + try await waitForDepth(2, "A fresh destination must open after a multilevel native pop") + _ = navigation!.popViewController(animated: true) + try await waitForDepth(1, "The final native pop must synchronize") + guard views(window).contains(where: { $0 === scroll }) && scroll.contentOffset == offset else { + throw failure("Multilevel navigation must retain the root scroll identity and offset") + } + guard failures.isEmpty else { throw failure(failures.prefix(4).joined(separator: "; ")) } + print("PASS: repeated page navigation preserves scroll identity and offset, with no guide flash outside rows") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "NavigationStability", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index f98fd01e..7173b890 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -97,6 +97,12 @@ Direct and nested destination selection, plus a result opened through the native Upstream LUI PR 258 now includes Android stretch at `eea10c7bf99ad1a2a40cc84b1c9657a764073d72`; both the Swift package and opam pin reference it. Local Android verification uses `/tmp/logseq-lui-override.gradle` to consume that exact upstream worktree while preserving the user's dirty `duniverse/lui` checkout. Keyboard checks operate on the scroll view containing the measured block, rather than an arbitrary scroll view in the window. Navigation checks assert native controller depth so a test cannot pass without a real push or pop. +The repeated-navigation regression reproduces against the date-format head in `/tmp/logseq-navigation-date-head.log`; SwiftUI reports multiple updates to its navigation authority in the same frame. `LGNavigationContent` mirrored core routes into a second `@State` path. That mirror originated in `0e0d6cce`; `7634b389` later animated its synchronization. Removing only that animation still fails `/tmp/logseq-navigation-mirror-no-animation.log`, so disabling animation is not the repair. The native path now reads the core routes directly and retains only an actual native pop pending core acknowledgement. `/tmp/logseq-navigation-final.log` passes twelve real push/pop cycles, nested pushes, pop-to-root, reopening, retained root scroll identity/offset and guide presentation bounds. `/tmp/logseq-navigation-final-system.log` contains no SwiftUI navigation-authority warnings for that check's process. +The native Android composer suite passes all nine cases in `/tmp/logseq-composer-tags-android-green.log`. Its added flows exercise actual tag selection, creation, Cancel, chip removal, unchanged Unicode draft text, typed send payload, failed capture recovery, retry after removing recovered tags, graph changes and stale catalogue responses. The initial final-empty-field assertion incorrectly included the placeholder in Compose semantics; the corrected assertion checks the editable text value without changing production behavior. +The iOS counterpart passes `/tmp/logseq-composer-tags-ios.log`, using actual native multiline text input and the shared native searchable picker. It additionally verifies the composer instance survives tag editing, accepted tagged capture clears its state, and graph switches clear chips. `composer-tag-chip.png` in `/tmp/logseq-composer-tags-ios-artifacts` was visually inspected. The check locates the internal multiline text view rather than expecting SwiftUI's outer accessibility identifier on UIKit, and waits for native typing to enable Send. +The requested larger query-result toggle uses the existing shared chevron icon on both hosts instead of a tiny Unicode triangle. `/tmp/logseq-query-toggle-result-view.log` passes all 30 result-view cases and `/tmp/logseq-query-toggle-query-block.log` passes all 21 query-block cases. `/tmp/logseq-query-toggle-ios.log` passes the full native query interaction check, including fold/unfold, selection, saved views, table cells, filter/sort sheets, gallery, search and contained errors. The actual toggle screenshot was visually inspected. +The navigation repair also passes twelve native page/block search-result pushes and backs in `/tmp/logseq-navigation-search-final.log`, and direct/nested/search selection actions in `/tmp/logseq-navigation-selection-final.log`. Search checks still emit the pre-existing AttributeGraph cycle diagnostics also recorded before this repair in `/tmp/logseq-nested-selection-final-2.log`; these checks pass their behavior and geometry assertions, but those diagnostics remain an audit item. +`/tmp/logseq-navigation-editor-final.log` additionally passes native editor entry/exit across journal sections with stable block coordinates and no extra image request after the navigation change. Device diagnostic capture `/tmp/logseq-phone-jitter-repro.log` contains 1225 geometry/lifecycle events without note text. The keyboard dismissal at uptime 270565 follows an explicit toolbar press and is not evidence of an unexpected dismissal. Idle geometry oscillations after full snapshots and hydration are confirmed. `outliner-hydration-stability.swift` reproduces 16pt drift before the cached-empty-row fix; `outliner-scrolling-editor.swift` independently reproduces focus loss when the active block leaves the mounted window. The cached-empty-row native regression now passes all five refresh cycles. Persistent focused rows also pass forward/reverse scrolling without detachment or a keyboard-hide notification. Native image retention, editor geometry, and multiline keyboard handoff checks remain green. Upstream fixes are in https://github.com/logseq/lui/pull/258 (PR 257 had already merged). The diagnostic fix build was installed and launched at 01:56 on the user iPhone; the user confirmed the flicker/jitter fix at 01:57. diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index b41efb82..765f5fb7 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -77,10 +77,9 @@ let legacy_view ~render_block _model_source row_source send : t = (box ~padding_horizontal:16 ~padding_vertical:8 ~accessibility_identifier:(identifier "block") [column ~gap:4 [ - button ~variant:`ghost ~text_alignment:`start - ~text_signal:(own (Signal.map2 (fun query expanded -> - (if expanded then "▾ " else "▸ ") ^ string_of_int (List.length query.rows) ^ " results") - query (Signal.value expanded))) + button ~variant:`ghost ~text_alignment:`start ~min_height:44 + ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "chevron-down" else "chevron-right")) (Signal.value expanded))) + ~text_signal:(own (Signal.map (fun query -> string_of_int (List.length query.rows) ^ " results") query)) ~label:"Toggle query results" ~accessibility_identifier:(identifier "toggle") ~on_press:(fun _ -> Signal.set expanded (not (Signal.sample (Signal.value expanded)))) []; if_ ~test:(Signal.value expanded) @@ -558,9 +557,10 @@ let collection ~render_block model_source raw_source send : t = button ~variant:`ghost ~text_alignment:`start ~grow:1.0 ~accessibility_identifier:("query.toggle." ^ owner) ~disabled_signal:(waiting model_source owner) + ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "chevron-down" else "chevron-right")) (Signal.value expanded))) ~text_signal:(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") views (Signal.value 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") views (Signal.value expanded))) ~on_press:(fun _ -> let next = not (Signal.sample (Signal.value expanded)) in if is_query_expansion (Signal.sample raw_source) then From 04c10b4f1288e19a12657fff4f4da4981fe9391b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 03:48:25 +0800 Subject: [PATCH 13/25] Match master link paste behavior and verify Recent selection --- apple/Tests/UI/nested-selection-toolbar.swift | 35 ++++++++-- apple/Tests/UI/selection-gesture.swift | 65 +++++++++++++++++++ .../016-mobile-interaction-regressions.md | 17 +++-- shared/src/logseq/core/block_clipboard.ml | 11 +++- shared/test/logseq/block_clipboard_test.ml | 32 +++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 apple/Tests/UI/selection-gesture.swift diff --git a/apple/Tests/UI/nested-selection-toolbar.swift b/apple/Tests/UI/nested-selection-toolbar.swift index 91bacee8..cf2218f2 100644 --- a/apple/Tests/UI/nested-selection-toolbar.swift +++ b/apple/Tests/UI/nested-selection-toolbar.swift @@ -6,6 +6,7 @@ import UIKit @main struct NestedSelectionToolbarCheck { @MainActor static func main() async throws { var routes: [String] = [] + var selectedPage: String? var selected = false var longPressed: [String] = [] var performed: [String] = [] @@ -15,11 +16,16 @@ import UIKit "outlinerRows": [["depth": 0, "block": ["uuid": uuid + "-child", "title": "Select this block", "pageId": uuid]]], "outlinerState": ["selectedBlockIds": selected && uuid == routes.last ? [uuid + "-child"] : []]] } - return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + let root = selectedPage ?? "journal" + var result: [String: Any] = [ "graphName": "Work", "selectedGraphId": "work", "nodeRoutes": projections, - "outlinerRows": [["depth": 0, "block": ["uuid": "journal-child", "title": "Journal", "pageId": "journal"]]], + "recentPages": [["uuid": "Recent C", "title": "Recent C"]], + "outlinerRows": [["depth": 0, "block": ["uuid": root + "-child", "title": "Select this block", "pageId": root]]], + "outlinerState": ["selectedBlockIds": selected && routes.isEmpty ? [root + "-child"] : []], "searchQuery": "Found", "searchResults": [["uuid": "Found", "title": "Found", "isPage": true]] - ]]), as: UTF8.self) + ] + if let selectedPage { result["selectedPage"] = ["uuid": selectedPage, "title": selectedPage] } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) } let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in if let data = request.params.payload?.data(using: .utf8), @@ -28,6 +34,8 @@ import UIKit case "open-node", "open-search-result": if let uuid = effect["text"] as? String { routes.append(uuid); selected = false } case "close-node": _ = routes.popLast(); selected = false + case "select-sidebar-page": + selectedPage = effect["text"] as? String; routes = []; selected = false case "long-press-outliner-block": if let uuid = effect["text"] as? String { longPressed.append(uuid); selected = true } case "outliner-toolbar": @@ -66,7 +74,7 @@ import UIKit throw failure(message) } try await Task.sleep(for: .milliseconds(500)) - for destination in ["Page A", "Page B", "Found"] { + for destination in ["Page A", "Page B", "Recent C", "Linked D", "Found"] { if destination == "Found" { guard let search = find("button.search") else { throw failure("Missing search action") } try runtime.renderer.backend.performPress(node: search) @@ -76,6 +84,23 @@ import UIKit views(window).compactMap { $0 as? UISearchTextField }.first { $0.isFirstResponder }!.insertText("Found") try await wait("Search must show its result") { find("search.result.Found") != nil } try runtime.renderer.backend.performPress(node: find("search.result.Found")!) + } else if destination == "Recent C" { + let backend = runtime.renderer.backend + func navigationControllers(_ controller: UIViewController) -> [UINavigationController] { + (controller as? UINavigationController).map { [$0] } ?? controller.children.flatMap(navigationControllers) + } + guard let navigation = navigationControllers(window.rootViewController!).first(where: { $0.viewControllers.count > 1 }) else { + throw failure("Linked pages must have a native navigation stack") + } + _ = navigation.popToRootViewController(animated: true) + try await wait("Return to the root before opening Recent") { routes.isEmpty && navigation.transitionCoordinator == nil } + guard let drawer = backend.rootIDs.flatMap(descendants).first(where: { backend.model(id: $0)?.kind == .drawer }) else { + throw failure("Missing sidebar drawer") + } + try backend.performToggle(node: drawer, checked: true) + try await wait("Recent must expose its page") { find("link.sidebar.page.Recent C") != nil } + try backend.performPress(node: find("link.sidebar.page.Recent C")!) + try await wait("Recent selects the root page instead of pushing") { selectedPage == "Recent C" && routes.isEmpty } } else { try runtime.applyHostUpdate(kind: "open-node", payload: String(decoding: try JSONSerialization.data(withJSONObject: destination, options: .fragmentsAllowed), as: UTF8.self)) } @@ -108,7 +133,7 @@ import UIKit try await wait("Unselect must reach the active destination") { performed.last == "unselect" } try await wait("Unselect removes native selection chrome") { find("toolbar.outliner.selection") == nil } } - print("PASS: direct, linked/nested and search destinations display native selection actions and dispatch Copy/Unselect") + print("PASS: direct, linked/nested, search, Recent root and links from Recent display native selection actions and dispatch Copy/Unselect") } static func failure(_ message: String) -> NSError { NSError(domain: "NestedSelection", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) diff --git a/apple/Tests/UI/selection-gesture.swift b/apple/Tests/UI/selection-gesture.swift new file mode 100644 index 00000000..e3fdccb2 --- /dev/null +++ b/apple/Tests/UI/selection-gesture.swift @@ -0,0 +1,65 @@ +// Interactive companion to nested-selection-toolbar.swift. Launch with the runtime +// check runner's --interactive mode and exercise actual touch long presses. +import SwiftUI +@testable import Logseq + +@MainActor private final class SelectionGestureEffects: LGEffectExecuting { + var routes: [String] = [] + var root = "Journal" + var selected: String? + + func row(_ uuid: String, _ title: String, page: String) -> [String: Any] { + ["depth": 0, "block": ["uuid": uuid, "title": title, "pageId": page]] + } + + func rows(_ page: String) -> [[String: Any]] { + [row(page + "-child", "Hold here to select on " + page, page: page), + row(page == "Journal" ? "Page A" : "Linked B", "Open linked page using this bullet", page: page)] + } + + func snapshot() -> String { + var result: [String: Any] = [ + "graphName": "Gesture audit", "selectedGraphId": "gesture-audit", "isGraphUnlocked": true, + "recentPages": [["uuid": "Recent C", "title": "Recent C"]], + "outlinerRows": rows(root), + "outlinerState": ["selectedBlockIds": routes.isEmpty ? selected.map { [$0] } ?? [] : []], + "searchQuery": "Found", "searchResults": [["uuid": "Found", "title": "Found", "isPage": true]], + "nodeRoutes": routes.map { uuid -> [String: Any] in + ["uuid": uuid, "page": ["uuid": uuid, "title": uuid], "outlinerRows": rows(uuid), + "outlinerState": ["selectedBlockIds": uuid == routes.last ? selected.map { [$0] } ?? [] : []]] + } + ] + if root != "Journal" { result["selectedPage"] = ["uuid": root, "title": root] } + return String(decoding: try! JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + + func execute(_ effect: LGEffect) async -> LGEffectResolution { + switch effect.kind { + case "open-node", "open-search-result": routes.append(effect.text); selected = nil + case "close-node": _ = routes.popLast(); selected = nil + case "select-sidebar-page": root = effect.text; routes = []; selected = nil + case "long-press-outliner-block": selected = effect.text + case "outliner-toolbar": + if effect.text == "copy" || effect.text == "unselect" { selected = nil } + default: break + } + print("GESTURE_AUDIT kind=\(effect.kind) root=\(root) depth=\(routes.count) selection=\(selected ?? "none")") + return LGEffectResolution(succeeded: true, message: snapshot(), output: .coreResponse) + } +} + +@main struct SelectionGestureApplication: App { + private let runtime: LGRuntime + + init() { + let effects = SelectionGestureEffects() + runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try! runtime.applyCoreResponse(effects.snapshot()) + try! runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try! runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + } + + var body: some Scene { + WindowGroup { LGRendererRoot(renderer: runtime.renderer) } + } +} diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 7173b890..4e7d6144 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -32,14 +32,14 @@ NOTE: I will write *all* tests before I add any implementation behavior. | Order | Work | Current evidence and next action | | --- | --- | --- | | 1 | Editing position and image retention | Native regression reproduced a 135.67pt shift and repeated image requests; retaining hidden placeholder slots and their height passes the same test. | -| 2 | Keyboard jitter and page flashes | Device diagnostics reproduced idle 4–8pt jumps during shell/hydration cycles. Native regression reproduces 16pt drift: previously loaded empty blocks become taller skeletons during refresh. Preserve cached renderability separately from hydration freshness; also retain an editor scrolled beyond its window. Repeated navigation validation remains open. | -| 3 | Composer tags | Native controls are shared with property selection; hashtag moved after attachment and status and installed. Nine graph-backed/shared-host checks now cover sends and recovery, including all graph-switch paths, stale catalog failures, tagged asset retries and removing restored tags. Finish native picker checks. | +| 2 | Keyboard jitter and page flashes | Device diagnostics and native regressions reproduce and verify cached-empty-row height and offscreen editor retention fixes. Repeated native navigation now reads the core path directly; twelve push/pop cycles, nested pop-to-root, root scroll retention and guide presentation bounds pass. The user confirmed the installed idle-jitter fix. | +| 3 | Composer tags | Native controls share property selection; hashtag follows attachment/status. Nine graph-backed/shared-host checks, nine Android composer cases and the complete iOS native select/create/cancel/remove/send/recovery/graph-isolation flow pass. | | 4 | Trim on leaving editing | Shared RPC tests cover hide, switch, selection, collapse, zoom, Return, autosave, blank content, and failed storage; full core gate passes. | -| 5 | Nested/search long press | The feature branch already contains the destination-toolbar fix from `bb4575f`. Native direct/nested/search selection now traverses the effect boundary, visibly renders the bar and dispatches Copy/Unselect successfully. Complete the remaining Recent/root-path audit and physical gesture check. | -| 6 | Master-compatible clipboard | Eleven graph-backed round-trip cases pass: Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, empty/nonempty/parent placement, UTF-16 selection, paragraphs, fenced code, foreign graphs and atomic storage failure. Both iOS and Android native clipboard/selection/rapid-typing/failed-save checks pass. Audit remaining master cases before final delivery. | +| 5 | Nested/search long press | The destination-toolbar fix from `bb4575f` passes native direct/nested/search/Recent-root/linked-from-Recent checks through the effect boundary. Actual 800ms touch holds also show the native selection bar on Journal, nested destinations, Recent root, a destination from Recent and a search result; Copy clears it. | +| 6 | Master-compatible clipboard | Thirteen graph-backed round-trip cases cover Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, nonadjacent selection, empty/nonempty/parent placement, UTF-16 ranges, paragraphs, alternate list markers, fenced code/math, foreign graphs and atomic failure. The master audit additionally reproduced and fixed nested links when replacing selected text inside an existing formatted link. Native clipboard/selection/rapid-typing/failed-save checks pass on both hosts; rebuilds are being validated after this final core repair. | | 7 | Date-format preference | Implemented the master graph property, shared 27-format lexer/formatter, all journal creation paths, live journal/reference/property/navigation labels and graph-scoped native settings menus. Twelve graph/shared-host cases and the full 1,036-test gate pass. Coverage includes storage failure, restart/sync, graph changes, all formats, invalid legacy days, UUID date commands and search-index refresh. Native iOS theme/alignment/confirmed-choice checks and all 19 Android query/editor/property/outliner/settings checks pass. | | 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name/timestamps are read-only; Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | -| 9 | TestFlight crash | Read the specified crash, identify the crashed build and symbolicated stack, reproduce its cause, and add a regression check before fixing it. | +| 9 | TestFlight crash | The mobile URL redirects to the public App Store; browser login is required and its form is open. Xcode Organizer's existing account successfully refreshed recent crash clusters and downloaded logs, but no verified mapping to the supplied feedback ID is available. The configured API key returns 404 even for this app resource, so it cannot read the requested report. Specific crash/build/root cause remain unproven pending authenticated access to that report. | | 10 | Delivery | Run final gates, update PRs, and non-destructively install and launch the final build on the user's iPhone. | ## Editing and navigation @@ -86,7 +86,7 @@ If authentication or crash symbols are unavailable, report that precise missing ## Testing Details The core gate is `rtk proxy opam exec --switch=5.5.0 -- dune build @shared/native/runtest`. -The latest complete gate passed 1024 tests in 63.858 seconds, including clipboard round trips, composer graph isolation, table edit policy, cached empty-row renderability and bounded editor retention (`/tmp/logseq-clipboard-full-core-final.log`). +The latest complete gate passes 1,038 tests in 77.285 seconds (`/tmp/logseq-interaction-audit-core-final.log`), including final clipboard link-boundary and additional structure cases, graph date preferences, composer isolation, table policy, cached empty-row renderability and bounded editor retention. Native image retention checks count actual uncached image requests and compare rendered block coordinates. Table policy checks pass in `/tmp/logseq-table-policy-core-green.log` and native iOS geometry and interactions pass in `/tmp/logseq-table-divider-native-final.log`. Composer graph isolation and asset recovery regressions fail before their fixes in `/tmp/logseq-composer-recovery-red.log` and all nine composer cases pass in `/tmp/logseq-composer-recovery-green.log`; three pre-existing asset capture cases also remain green. @@ -94,6 +94,9 @@ Android initially passed 15 of 16 interaction checks, with the new vertical divi Clipboard core regressions fail in `/tmp/logseq-clipboard-red.log` before implementation and pass all eleven graph-backed cases in `/tmp/logseq-clipboard-core-final.log`. Native iOS clipboard transport fails before implementation in `/tmp/logseq-clipboard-native-red-2.log`; native clipboard, exact pre-paste selection, same/different block acknowledgement, rapid typing and failure recovery pass in `/tmp/logseq-clipboard-native-input.log`. The test uses the UITextView delegate input path and the production effect drain, avoiding programmatic `insertText` bypass and parallel test-only drains. Android clipboard transport fails before implementation in `/tmp/logseq-clipboard-android-red.log`. All eighteen native query/editor/property/outliner cases now pass in `/tmp/logseq-clipboard-android-green.log`, including actual Android clipboard creation/read, same/different block acknowledgements, rapid typing and failed storage recovery. Direct and nested destination selection, plus a result opened through the native search field, pass `/tmp/logseq-nested-selection-final-2.log`. The check no longer injects selected snapshots manually; long-press effects return the selection projection and Copy/Unselect return the cleared projection. +The expanded check passes `/tmp/logseq-recent-selection.log`: it pops the existing native stack, opens Recent through the actual sidebar control, selects the root, opens a linked destination and verifies selection actions there, then checks search. `apple/Tests/UI/selection-gesture.swift` is its interactive companion. Launch with `python3 apple/Tests/UI/run-simulator-runtime-check.py apple/Tests/UI/selection-gesture.swift --interactive ` and use actual 800ms touch holds. Computer-use checks verified the visible selection bar and Copy action on Journal, nested pages, Recent root, a destination from Recent and a search destination; the rendered nested/search screenshots were inspected. +The remaining selected-block master audit compares `compose-copied-blocks-contents`, `blocks-for-clipboard`, `paste-copied-blocks-or-text`, `selection-within-link?` and `paste-blocks` with the shared implementation. Root order, ancestor deduplication, copied descendants, snapshot identity, typed values, original reference targets and target placement agree. `/tmp/logseq-clipboard-audit-red-fixed.log` reproduces incorrect nested formatting for a partial selection inside a link. The fix checks whether the UTF-16 selection lies inside a complete formatted link, matching master's replacement rule. All thirteen cases pass `/tmp/logseq-clipboard-audit-green.log`, including a selection between two separate links that must still become a new link. An initial nonadjacent-selection fixture used an invalid fractional order; the corrected valid fixture passes without production changes. +The nested-label case additionally fails `/tmp/logseq-clipboard-nested-link-red.log`. Reusing the project's regex engine with master's non-greedy formatted-link expression fixes bracketed labels without treating text between separate links as linked. All thirteen cases pass `/tmp/logseq-clipboard-audit-final-green.log`, followed by the complete 1,038-test gate. The native clipboard transport and rapid-input/recovery check also passes `/tmp/logseq-clipboard-audit-native.log`. Upstream LUI PR 258 now includes Android stretch at `eea10c7bf99ad1a2a40cc84b1c9657a764073d72`; both the Swift package and opam pin reference it. Local Android verification uses `/tmp/logseq-lui-override.gradle` to consume that exact upstream worktree while preserving the user's dirty `duniverse/lui` checkout. Keyboard checks operate on the scroll view containing the measured block, rather than an arbitrary scroll view in the window. Navigation checks assert native controller depth so a test cannot pass without a real push or pop. @@ -138,7 +141,7 @@ Date-format implementation notes from the master audit: ## Question No product clarification blocks the interaction fixes. -Crash investigation may require the existing App Store Connect session and matching symbols; access has not yet been checked because the user requested that investigation last. +The requested TestFlight report still needs authenticated access. Both the in-app browser and Chrome lack a valid web session; the user has been asked to complete Apple login while independent build/install work continues. Xcode can fetch current crash clusters, including AttributeGraph failures, but the supplied feedback ID has not been correlated to a cluster. The configured API key also cannot see app `1601013908` (`GET /v1/apps/1601013908` returns 404), so its report-specific 404 must not be interpreted as evidence that the report does not exist. Do not infer this report's root cause from an uncorrelated cluster or older local logs. --- diff --git a/shared/src/logseq/core/block_clipboard.ml b/shared/src/logseq/core/block_clipboard.ml index 2759e43e..6d59ebe0 100644 --- a/shared/src/logseq/core/block_clipboard.ml +++ b/shared/src/logseq/core/block_clipboard.ml @@ -160,6 +160,15 @@ let parse_text ~fresh_uuid source = let paragraphs ~fresh_uuid source = Str.split (Str.regexp "\n\n+") source |> List.map (fun title -> {uuid = fresh_uuid (); parent = None; attrs = ["block/title", D.String (String.trim title)]}) +let formatted_link = Re.compile (Re.Pcre.re "\\[.*?\\]\\(.*?\\)") +let selection_within_link source first last = + let rec scan offset = + match Re.exec_opt ~pos:offset formatted_link source with + | Some group -> let start, finish = Re.Group.offset group 0 in + (first >= start && last <= finish) || scan finish + | None -> false in + first <> last && scan 0 + let inline content = let source = content.before_title in let first = S.byte_index_of_utf16 source content.selection_start in @@ -168,7 +177,7 @@ let inline content = let selected = String.sub source first (last - first) in let plain = normalize_lines content.plain_text in let url value = Str.string_match (Str.regexp "^\\(https?\\|file\\|ftp\\)://[^ \t\r\n]+$") value 0 in - let within_link = String.ends_with ~suffix:"](" before && String.starts_with ~prefix:")" after in + let within_link = selection_within_link source first last in let inserted = if before <> "" && after <> "" && String.ends_with ~suffix:"((" before && String.starts_with ~prefix:"))" after && String.starts_with ~prefix:"((" plain && String.ends_with ~suffix:"))" plain diff --git a/shared/test/logseq/block_clipboard_test.ml b/shared/test/logseq/block_clipboard_test.ml index 44fdbe56..c088a6b1 100644 --- a/shared/test/logseq/block_clipboard_test.ml +++ b/shared/test/logseq/block_clipboard_test.ml @@ -198,8 +198,38 @@ let selected_links_and_wrapped_references_match_master () = ["A😀Z", 1, 2, "https://example.com", "A[😀](https://example.com)Z"; "https://old.example", 0, 19, "https://new.example", "https://new.example"; "[Label](https://old.example)", 8, 19, "https://new.example", "[Label](https://new.example)"; + "[Label](https://old.example)", 16, 3, "https://new.example", "[Label](https://https://new.example.example)"; + "[Label](https://old.example)", 1, 5, "https://new.example", "[https://new.example](https://old.example)"; + "[Label](https://old.example)", 1, 5, "New label", "[New label](https://old.example)"; + "[Outer [Label]](https://old.example)", 8, 5, "https://new.example", "[Outer [https://new.example]](https://old.example)"; + "Outside [Label](https://old.example)", 0, 7, "https://new.example", "[Outside](https://new.example) [Label](https://old.example)"; + "[One](https://one) gap [Two](https://two)", 19, 3, "https://new.example", "[One](https://one) [gap](https://new.example) [Two](https://two)"; "(())", 2, 0, "((" ^ Q.alpha ^ "))", "((" ^ Q.alpha ^ "))"] +let nonadjacent_selection_excludes_unselected_siblings () = R.with_runtime (fun session runtime _ -> + insert runtime child Q.page "a5" "Paste here"; + let copied = copy session [target; Q.alpha] in + success (paste ~rich:(text (member "blocksEDN" copied)) session child "Paste here" (text (member "plainText" copied))); + let all = children runtime Q.page in + let rec after = function [] -> [] | (b : Cache_model.block) :: rest -> if b.uuid = child then rest else after rest in + check_eq ~msg:"unselected siblings between selected roots are not copied" + (List.map (fun (b : Cache_model.block) -> b.title) (after all)) [title runtime Q.alpha; title runtime target]; + check_eq ~msg:"the unselected source sibling remains unchanged" (title runtime Q.beta) "Beta result") + +let alternate_markdown_markers_and_math_match_master () = + R.with_runtime (fun session runtime _ -> + rename runtime target ""; + success (paste session target "" "+ Parent\n * Child\n* Tail"); + check_eq (title runtime target) "Parent"; + check_eq (List.map (fun (b : Cache_model.block) -> b.title) (children runtime target)) ["Child"]; + check_eq (List.hd (List.rev (children runtime Q.page))).title "Tail"); + R.with_runtime (fun session runtime _ -> + rename runtime target ""; + success (paste session target "" "$$\nx + y\n\n- literal\n$$\n- After math"); + check_eq (title runtime target) "x + y\n\n- literal"; + check_eq (value runtime target "logseq.property.node/display-type") (Some (D.Keyword "math")); + check_eq (List.hd (List.rev (children runtime Q.page))).title "After math") + let invalid_and_foreign_clipboard_use_plain_text () = R.with_runtime (fun session runtime _ -> let copied = copy session [Q.alpha; Q.beta] in let rich = text (member "blocksEDN" copied) in @@ -240,6 +270,8 @@ let cases = [ case "code editors paste literal text" code_editors_paste_literal_text; case "empty targets with children keep master placement" empty_targets_with_children_match_master; case "selected links and wrapped refs keep master semantics" selected_links_and_wrapped_references_match_master; + case "nonadjacent selected roots exclude intervening siblings" nonadjacent_selection_excludes_unselected_siblings; + case "alternate Markdown markers and literal math keep structure" alternate_markdown_markers_and_math_match_master; case "foreign and malformed clipboard use plain text" invalid_and_foreign_clipboard_use_plain_text; case "invalid trees and failed storage have no partial writes" invalid_tree_and_failed_storage_are_atomic; ] From 6df35e5270728cf4ecbc4ad52adbfb88d1dd6252 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 03:50:57 +0800 Subject: [PATCH 14/25] Record final interaction checks and iPhone install --- docs/agent-guide/016-mobile-interaction-regressions.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 4e7d6144..3d44ce30 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -36,11 +36,11 @@ NOTE: I will write *all* tests before I add any implementation behavior. | 3 | Composer tags | Native controls share property selection; hashtag follows attachment/status. Nine graph-backed/shared-host checks, nine Android composer cases and the complete iOS native select/create/cancel/remove/send/recovery/graph-isolation flow pass. | | 4 | Trim on leaving editing | Shared RPC tests cover hide, switch, selection, collapse, zoom, Return, autosave, blank content, and failed storage; full core gate passes. | | 5 | Nested/search long press | The destination-toolbar fix from `bb4575f` passes native direct/nested/search/Recent-root/linked-from-Recent checks through the effect boundary. Actual 800ms touch holds also show the native selection bar on Journal, nested destinations, Recent root, a destination from Recent and a search result; Copy clears it. | -| 6 | Master-compatible clipboard | Thirteen graph-backed round-trip cases cover Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, nonadjacent selection, empty/nonempty/parent placement, UTF-16 ranges, paragraphs, alternate list markers, fenced code/math, foreign graphs and atomic failure. The master audit additionally reproduced and fixed nested links when replacing selected text inside an existing formatted link. Native clipboard/selection/rapid-typing/failed-save checks pass on both hosts; rebuilds are being validated after this final core repair. | +| 6 | Master-compatible clipboard | Thirteen graph-backed round-trip cases cover Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, nonadjacent selection, empty/nonempty/parent placement, UTF-16 ranges, paragraphs, alternate list markers, fenced code/math, foreign graphs and atomic failure. The master audit additionally reproduced and fixed nested links when replacing selected text inside an existing formatted link. Native clipboard/selection/rapid-typing/failed-save checks and rebuilt packages pass on both hosts. | | 7 | Date-format preference | Implemented the master graph property, shared 27-format lexer/formatter, all journal creation paths, live journal/reference/property/navigation labels and graph-scoped native settings menus. Twelve graph/shared-host cases and the full 1,036-test gate pass. Coverage includes storage failure, restart/sync, graph changes, all formats, invalid legacy days, UUID date commands and search-index refresh. Native iOS theme/alignment/confirmed-choice checks and all 19 Android query/editor/property/outliner/settings checks pass. | | 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name/timestamps are read-only; Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | | 9 | TestFlight crash | The mobile URL redirects to the public App Store; browser login is required and its form is open. Xcode Organizer's existing account successfully refreshed recent crash clusters and downloaded logs, but no verified mapping to the supplied feedback ID is available. The configured API key returns 404 even for this app resource, so it cannot read the requested report. Specific crash/build/root cause remain unproven pending authenticated access to that report. | -| 10 | Delivery | Run final gates, update PRs, and non-destructively install and launch the final build on the user's iPhone. | +| 10 | Delivery | The interaction fixes pass the full 1,038-test core gate, native iOS checks and all 28 Android cases. Commit `04c10b4` was installed non-destructively as build `1792698528` and process `23494` was verified alive. PR 69 remains open. Delivery must be repeated after the specified TestFlight crash is resolved. | ## Editing and navigation @@ -97,6 +97,7 @@ Direct and nested destination selection, plus a result opened through the native The expanded check passes `/tmp/logseq-recent-selection.log`: it pops the existing native stack, opens Recent through the actual sidebar control, selects the root, opens a linked destination and verifies selection actions there, then checks search. `apple/Tests/UI/selection-gesture.swift` is its interactive companion. Launch with `python3 apple/Tests/UI/run-simulator-runtime-check.py apple/Tests/UI/selection-gesture.swift --interactive ` and use actual 800ms touch holds. Computer-use checks verified the visible selection bar and Copy action on Journal, nested pages, Recent root, a destination from Recent and a search destination; the rendered nested/search screenshots were inspected. The remaining selected-block master audit compares `compose-copied-blocks-contents`, `blocks-for-clipboard`, `paste-copied-blocks-or-text`, `selection-within-link?` and `paste-blocks` with the shared implementation. Root order, ancestor deduplication, copied descendants, snapshot identity, typed values, original reference targets and target placement agree. `/tmp/logseq-clipboard-audit-red-fixed.log` reproduces incorrect nested formatting for a partial selection inside a link. The fix checks whether the UTF-16 selection lies inside a complete formatted link, matching master's replacement rule. All thirteen cases pass `/tmp/logseq-clipboard-audit-green.log`, including a selection between two separate links that must still become a new link. An initial nonadjacent-selection fixture used an invalid fractional order; the corrected valid fixture passes without production changes. The nested-label case additionally fails `/tmp/logseq-clipboard-nested-link-red.log`. Reusing the project's regex engine with master's non-greedy formatted-link expression fixes bracketed labels without treating text between separate links as linked. All thirteen cases pass `/tmp/logseq-clipboard-audit-final-green.log`, followed by the complete 1,038-test gate. The native clipboard transport and rapid-input/recovery check also passes `/tmp/logseq-clipboard-audit-native.log`. +Final Android rebuild succeeds in `/tmp/logseq-interaction-audit-android-final-build.log`. All 28 native query, completion/clipboard, property, outliner, date-format and composer checks pass `/tmp/logseq-interaction-audit-android-final.log` (234.085s), including actual date preference selection and tag recovery/isolation. Native iOS build and phone packaging succeed in `/tmp/logseq-interaction-audit-simulator-build.log` and `/tmp/logseq-interaction-audit-device-final-build.log`. Upstream LUI PR 258 now includes Android stretch at `eea10c7bf99ad1a2a40cc84b1c9657a764073d72`; both the Swift package and opam pin reference it. Local Android verification uses `/tmp/logseq-lui-override.gradle` to consume that exact upstream worktree while preserving the user's dirty `duniverse/lui` checkout. Keyboard checks operate on the scroll view containing the measured block, rather than an arbitrary scroll view in the window. Navigation checks assert native controller depth so a test cannot pass without a real push or pop. @@ -148,3 +149,5 @@ The requested TestFlight report still needs authenticated access. Both the in-ap ## Latest interim device install At the user's next "install first" request, build `1792695320` from mobile commit `89b151a` was built, signed, installed and launched at 02:56 on the paired iPhone. Process `23154` was verified alive. The install preserves the data container and includes the structured multi-block clipboard changes. Date-format work is subsequent and was not included. Evidence: `/tmp/logseq-latest-device-build.log`, `/tmp/logseq-latest-device-install.json`, `/tmp/logseq-latest-device-launch.json` and `/tmp/logseq-latest-device-processes.json`. + +At 03:50, build `1792698528` (`LogseqRevision=04c10b4f`) was installed and launched on the same iPhone without uninstalling or resetting its container. Process `23494` was confirmed in the device process list. This build includes date-format settings, the direct core navigation path, larger query chevrons and final clipboard link-selection repair. Evidence: `/tmp/logseq-interaction-audit-device-final-build.log`, `/tmp/logseq-interaction-audit-device-install.json`, `/tmp/logseq-interaction-audit-device-launch.json` and `/tmp/logseq-interaction-audit-device-processes.json`. The specified TestFlight report remains an outstanding requirement; this install does not constitute completion of its crash investigation. From ec84911de27cb22559cebf7a899ea5721ec07965 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 04:07:43 +0800 Subject: [PATCH 15/25] Reuse block disclosure icons for query results --- apple/Sources/Logseq/LGRenderer.swift | 4 ++-- apple/Tests/UI/query-block-results.swift | 17 +++++++++++++++++ shared/src/logseq/view_query.ml | 4 ++-- shared/test/logseq/query_block_test.ml | 4 ++++ shared/test/logseq/result_view_test.ml | 4 ++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apple/Sources/Logseq/LGRenderer.swift b/apple/Sources/Logseq/LGRenderer.swift index d857f445..a0d22ed5 100644 --- a/apple/Sources/Logseq/LGRenderer.swift +++ b/apple/Sources/Logseq/LGRenderer.swift @@ -78,8 +78,8 @@ enum LGIconPolicy { "chevron-down": .assetName("chevron_down"), "chevron-up": .assetName("chevron_up"), "document": .assetName("document"), - "disclosure-down": .assetName("disclosure_down"), - "disclosure-right": .assetName("disclosure_right"), + "disclosure-down": .systemName("arrowtriangle.down.fill"), + "disclosure-right": .systemName("arrowtriangle.right.fill"), "refresh": .systemName("arrow.clockwise"), "logo": .assetName("logo"), "graph-locked": .systemName("lock"), diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index aeb7cdbf..fc79823b 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -122,6 +122,22 @@ import Vision try await wait("Query results must mount in the native outline") { find("query.result.query-owner.2") != nil } + func verifyDisclosure(expanded: Bool, screenshot: String) async throws { + guard let toggle = find("query.toggle.query-owner"), + let icon = backend.model(id: toggle)?.property(.icon)?.stringValue else { + throw failure("Results must expose a disclosure icon") + } + let symbol = expanded ? "arrowtriangle.down.fill" : "arrowtriangle.right.fill" + guard backend.iconSource(for: icon) == .systemName(symbol), UIImage(systemName: symbol) != nil else { + throw failure("Results must resolve the same native triangle as blocks: \(icon), expanded=\(expanded)") + } + try await Task.sleep(for: .milliseconds(250)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(screenshot)) + } + try await verifyDisclosure(expanded: true, screenshot: "query-disclosure-expanded.png") try await Task.sleep(for: .milliseconds(700)) window.layoutIfNeeded() let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in @@ -158,6 +174,7 @@ import Vision try runtime.applyCoreResponse(snapshot()) try backend.performPress(node: find("query.toggle.query-owner")!) try await wait("Folding must unmount query rows") { find("query.rows.query-owner") == nil } + try await verifyDisclosure(expanded: false, screenshot: "query-disclosure-collapsed.png") try backend.performPress(node: find("query.toggle.query-owner")!) try await wait("Unfolding must restore query rows") { find("query.result.query-owner.0") != nil } try backend.performExtensionEvent(node: nativeRow("result-0")!, name: "open-node", values: ["uuid": .string("result-0")]) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 765f5fb7..28efebe8 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -78,7 +78,7 @@ let legacy_view ~render_block _model_source row_source send : t = ~accessibility_identifier:(identifier "block") [column ~gap:4 [ button ~variant:`ghost ~text_alignment:`start ~min_height:44 - ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "chevron-down" else "chevron-right")) (Signal.value expanded))) + ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "disclosure-down" else "disclosure-right")) (Signal.value expanded))) ~text_signal:(own (Signal.map (fun query -> string_of_int (List.length query.rows) ^ " results") query)) ~label:"Toggle query results" ~accessibility_identifier:(identifier "toggle") ~on_press:(fun _ -> Signal.set expanded (not (Signal.sample (Signal.value expanded)))) []; @@ -557,7 +557,7 @@ let collection ~render_block model_source raw_source send : t = button ~variant:`ghost ~text_alignment:`start ~grow:1.0 ~accessibility_identifier:("query.toggle." ^ owner) ~disabled_signal:(waiting model_source owner) - ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "chevron-down" else "chevron-right")) (Signal.value expanded))) + ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "disclosure-down" else "disclosure-right")) (Signal.value expanded))) ~text_signal:(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") views (Signal.value expanded))) diff --git a/shared/test/logseq/query_block_test.ml b/shared/test/logseq/query_block_test.ml index b2164533..edc38e21 100644 --- a/shared/test/logseq/query_block_test.ml +++ b/shared/test/logseq/query_block_test.ml @@ -201,7 +201,11 @@ let ui_states_and_disclosure () = check_eq ~msg:"show more reveals the remaining results" (find app "more") (-1); let fold = find app "toggle" in check (fold <> -1); + check_eq ~msg:"expanded results use the block disclosure icon" + (App_test.property_string (Lui_app.runtime app) fold Lui_protocol.InlineIconName) "app:disclosure-down"; App_test.dispatch app (Lui_protocol.Press fold); App_test.flush app; + check_eq ~msg:"collapsed results use the block disclosure icon" + (App_test.property_string (Lui_app.runtime app) fold Lui_protocol.InlineIconName) "app:disclosure-right"; check_eq (find app "rows") (-1))) [App_test.ios_backend; App_test.kotlin_backend] diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index 57db5441..836bdd05 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -610,6 +610,10 @@ let native_toolbar_alignment_and_search_expansion () = check_eq (App_test.children host toolbar |> List.map (fun id -> App_test.property_string host id Lui_protocol.AccessibilityIdentifier)) ["query.toggle." ^ owner; "view.filter." ^ list_view; "view.sort." ^ list_view; "view.search.toggle." ^ owner; "view.type." ^ list_view]; + let assert_disclosure expected = + check_eq ~msg:"collection results reuse the block disclosure icon" + (App_test.property_string host (find ("query.toggle." ^ owner)) Lui_protocol.InlineIconName) expected in + assert_disclosure "app:disclosure-down"; check_eq ~msg:"search expands only when requested" (find ("view.search." ^ owner)) (-1); click ("view.search.toggle." ^ owner); check (find ("view.search." ^ owner) <> -1); From 466dfa80b0980f73b8a459538f11f4241bec2a0a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 04:12:38 +0800 Subject: [PATCH 16/25] Hide date format controls from native settings --- .../com/logseq/app/AndroidDateFormatTest.kt | 32 +++------------- apple/Tests/UI/settings-control-colors.swift | 38 ++++++------------- shared/src/logseq/view_settings.ml | 26 ------------- shared/test/logseq/journal_format_test.ml | 20 ++++------ 4 files changed, 25 insertions(+), 91 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt index 6a5134d1..eaf20c5f 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidDateFormatTest.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.logseq.app.runtime.LogseqNativeBridge import com.logseq.app.runtime.LuiPatchFilter @@ -19,7 +18,6 @@ import com.logseq.app.ui.extensions.logseqExtensionRegistry import dev.lui.LuiBackend import org.json.JSONObject import org.junit.After -import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -36,7 +34,7 @@ class AndroidDateFormatTest { .put("result", JSONObject().put("selectedGraphId", graph).put("graphName", graph) .apply { if (format != null) put("journalTitleFormat", format) }).toString() - @Test fun dateFormatUsesNativeChoicesAndStaysScopedToTheGraph() { + @Test fun dateFormatIsAbsentWhileEditorSettingsRemainAvailable() { val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) filter = LuiPatchFilter(backend) @@ -51,29 +49,11 @@ class AndroidDateFormatTest { Surface(Modifier.fillMaxSize(), color = LogseqTheme.darkScheme.background) { backend.Content(root) } } } - ui.onNodeWithTag("picker.settings.date-format").assertIsDisplayed().performClick() - ui.onNodeWithText("dd-MM-yyyy").assertIsDisplayed().performClick() - var selected: JSONObject? = null - ui.waitUntil(timeoutMillis = 5000) { - ui.runOnIdle { - repeat(24) { - val raw = bridge.takeEffect() - if (raw.isNotEmpty()) JSONObject(raw).optJSONObject("effect")?.let { effect -> - if (effect.optString("kind") == "set-journal-title-format") selected = effect - } - } - } - selected != null - } - val payload = JSONObject(selected!!.getString("text")) - assertEquals("demo", payload.getString("graphId")) - assertEquals("dd-MM-yyyy", payload.getString("format")) - ui.runOnIdle { - bridge.applySnapshot(snapshot("demo", "dd-MM-yyyy")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) - bridge.resolveEffect(selected!!.getLong("id"), true, "").takeIf { it.isNotEmpty() }?.let(filter::applyJson) - } - ui.onNodeWithText("dd-MM-yyyy").assertIsDisplayed() + ui.onNodeWithTag("picker.settings.date-format").assertDoesNotExist() + ui.onNodeWithText("Date format").assertDoesNotExist() + ui.onNodeWithTag("switch.settings.spell-check").assertIsDisplayed() ui.runOnIdle { bridge.applySnapshot(snapshot("other", null)).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } - ui.onNodeWithText("MMM do, yyyy").assertIsDisplayed() + ui.onNodeWithTag("picker.settings.date-format").assertDoesNotExist() + ui.onNodeWithText("MMM do, yyyy").assertDoesNotExist() } } diff --git a/apple/Tests/UI/settings-control-colors.swift b/apple/Tests/UI/settings-control-colors.swift index ee06fde8..aa3d1d57 100644 --- a/apple/Tests/UI/settings-control-colors.swift +++ b/apple/Tests/UI/settings-control-colors.swift @@ -70,12 +70,13 @@ import Vision } return observation.boundingBox } - let date = try labelRect("Date format") + guard recognition.results?.contains(where: { $0.topCandidates(1).first?.string == "Date format" }) != true else { + throw failure("Date format must not appear in settings") + } let spell = try labelRect("Spell check") let correction = try labelRect("Auto-correction") - guard abs(date.minX - spell.minX) * window.bounds.width <= 3, - abs(date.midY - spell.midY) <= abs(spell.midY - correction.midY) + 3 / window.bounds.height else { - throw failure("Date format must align with adjacent native editor settings and retain their row spacing") + guard abs(spell.minX - correction.minX) * window.bounds.width <= 3 else { + throw failure("Native editor settings must remain aligned") } func coloredPixels(in rect: CGRect, green: Bool) -> Int { let pixelRect = CGRect(x: rect.minX * image.scale, y: rect.minY * image.scale, @@ -99,34 +100,17 @@ import Vision let rect = control.convert(control.bounds, to: window) guard coloredPixels(in: rect, green: false) > 100 && coloredPixels(in: rect, green: true) == 0 else { throw failure("Native settings switches must use Logseq teal instead of system green") } } - for identifier in ["picker.settings.appearance", "picker.settings.language", "picker.settings.date-format"] { + for identifier in ["picker.settings.appearance", "picker.settings.language"] { guard let rect = frames[try find(identifier)] else { throw failure("Missing picker frame: " + identifier) } guard coloredPixels(in: rect, green: false) == 0 else { throw failure("Picker value must use text color: " + identifier) } } } - let picker = try find("picker.settings.date-format") - guard backend.model(id: try find("settings.date-format.option.yyyy-MM-dd"))?.isChecked == true, - let frame = frames[picker], frame.width > 80, frame.maxX < window.bounds.maxX else { - throw failure("The date format must show the current graph preference within the settings row") - } - let option = try find("settings.date-format.option.dd-MM-yyyy") - try backend.performChange(node: option) - for _ in 0..<100 { - if !effects.formats.isEmpty { break } - try await Task.sleep(for: .milliseconds(20)) - } - guard effects.formats.count == 1, - let data = effects.formats[0].data(using: .utf8), - let payload = try JSONSerialization.jsonObject(with: data) as? [String: String], - payload["graphId"] == "settings-colors", payload["format"] == "dd-MM-yyyy" else { - throw failure("A date-format choice must reach the graph-scoped mutation boundary") - } - try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"settings-colors","graphName":"Settings colors","journalTitleFormat":"dd-MM-yyyy","blocks":[],"outlinerRows":[]}}"#) - try await Task.sleep(for: .milliseconds(200)) - guard backend.model(id: try find("settings.date-format.option.dd-MM-yyyy"))?.isChecked == true else { - throw failure("The retained native picker must update from the confirmed graph preference") + guard backend.rootIDs.flatMap(ids).allSatisfy({ + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue != "picker.settings.date-format" + }), effects.formats.isEmpty else { + throw failure("Hidden date format controls must not retain interactive nodes or emit mutations") } - print("PASS: native settings retain theme colors and expose graph-scoped date format choices with confirmed updates") + print("PASS: native settings retain theme colors and aligned editor controls without date format options") } static func failure(_ message: String) -> NSError { NSError(domain: "SettingsControlColors", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) diff --git a/shared/src/logseq/view_settings.ml b/shared/src/logseq/view_settings.ml index 471e5ff0..04ffd119 100644 --- a/shared/src/logseq/view_settings.ml +++ b/shared/src/logseq/view_settings.ml @@ -12,30 +12,6 @@ let with_native_control_tint role (control : t) : t = tweak | _ -> control context parent -let settings_date_format_control context model_source send : t = - let formats = List.sort String.compare Journal.built_in_journal_title_formatters in - let selected format = Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format = format) model_source in - let choose format _ = ignore (send (Model.ChooseJournalTitleFormat format)) in - if Lui_ui.host context <> KotlinHost then - radio_group ~label:"Date format" ~style_class:"menu" - ~accessibility_identifier:"picker.settings.date-format" - (List.map (fun format -> radio ~text:format ~checked_signal:(selected format) - ~accessibility_identifier:("settings.date-format.option." ^ format) - ~on_change:(choose format) []) formats) - |> with_native_control_tint "picker" - else row ~min_height:44 ~padding_horizontal:16 ~cross:`center - [text ~value:"Date format" []; spacer []; - menu ~label:"Date format" ~foreground:"secondary" - ~text_signal:(Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format) model_source) - ~disabled_signal:(Signal.map (fun (model : Model.logseq_model) -> model.selected_graph_id = None) model_source) - ~accessibility_identifier:"picker.settings.date-format" - (List.map (fun format -> - menu_item ~text:format - ~selected_signal:(Signal.map (fun (model : Model.logseq_model) -> model.journal_title_format = format) model_source) - ~accessibility_identifier:("settings.date-format.option." ^ format) - ~on_press:(fun _ -> ignore (send (Model.ChooseJournalTitleFormat format))) []) - formats)] - let settings_language_choice_radio model_source (choice_source : Model.settings_language_choice Signal.signal) send : radio_el = @@ -779,7 +755,6 @@ let settings_kotlin_screen (context : Lui_ui.ui_context) model_source text ~style_class:"subheadline semibold" ~padding_horizontal:16 ~foreground:"primary" ~accessibility_identifier:"label.settings.editor" ~value:"Editor" []; - settings_date_format_control context model_source send; settings_editor_card context (Signal.map View_base.settings_spell_check model_source) (Signal.map View_base.settings_auto_correction model_source) @@ -907,7 +882,6 @@ let settings_form_screen (context : Lui_ui.ui_context) model_source send ]; heading ~value:"Editor" ~foreground:"muted-foreground" ~accessibility_identifier:"label.settings.editor" []; - settings_date_format_control context model_source send; with_native_control_tint "switch" (toggle ~checked_signal: (Signal.map View_base.settings_spell_check model_source) diff --git a/shared/test/logseq/journal_format_test.ml b/shared/test/logseq/journal_format_test.ml index 8d47ded2..6e57462f 100644 --- a/shared/test/logseq/journal_format_test.ml +++ b/shared/test/logseq/journal_format_test.ml @@ -98,22 +98,18 @@ let failed_storage_keeps_the_previous_graph_preference () = R.with_runtime (fun (String_kit.index_of ~sub:"Storage unavailable" ~start:0 (J.to_string response) <> None); check_eq (stored_format runtime) before) -let settings_share_native_date_format_choices () = +let settings_hide_date_format_choices () = List.iter (fun backend -> R.with_runtime (fun session _ _ -> Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> App_test.send app Model.OpenSettings; App_test.flush app; let host = Lui_app.runtime app in let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in - check ~msg:"both hosts expose an editable date-format preference" (find "picker.settings.date-format" <> -1); - let option = find "settings.date-format.option.yyyy-MM-dd" in - check ~msg:"format choices use the shared native menu" (option <> -1); - App_test.dispatch app (if App_test.node host option = Some Lui_protocol.Radio then Lui_protocol.Change option else Lui_protocol.Press option); App_test.flush app; - let effects = (App.model app).pending_effects |> List.map (fun eff -> J.from_string (Native_bridge.encode_effect eff)) in - let mutation = List.find_opt (fun eff -> member "kind" eff = `String "set-journal-title-format") effects in - check ~msg:"choosing a date format uses the graph mutation boundary" (mutation <> None); - let payload = J.from_string (R.text (member "text" (Option.get mutation))) in - check_eq (member "graphId" payload) (`String "plain-1"); - check_eq (member "format" payload) (`String "yyyy-MM-dd")))) [App_test.ios_backend; App_test.kotlin_backend] + check_eq ~msg:"date format is not exposed in native settings" + (find "picker.settings.date-format") (-1); + check_eq ~msg:"hidden format options must not remain interactive" + (find "settings.date-format.option.yyyy-MM-dd") (-1); + check ~msg:"editor settings remain available" (find "switch.settings.spell-check" <> -1)))) + [App_test.ios_backend; App_test.kotlin_backend] let open_destinations_refresh_without_reopening () = R.with_runtime (fun _ runtime _ -> let day = 20261023 in @@ -217,5 +213,5 @@ let cases = [ case "invalid and stale date preferences never write" invalid_and_stale_changes_never_write; case "all journal creation paths use the chosen format" all_journal_creation_paths_use_the_preference; case "failed storage preserves the graph preference" failed_storage_keeps_the_previous_graph_preference; - case "native settings share graph date format choices" settings_share_native_date_format_choices; + case "native settings hide date format choices" settings_hide_date_format_choices; ] From e5c39f73e6e09e88de50106173f813b968e565fe Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 04:36:08 +0800 Subject: [PATCH 17/25] Protect page renames and enable table Name editing --- .../assets/query-result-views.json | 32 ++--- .../com/logseq/app/AndroidPageRenameTest.kt | 115 +++++++++++++++ .../logseq/app/AndroidQueryResultViewsTest.kt | 22 ++- apple/Tests/UI/object-pages.swift | 61 ++++++++ apple/Tests/UI/page-rename.swift | 104 ++++++++++++++ apple/Tests/UI/query-block-results.swift | 9 +- logseq.opam | 1 + logseq.opam.locked | 3 + shared/native/dune | 1 + shared/native/dune_modules/core.sexp | 1 + shared/src/logseq/core/dev_query.ml | 24 +++- shared/src/logseq/core/dev_query.mli | 1 + shared/src/logseq/core/node_property.ml | 11 +- shared/src/logseq/core/node_title.ml | 74 ++++++++++ shared/src/logseq/core/node_title.mli | 5 + shared/src/logseq/core/pending_projection.ml | 6 +- shared/src/logseq/core/result_view.ml | 15 +- shared/src/logseq/core/rpc_session.ml | 58 +++++--- shared/src/logseq/view_property.ml | 4 + shared/src/logseq/view_property.mli | 2 + shared/src/logseq/view_query.ml | 4 + shared/src/logseq/view_rows.ml | 1 + shared/src/logseq/view_screens.ml | 46 +++++- shared/test/logseq/app_test.ml | 42 ++++++ shared/test/logseq/node_property_test.ml | 132 ++++++++++++++++++ shared/test/logseq/result_view_test.ml | 27 +++- 26 files changed, 737 insertions(+), 64 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt create mode 100644 apple/Tests/UI/object-pages.swift create mode 100644 apple/Tests/UI/page-rename.swift create mode 100644 shared/src/logseq/core/node_title.ml create mode 100644 shared/src/logseq/core/node_title.mli diff --git a/android/app/src/androidTest/assets/query-result-views.json b/android/app/src/androidTest/assets/query-result-views.json index f7152823..5b182c2e 100644 --- a/android/app/src/androidTest/assets/query-result-views.json +++ b/android/app/src/androidTest/assets/query-result-views.json @@ -43,7 +43,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 1" @@ -79,7 +79,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 2" @@ -115,7 +115,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 3" @@ -144,7 +144,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": false + "editable": true }, { "attr": "user.property/Score", @@ -199,7 +199,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 1" @@ -235,7 +235,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 2" @@ -271,7 +271,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 3" @@ -301,7 +301,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": false + "editable": true }, { "attr": "user.property/Score", @@ -356,7 +356,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 1" @@ -392,7 +392,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 2" @@ -428,7 +428,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 3" @@ -458,7 +458,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": false + "editable": true }, { "attr": "user.property/Score", @@ -513,7 +513,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 1", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 1" @@ -549,7 +549,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 2", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 2" @@ -585,7 +585,7 @@ "attr": "block/title", "kind": "string", "text": "Project result 3", - "editable": false, + "editable": true, "raw": { "type": "string", "value": "Project result 3" @@ -615,7 +615,7 @@ "attr": "block/title", "title": "Name", "kind": "string", - "editable": false + "editable": true }, { "attr": "user.property/Score", diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt new file mode 100644 index 00000000..093bcd3c --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt @@ -0,0 +1,115 @@ +package com.logseq.app + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.test.espresso.Espresso.pressBack +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.logseq.app.runtime.LogseqNativeBridge +import com.logseq.app.runtime.LuiPatchFilter +import com.logseq.app.runtime.dispatchLuiEvent +import com.logseq.app.runtime.logseqIconResolver +import com.logseq.app.ui.LogseqTheme +import com.logseq.app.ui.extensions.logseqExtensionRegistry +import dev.lui.LuiBackend +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidPageRenameTest { + @get:Rule val ui = createComposeRule() + private lateinit var bridge: LogseqNativeBridge + private lateinit var filter: LuiPatchFilter + + @After fun closeBridge() { if (::bridge.isInitialized) ui.runOnIdle { bridge.close() } } + + private fun snapshot(title: String, rename: Boolean = true, graph: String = "rename-check"): String { + val property = JSONObject().put("attr", "block/title").put("title", "Name").put("kind", "string") + .put("text", title).put("editable", true) + .put("raw", JSONObject().put("type", "string").put("value", title)) + return JSONObject().put("ok", true).put("result", JSONObject().put("selectedGraphId", graph) + .put("graphName", graph).put("selectedPage", JSONObject().put("uuid", "page").put("title", title)) + .put("outlinerRows", JSONArray()).put("nodeProperties", JSONObject().put("ownerUuid", "page") + .put("properties", JSONArray()).put("rename", if (rename) property else JSONObject.NULL))).toString() + } + + private fun apply(title: String, rename: Boolean = true, graph: String = "rename-check") { + bridge.applySnapshot(snapshot(title, rename, graph)).takeIf { it.isNotEmpty() }?.let(filter::applyJson) + } + + private fun takeRename(): JSONObject { + var result: JSONObject? = null + ui.waitUntil(timeoutMillis = 5000) { + ui.runOnIdle { + repeat(32) { + val raw = bridge.takeEffect() + if (raw.isNotEmpty()) JSONObject(raw).optJSONObject("effect")?.let { + if (it.optString("kind") == "node-property") result = it + } + } + } + result != null + } + return requireNotNull(result) + } + + @Test fun nativeRenameSupportsFailureRetryProtectedPagesAndGraphChanges() { + val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, + extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) + filter = LuiPatchFilter(backend) + bridge = LogseqNativeBridge(onPatch = { if (it.isNotEmpty()) filter.applyJson(it) }) + val root = bridge.initialize(authenticationCode = 3) + apply("Original page") + bridge.applyHostUpdate("graph-loading", "false").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + ui.setContent { + MaterialExpressiveTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) { + Surface(Modifier.fillMaxSize(), color = LogseqTheme.lightScheme.background) { backend.Content(root) } + } + } + val editor = "page.rename.page" + ui.onNodeWithTag("button.overflow-menu").performClick() + ui.onNodeWithTag("button.page.rename").assertIsDisplayed().performClick() + ui.onNodeWithTag("view.edit.clear.$editor").assertDoesNotExist() + ui.onNodeWithTag("view.edit.value.$editor").assertIsDisplayed().performTextReplacement("Renamed page") + ui.onNodeWithTag("view.edit.save.$editor").performClick() + val failed = takeRename() + val payload = JSONObject(failed.getString("text")) + assertEquals("rename-check", payload.getString("graphId")) + assertEquals("page", payload.getString("blockUuid")) + assertEquals("block/title", payload.getString("attr")) + assertEquals("Renamed page", payload.getString("value")) + ui.runOnIdle { bridge.resolveEffect(failed.getLong("id"), false, "Storage unavailable").takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("view.edit.value.$editor").assertIsDisplayed() + ui.onNodeWithTag("view.edit.save.$editor").performClick() + val retried = takeRename() + ui.runOnIdle { + apply("Renamed page") + bridge.resolveEffect(retried.getLong("id"), true, "").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + } + ui.onNodeWithTag("view.edit.value.$editor").assertDoesNotExist() + ui.onNodeWithTag("title.selected-page").assertIsDisplayed() + ui.runOnIdle { apply("Protected page", rename = false) } + ui.onNodeWithTag("button.overflow-menu").performClick() + ui.onNodeWithText("Share").assertIsDisplayed() + ui.onNodeWithTag("button.page.rename").assertDoesNotExist() + pressBack() + ui.runOnIdle { apply("Renamed page") } + ui.onNodeWithTag("button.overflow-menu").performClick() + ui.onNodeWithTag("button.page.rename").performClick() + ui.onNodeWithTag("view.edit.value.$editor").assertIsDisplayed() + ui.runOnIdle { apply("Other graph page", graph = "other-graph") } + ui.onNodeWithTag("view.edit.value.$editor").assertDoesNotExist() + } +} diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt index b4f2d406..3fde5dc0 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.captureToImage @@ -164,6 +163,22 @@ class AndroidQueryResultViewsTest { assertEquals("gallery", action.getJSONObject("config").getString("type")) } + private fun assertObjectPageHidesBlocks(property: Boolean) { + mount { result -> + val query = result.getJSONArray("outlinerRows").getJSONObject(0).getJSONObject("block").getJSONObject("query") + result.put("selectedPage", JSONObject().put("uuid", "collection").put("title", "Collection")) + .put("selectedPageIsTag", !property).put("selectedPageIsProperty", property) + .put("resultViews", query) + } + ui.onNodeWithTag("outliner.block.query-owner").assertDoesNotExist() + ui.onNodeWithTag("button.outliner.placeholder.collection").assertDoesNotExist() + ui.onNodeWithText("Project result 1").assertIsDisplayed() + capture(if (property) "property-objects" else "tag-objects") + } + + @Test fun tagPageHidesBlocksAndPlaceholderButShowsObjects() = assertObjectPageHidesBlocks(false) + @Test fun propertyPageHidesBlocksAndPlaceholderButShowsObjects() = assertObjectPageHidesBlocks(true) + @Test fun foldedQueryResultsLoadOnlyWhenTapped() { mount { result -> val query = result.getJSONArray("outlinerRows").getJSONObject(0).getJSONObject("block").getJSONObject("query") @@ -212,7 +227,10 @@ class AndroidQueryResultViewsTest { ui.onNodeWithTag("view.table.scroll.table").performTouchInput { swipeLeft() } ui.onNodeWithTag("view.table.scroll.table").performTouchInput { swipeRight() } ui.onNodeWithTag("sidebar.content").assertIsNotDisplayed() - ui.onNodeWithTag("view.cell.table.result-0.block/title").assertHasNoClickAction() + ui.onNodeWithTag("view.cell.table.result-0.block/title").performClick() + ui.onNodeWithTag("view.edit.value.table").assertIsDisplayed() + ui.onNodeWithTag("view.edit.clear.table").assertDoesNotExist() + ui.onNodeWithTag("view.edit.cancel.table").performClick() ui.onNodeWithTag("view.edit.sheet.table").assertDoesNotExist() for (attr in listOf("user.property/Score")) { val header = ui.onNodeWithTag("view.cell.separator.table.header.$attr", useUnmergedTree = true) diff --git a/apple/Tests/UI/object-pages.swift b/apple/Tests/UI/object-pages.swift new file mode 100644 index 00000000..0f55f676 --- /dev/null +++ b/apple/Tests/UI/object-pages.swift @@ -0,0 +1,61 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class ObjectPageEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + LGEffectResolution(succeeded: true, message: "", output: .discard) + } +} + +@main struct ObjectPagesCheck { + @MainActor static func main() async throws { + func snapshot(_ kind: String) throws -> String { + let result: [String: Any] = [ + "selectedGraphId": "object-pages", "graphName": "Object pages", + "selectedPage": ["uuid": "collection", "title": "Collection"], + "selectedPageIsTag": kind == "tag", "selectedPageIsProperty": kind == "property", + "outlinerRows": [["depth": 0, "block": ["uuid": "page-child", "title": "Page child", "pageId": "collection"]]], + "resultViews": ["ownerUuid": "collection", "featureType": "tagged", "views": [ + ["uuid": "objects-view", "name": "All", "type": "list", "rows": [ + ["block": ["uuid": "object-result", "title": "Visible object", "pageId": "elsewhere"]] + ]] + ]] + ] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: ObjectPageEffects()) + try runtime.applyCoreResponse(snapshot("tag")) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let backend = runtime.renderer.backend + func ids(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(ids) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(ids).first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + for kind in ["tag", "property", "page", "tag"] { + try runtime.applyCoreResponse(snapshot(kind)) + try await Task.sleep(for: .milliseconds(500)) + let ordinary = kind == "page" + guard (find("outliner.block.page-child") != nil) == ordinary, + (find("button.outliner.placeholder.collection") != nil) == ordinary, + find("outliner.block.object-result") != nil else { + throw NSError(domain: "ObjectPages", code: 1, userInfo: [NSLocalizedDescriptionKey: + "Only ordinary pages may display page blocks and placeholders; objects must remain visible: " + kind]) + } + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("objects-" + kind + ".png")) + } + print("PASS: tag/property pages hide blocks and placeholders, ordinary pages retain them, and objects stay mounted") + } +} diff --git a/apple/Tests/UI/page-rename.swift b/apple/Tests/UI/page-rename.swift new file mode 100644 index 00000000..18bfe0bb --- /dev/null +++ b/apple/Tests/UI/page-rename.swift @@ -0,0 +1,104 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class RenameEffects: LGEffectExecuting { + var reject = true + var requests: [[String: Any]] = [] + var save: ((String) throws -> String)? + func execute(_ effect: LGEffect) async -> LGEffectResolution { + guard effect.kind == "node-property", let data = effect.text.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return LGEffectResolution(succeeded: true, message: "", output: .discard) + } + requests.append(payload) + if reject { return LGEffectResolution(succeeded: false, message: "Storage unavailable") } + do { return LGEffectResolution(succeeded: true, message: try save?(payload["value"] as? String ?? "") ?? "") } + catch { return LGEffectResolution(succeeded: false, message: String(describing: error)) } + } +} + +@main struct PageRenameCheck { + @MainActor static func main() async throws { + var title = "Original page" + var graph = "rename-check" + var canRename = true + func snapshot() throws -> String { + let property: [String: Any] = ["attr": "block/title", "title": "Name", "kind": "string", + "text": title, "editable": true, "raw": ["type": "string", "value": title]] + let result: [String: Any] = ["selectedGraphId": graph, "graphName": graph, + "selectedPage": ["uuid": "page", "title": title], "outlinerRows": [], + "nodeProperties": ["ownerUuid": "page", "properties": [], "rename": canRename ? property as Any : NSNull()]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let effects = RenameEffects() + effects.save = { title = $0; return try snapshot() } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let backend = runtime.renderer.backend + func ids(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(ids) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(ids).first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(30)) + } + throw NSError(domain: "PageRename", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer).tint(LogseqThemePolicy.accent)) + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + func capture(_ name: String) async throws { + try await Task.sleep(for: .milliseconds(300)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(name)) + } + let editor = "page.rename.page" + try await wait("Ordinary pages offer Rename page") { find("button.page.rename") != nil } + try backend.performPress(node: find("button.page.rename")!) + try await wait("Rename presents a native sheet") { window.rootViewController?.presentedViewController != nil && find("view.edit.value." + editor) != nil } + try await capture("page-rename-editor.png") + guard find("view.edit.clear." + editor) == nil else { throw NSError(domain: "PageRename", code: 2) } + try backend.performTextChange(node: find("view.edit.value." + editor)!, text: "Renamed page") + try backend.performPress(node: find("view.edit.save." + editor)!) + try await wait("A failed save keeps the edited name available for retry") { + effects.requests.count == 1 && find("view.edit.value." + editor) != nil && runtime.lastError == "Storage unavailable" + } + try await capture("page-rename-failure.png") + effects.reject = false + try await Task.sleep(for: .milliseconds(200)) + try backend.performPress(node: find("view.edit.save." + editor)!) + try await wait("Successful rename updates the page title and dismisses the editor") { + title == "Renamed page" && find("view.edit.value." + editor) == nil + && backend.model(id: find("title.selected-page") ?? -1)?.property(.text)?.stringValue == title + } + guard effects.requests.allSatisfy({ $0["graphId"] as? String == "rename-check" && $0["attr"] as? String == "block/title" }) else { + throw NSError(domain: "PageRename", code: 3) + } + try await wait("Native dismissal finishes") { window.rootViewController?.presentedViewController == nil } + try await capture("page-renamed.png") + canRename = false + try runtime.applyCoreResponse(snapshot()) + try await wait("Protected pages do not expose Rename page") { find("button.page.rename") == nil } + canRename = true + try runtime.applyCoreResponse(snapshot()) + try await wait("Returning to an ordinary page restores Rename page") { find("button.page.rename") != nil } + try backend.performPress(node: find("button.page.rename")!) + try await wait("Rename can be opened again") { find("view.edit.value." + editor) != nil } + graph = "different-graph" + try runtime.applyCoreResponse(snapshot()) + try await wait("Changing graphs dismisses the captured rename editor") { find("view.edit.value." + editor) == nil } + print("PASS: native rename sheet, source text, no Clear value, save failure/retry, title refresh, protected menus and graph isolation") + } +} diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index fc79823b..5cc3cc23 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -48,7 +48,7 @@ import Vision "error": queryError as Any? ?? NSNull()] if includeViews { let columns: [[String: Any]] = [ - ["attr": "block/title", "title": "Name", "kind": "default", "editable": false, "filterOperators": ["is", "is-not", "text-contains", "text-not-contains"]], + ["attr": "block/title", "title": "Name", "kind": "default", "editable": true, "filterOperators": ["is", "is-not", "text-contains", "text-not-contains"]], ["attr": "user.property/Score", "title": "Score", "kind": "number", "editable": true, "ref": true, "filterValues": (1...3).map { value in ["title": "\(value)", "match": [String(format: "30000000-0000-4000-8000-%012d", value)]] @@ -58,7 +58,7 @@ import Vision let rows = results.enumerated().map { index, result -> [String: Any] in var result = result result["properties"] = [ - ["attr": "block/title", "text": "Project result \(index + 1)", "editable": false, + ["attr": "block/title", "text": "Project result \(index + 1)", "editable": true, "raw": ["type": "string", "value": "Project result \(index + 1)"]], ["attr": "user.property/Score", "kind": "number", "text": index == 1 ? "" : "\(index + 1)", "editable": true, "raw": ["type": "ref-uuid", "value": String(format: "30000000-0000-4000-8000-%012d", index + 1)]], @@ -230,6 +230,11 @@ import Vision guard tableText.contains("Name") && tableText.contains("Project result 1") else { throw failure("Table cells must have visible native content: \(tableText)") } + try backend.performPress(node: find("view.cell.table.result-0.block/title")!) + try await wait("Name must open its native text editor") { find("view.edit.value.table") != nil } + guard find("view.edit.clear.table") == nil else { throw failure("Name must not offer Clear value") } + try backend.performPress(node: find("view.edit.cancel.table")!) + try await wait("Name dismissal must finish") { window.rootViewController?.presentedViewController == nil } try backend.performPress(node: find("view.cell.table.result-0.user.property/Score")!) try await wait("Table cell editor must present") { find("view.edit.value.table") != nil } _ = try await capture("query-cell-editor.png") diff --git a/logseq.opam b/logseq.opam index 848b91d3..63ca75ce 100644 --- a/logseq.opam +++ b/logseq.opam @@ -22,6 +22,7 @@ depends: [ "sqlite3" "re" "uucp" + "uunf" ] pin-depends: [ ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] diff --git a/logseq.opam.locked b/logseq.opam.locked index 4a7e80fb..56ffb13f 100644 --- a/logseq.opam.locked +++ b/logseq.opam.locked @@ -161,6 +161,7 @@ depends: [ "uopt" {= "v0.17.0" & ?vendor} "uri" {= "4.4.0" & ?vendor} "uucp" {= "17.0.0+dune" & ?vendor} + "uunf" {= "17.0.0+dune" & ?vendor} "uutf" {= "1.0.3+dune" & ?vendor} "variantslib" {= "v0.17.0" & ?vendor} "xmlm" {= "dev" & ?vendor} @@ -214,6 +215,7 @@ depexts: [ ["system:pkgconf"] {os = "win32" & os-distribution = "cygwinports"} ] pin-depends: [ + ["uunf.17.0.0+dune" "git+https://github.com/dune-universe/uunf.git#3ce81b762a9fcbb817bceab8274bb4636e861442"] [ "alcotest.1.9.1" "https://github.com/mirage/alcotest/releases/download/1.9.1/alcotest-1.9.1.tbz" @@ -785,6 +787,7 @@ x-opam-monorepo-cli-args: [ "--ocaml-version=5.5.0" "--opam-provided=ocaml-compiler" "logseq" ] x-opam-monorepo-duniverse-dirs: [ + ["git+https://github.com/dune-universe/uunf.git#3ce81b762a9fcbb817bceab8274bb4636e861442" "uunf"] [ "https://github.com/dune-universe/uucp/releases/download/v17.0.0%2Bdune/uucp-17.0.0.dune.tbz" "uucp" diff --git a/shared/native/dune b/shared/native/dune index 8f8d3c62..54b25c92 100644 --- a/shared/native/dune +++ b/shared/native/dune @@ -29,6 +29,7 @@ str re uucp + uunf unix threads.posix ctypes.foreign) diff --git a/shared/native/dune_modules/core.sexp b/shared/native/dune_modules/core.sexp index d95cb703..7749da42 100644 --- a/shared/native/dune_modules/core.sexp +++ b/shared/native/dune_modules/core.sexp @@ -13,6 +13,7 @@ "Query_block" "Result_view" "Node_property" +"Node_title" "Journal_sync" "E2ee" "E2ee_keyring" diff --git a/shared/src/logseq/core/dev_query.ml b/shared/src/logseq/core/dev_query.ml index 0465a7bf..7d72764f 100644 --- a/shared/src/logseq/core/dev_query.ml +++ b/shared/src/logseq/core/dev_query.ml @@ -404,12 +404,24 @@ let encode_utf8 (cps : int list) : string = (* String.prototype.toLowerCase — per-codepoint full simple case mapping. *) let lowercase (s : string) : string = - decode_utf8 s - |> List.concat_map (fun cp -> - match Uucp.Case.Map.to_lower (Uchar.unsafe_of_int cp) with - | `Self -> [ cp ] - | `Uchars us -> List.map Uchar.to_int us) - |> encode_utf8 + let scalars = Array.of_list (decode_utf8 s) in + let cased_after = Array.make (Array.length scalars) false in + let following = ref false in + for index = Array.length scalars - 1 downto 0 do + cased_after.(index) <- !following; + let value = Uchar.unsafe_of_int scalars.(index) in + if not (Uucp.Case.is_case_ignorable value) then following := Uucp.Case.is_cased value + done; + let output = Buffer.create (String.length s) and preceding = ref false in + Array.iteri (fun index cp -> + let value = Uchar.unsafe_of_int cp in + (* JavaScript's default lowercase includes the contextual Final_Sigma rule. *) + if cp = 0x03A3 && !preceding && not cased_after.(index) then Buffer.add_utf_8_uchar output (Uchar.of_int 0x03C2) + else (match Uucp.Case.Map.to_lower value with + | `Self -> Buffer.add_utf_8_uchar output value + | `Uchars values -> List.iter (Buffer.add_utf_8_uchar output) values); + if not (Uucp.Case.is_case_ignorable value) then preceding := Uucp.Case.is_cased value) scalars; + Buffer.contents output (* String.prototype.toUpperCase — per-codepoint full simple case mapping. *) let uppercase (s : string) : string = diff --git a/shared/src/logseq/core/dev_query.mli b/shared/src/logseq/core/dev_query.mli index ebfe1174..c52da247 100644 --- a/shared/src/logseq/core/dev_query.mli +++ b/shared/src/logseq/core/dev_query.mli @@ -17,6 +17,7 @@ module Unicode : sig val lowercase : string -> string end module Date_time_util : sig + val valid_journal_title : ?formatters:string list -> string -> bool type period = Days | Weeks | Months | Years | Hours | Minutes val time_ms : unit -> int64 val minus : period -> int -> int64 -> int64 diff --git a/shared/src/logseq/core/node_property.ml b/shared/src/logseq/core/node_property.ml index c65152be..cc7b9231 100644 --- a/shared/src/logseq/core/node_property.ml +++ b/shared/src/logseq/core/node_property.ml @@ -28,12 +28,19 @@ let render db ~owner ~encode_block = | None -> "", attr in let attrs = Hashtbl.fold (fun attr () acc -> attr :: acc) attrs [] |> List.sort (fun a b -> compare (order a) (order b)) in - Ok (`Assoc ["ownerUuid", `String owner; "properties", `List (List.map (fun attr -> + let rename = if Node_title.can_rename_page db id then + Result_view.property_json ~include_candidates:false db ~owner ~attr:"block/title" ~encode_block + else `Null in + Ok (`Assoc ["ownerUuid", `String owner; "rename", rename; "properties", `List (List.map (fun attr -> Result_view.property_json db ~owner ~attr ~encode_block) attrs)]) let mutation db ~now ~fresh_uuid input = try let attr = match input with `Assoc fields -> (match List.assoc_opt "attr" fields with Some (`String attr) -> attr | _ -> "") | _ -> "" in - if not (visible db attr) then invalid_arg "This property is read-only"; + let rename = attr = "block/title" && (match input with + | `Assoc fields -> (match List.assoc_opt "blockUuid" fields with Some (`String uuid) -> + Option.fold ~none:false ~some:(Node_title.can_rename_page db) (Ds.entid db "block/uuid" (Ds.Uuid uuid)) | _ -> false) + | _ -> false) in + if not (rename || visible db attr) then invalid_arg "This property is read-only"; Ok (Result_view.edit_property ~text_value:true ~allow_builtin:true db ~now ~fresh_uuid input) with Invalid_argument message -> Error message diff --git a/shared/src/logseq/core/node_title.ml b/shared/src/logseq/core/node_title.ml new file mode 100644 index 00000000..c2542882 --- /dev/null +++ b/shared/src/logseq/core/node_title.ml @@ -0,0 +1,74 @@ +module D = Datascript +module G = Graph_read +module U = Dev_query.Unicode + +let get db id attr = G.value db id attr +let text = function Some (D.String value) -> value | _ -> "" +let ident db id = match get db id "db/ident" with Some (D.Keyword value) -> value | _ -> "" +let tags db id = G.ref_eids db id "block/tags" +let tagged db id kind = List.exists (fun tag -> ident db tag = kind) (tags db id) +let is_property db id = tagged db id "logseq.class/Property" +let is_class db id = tagged db id "logseq.class/Tag" +let is_page db id = get db id "block/name" <> None || + List.exists (tagged db id) ["logseq.class/Page"; "logseq.class/Tag"; "logseq.class/Property"; "logseq.class/Journal"] +let builtin db id = get db id "logseq.property/built-in?" = Some (D.Bool true) + || get db id "file/path" <> None + || List.exists (fun prefix -> String.starts_with ~prefix (ident db id)) + ["block/"; "db/"; "logseq.class/"; "logseq.kv/"; "logseq.property/"; "logseq.property."] +let journal db id = get db id "block/journal-day" <> None || tagged db id "logseq.class/Journal" +let can_edit db id = not (builtin db id || journal db id) +let can_rename_page db id = is_page db id && can_edit db id + +let nfc text = + let normalizer = Uunf.create `NFC in + let result = Buffer.create (String.length text) in + let rec add value = match Uunf.add normalizer value with + | `Uchar value -> Buffer.add_utf_8_uchar result value; add `Await + | `Await | `End -> () in + let rec loop index = + if index < String.length text then ( + let value = String.get_utf_8_uchar text index in + add (`Uchar (Uchar.utf_decode_uchar value)); + loop (index + Uchar.utf_decode_length value)) in + loop 0; add `End; Buffer.contents result + +let name title = + let title = U.lowercase title in + let title = if String.starts_with ~prefix:"/" title then String.sub title 1 (String.length title - 1) else title in + let title = if String.ends_with ~suffix:"/" title then String.sub title 0 (String.length title - 1) else title in + nfc title + +let duplicate db id title = + let property = is_property db id in + let exact = property || is_class db id in + let attr, lookup = if exact then "block/title", title else "block/name", name title in + let own_tags = tags db id in + let own_idents = List.map (ident db) own_tags |> List.sort_uniq String.compare in + let parent = G.ref_eids db id "block/parent" in + own_tags <> [] && Seq.exists (fun (datom : D.datom) -> + let other = datom.e in + other <> id + && (not property || get db other "logseq.property/built-in?" = None) + && (property || (if parent <> [] then G.ref_eids db other "block/parent" = parent + else exact || G.ref_eids db other "block/parent" = [])) + && let other_tags = tags db other in + if not (List.exists (fun tag -> List.mem tag own_tags) other_tags) then false + else let other_idents = List.map (ident db) other_tags |> List.sort_uniq String.compare in + let common = List.filter (fun value -> List.mem value other_idents) own_idents in + not (common = ["logseq.class/Page"] && List.length own_idents > 1 && List.length other_idents > 1)) + (D.Db.datoms db D.Avet ~a:attr ~v:(D.String lookup) ()) + +(* Keep these guards at the mutation boundary, matching outliner/validate.cljs. *) +let validate db id title = + if builtin db id then Error "Built-in names cannot be changed" + else if journal db id then Error "Journal pages cannot be renamed" + else if not (is_page db id) then Ok () + else if U.trim title = "" then Error "Page name cannot be blank" + else if String.contains title '#' then Error "Page name cannot include #" + else if String.contains title '/' && not (Dev_query.Date_time_util.valid_journal_title title) then + Error "Page name cannot include /" + else if is_property db id && String.starts_with ~prefix:"[[" title then + Error "Property name cannot start with [[" + else if Dev_query.Date_time_util.valid_journal_title title then Error "A page cannot be renamed to a journal" + else if duplicate db id title then Error "Another page, tag or property with this name already exists" + else Ok () diff --git a/shared/src/logseq/core/node_title.mli b/shared/src/logseq/core/node_title.mli new file mode 100644 index 00000000..3fec6450 --- /dev/null +++ b/shared/src/logseq/core/node_title.mli @@ -0,0 +1,5 @@ +val is_page : Datascript.db -> int -> bool +val can_edit : Datascript.db -> int -> bool +val can_rename_page : Datascript.db -> int -> bool +val name : string -> string +val validate : Datascript.db -> int -> string -> (unit, string) result diff --git a/shared/src/logseq/core/pending_projection.ml b/shared/src/logseq/core/pending_projection.ml index 72b791de..80132229 100644 --- a/shared/src/logseq/core/pending_projection.ml +++ b/shared/src/logseq/core/pending_projection.ml @@ -190,6 +190,9 @@ let title_tx db uuid title = | None -> [] in [ Ds.Add (reference, "block/title", Ds.String title) ] + @ (match Ds.entid db "block/uuid" (Ds.Uuid uuid) with + | Some id when Node_title.is_page db id -> [Ds.Add (reference, "block/name", Ds.String (Node_title.name title))] + | _ -> []) @ retractions @ List.map (fun eid -> Ds.Add (reference, "block/refs", Ds.Ref eid)) @@ -393,7 +396,8 @@ let rec compile db (intent : Ops.pending_intent) = with | Some current -> if current = value.expected_title then - Ok (title_tx db value.uuid value.title) + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid value.uuid)) in + Result.map (fun () -> title_tx db value.uuid value.title) (Node_title.validate db id value.title) else Error "title changed on the server" | None -> Error "block no longer exists") | Ops.Set_property value -> diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 726a5ff1..2b2f755b 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -160,9 +160,9 @@ let columns db view rows = (if ordered = [] then "block/title" :: List.filter (( <> ) "block/title") rest else ordered @ rest) |> List.map (property db) let editable_type p = List.mem p.typ ["default"; "url"; "number"; "checkbox"; "date"; "datetime"; "page"; "entity"; "node"; "class"; "property"] -let editable p = public_attr p.attr - && not (List.mem p.attr ["block/title"; "block/created-at"; "block/updated-at"]) - && editable_type p +let editable p = p.attr = "block/title" || (public_attr p.attr + && not (List.mem p.attr ["block/created-at"; "block/updated-at"]) + && editable_type p) let node_editable db p = p.attr = "block/title" || p.attr = "block/tags" || editable p || (editable_type p && String.starts_with ~prefix:"logseq.property/" p.attr && match attr_eid db p.attr with None -> false | Some id -> get db id "logseq.property/hide?" <> Ds.Bool true && get db id "logseq.property/public?" <> Ds.Bool false) @@ -196,7 +196,7 @@ let row_json db encode properties = function | Query_block.Cells cells -> `Assoc ["cells", `List (List.map (fun cell -> `String cell) cells)] | Query_block.Block block -> let id = eid db block.uuid in `Assoc ["block", encode block; "properties", `List (List.map (fun p -> - `Assoc ["attr", `String p.attr; "kind", `String p.typ; "editable", `Bool (editable p); + `Assoc ["attr", `String p.attr; "kind", `String p.typ; "editable", `Bool (editable p && (p.attr <> "block/title" || Node_title.can_edit db id)); "text", `String (String.concat ", " (List.map (property_display db p) (attr_values db id p.attr))); "editorValue", `String (match attr_values db id p.attr with value :: _ -> temporal_value db p value | [] -> ""); "raw", semantic_json db (raw db id p.attr)]) properties)] @@ -340,8 +340,9 @@ let edit_property ?(text_value = false) ?(allow_builtin = false) db ~now ~fresh_ let expected = member "expected" json in if expected <> semantic_json db (raw db target_id attr) then invalid_arg "This cell changed. Reopen it to edit the latest value"; let text = match member "value" json with `String value -> value | _ -> invalid_arg "A cell value is required" in - if attr = "block/title" then - Ops.Save_title {uuid = target; expected_title = display (get db target_id attr); title = text} + if attr = "block/title" then ( + (match Node_title.validate db target_id text with Ok () -> () | Error message -> invalid_arg message); + Ops.Save_title {uuid = target; expected_title = display (get db target_id attr); title = text}) else let created = ref [] in let value text = @@ -429,7 +430,7 @@ let property_json ?(include_candidates = true) db ~owner ~attr ~encode_block = ("text", `String text) :: ("raw", semantic_json db (raw db id attr)) :: ("textLayout", `Bool text_layout) :: ("blocks", `List blocks) :: ("markup", Markup.to_yojson (Markup.parse [] [] text)) :: - ("filterValues", `List filter_values) :: ("editable", `Bool (node_editable db p)) :: + ("filterValues", `List filter_values) :: ("editable", `Bool (node_editable db p && (p.attr <> "block/title" || Node_title.can_edit db id))) :: List.remove_assoc "editable" (List.remove_assoc "filterValues" metadata)) let mutation db ~now ~fresh_uuid json = try diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index 1094f9a6..f2263d01 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -93,12 +93,9 @@ let query_db session = | None -> Some (state session).model.db let current_summary session (summary : Model.entity_summary) = - match Option.bind (query_db session) (fun db -> - Option.bind (Datascript.entid db "block/uuid" (Datascript.Uuid summary.uuid)) (fun eid -> - Option.bind (Graph_read.int_value (Graph_read.value db eid "block/journal-day")) (fun day -> - if Journal.valid_day day then Some (Journal.title db day) else None))) with - | Some title -> {summary with title} - | None -> summary + Option.value ~default:summary (Option.bind (query_db session) (fun db -> + Option.bind (Datascript.entid db "block/uuid" (Datascript.Uuid summary.uuid)) + (Graph_read.entity_summary (fun value -> Ok value) db))) let summary_json session summary = Rpc.summary_json (current_summary session summary) @@ -824,6 +821,26 @@ let with_object action payload missing handle = (action ^ " payload must be an object"))) | None -> Rpc.failure "invalid_params" missing +let search_page session ~offset ~limit query = + let h = host session in + match h.Types.graph_search_page with + | Some search -> search ~offset ~limit query + | None -> + let all = match h.Types.graph_search with Some search -> search query | None -> [] in + let rec slice skip remaining acc = function + | [] -> List.rev acc, false + | _ when remaining = 0 && skip = 0 -> List.rev acc, true + | _ :: rest when skip > 0 -> slice (skip - 1) remaining acc rest + | hit :: rest -> slice 0 (remaining - 1) (hit :: acc) rest in + slice offset limit [] all + +let refresh_retained_search session = + let current = state session in + if current.search_result_pages <> [] && not (String_kit.is_blank current.search_query) then ( + let found, has_more = search_page session ~offset:0 ~limit:(max 20 current.search_result_count) current.search_query in + session.Types.state := {current with search_result_pages = [found]; + search_result_count = List.length found; search_has_more = has_more}) + let stage_result session operation_id base_t intent = match enqueue_semantic session @@ -834,7 +851,11 @@ let stage_result session operation_id base_t intent = intent; } with - | Ok () -> snapshot_visible session + | Ok () -> + let rec changes_title = function Ops.Save_title _ -> true + | Ops.Atomic intents -> List.exists changes_title intents | _ -> false in + if changes_title intent then refresh_retained_search session; + snapshot_visible session | Error message -> Rpc.failure "stage_operation_failed" message let editing_cursor session = @@ -1877,9 +1898,17 @@ let rec dispatch session action payload = | "nodeProperty" -> (match payload, query_db session with | Some payload, Some db -> - (try match editing_cursor session with + (try + let input = Json.from_string payload in + let requested_graph = match input with `Assoc fields -> List.assoc_opt "graphId" fields | _ -> None in + let same_graph = match requested_graph, (state session).config with + | None, _ -> true + | Some (`String graph), Some config -> graph = config.Api.graph_id + | _ -> false in + if not same_graph then Rpc.failure "stale_graph" "The active graph changed. Reopen the editor" + else match editing_cursor session with | Error (code, message) -> Rpc.failure code message - | Ok cursor -> (match Node_property.mutation db ~now:(now_ms ()) ~fresh_uuid:fresh_squuid (Json.from_string payload) with + | Ok cursor -> (match Node_property.mutation db ~now:(now_ms ()) ~fresh_uuid:fresh_squuid input with | Ok intent -> stage_result session (fresh_squuid ()) cursor intent | Error message -> Rpc.failure "invalid_property" message) with error -> Rpc.failure "invalid_property" (Printexc.to_string error)) @@ -2054,16 +2083,7 @@ let rec dispatch session action payload = let valid = offset = 0 || (offset = s.search_result_count && query = s.search_query) in let found, has_more = if not valid || String_kit.is_blank query then [], false - else match h.Types.graph_search_page with - | Some search -> search ~offset ~limit:20 query - | None -> - let all = match h.Types.graph_search with Some search -> search query | None -> [] in - let rec slice skip remaining acc = function - | [] -> List.rev acc, false - | _ when remaining = 0 && skip = 0 -> List.rev acc, true - | _ :: rest when skip > 0 -> slice (skip - 1) remaining acc rest - | hit :: rest -> slice 0 (remaining - 1) (hit :: acc) rest in - slice offset 20 [] all in + else search_page session ~offset ~limit:20 query in if valid then session.Types.state := { !(session.state) with search_query = query; search_has_more = has_more; search_result_pages = (if offset = 0 then [found] else found :: s.search_result_pages); diff --git a/shared/src/logseq/view_property.ml b/shared/src/logseq/view_property.ml index faa54b32..df236f9b 100644 --- a/shared/src/logseq/view_property.ml +++ b/shared/src/logseq/view_property.ml @@ -5,6 +5,10 @@ let text = function `String value -> value | _ -> "" let list = function `List values -> values | _ -> [] let decode raw = try J.from_string raw with _ -> `Null let has_properties raw = list (field "properties" (decode raw)) <> [] +let rename_page raw = + let source = decode raw in + let owner = text (field "ownerUuid" source) and property = field "rename" source in + if owner <> "" && field "editable" property = `Bool true then Some (owner, property) else None let display_value property = let raw = text (field "text" property) in diff --git a/shared/src/logseq/view_property.mli b/shared/src/logseq/view_property.mli index 0cf65340..1f8d1c35 100644 --- a/shared/src/logseq/view_property.mli +++ b/shared/src/logseq/view_property.mli @@ -1,3 +1,5 @@ val view : ?compact:bool -> render_block:(Model.outline_row Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t) -> Model.logseq_model Signal.signal -> string Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val has_properties : string -> bool + +val rename_page : string -> (string * Yojson.Basic.t) option diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 28efebe8..7bbaf218 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -439,6 +439,10 @@ let property_editor ~id ~column:col ~submit:save model_source property close : t let ref_ = jbool (jfield "ref" col) in let closed = jlist (jfield "choices" col) <> [] || jbool (jfield "closed" col) in let query = state (if temporal then (match jtext (jfield "editorValue" property) with "" -> temporal_default kind | value -> value) + else if attr = "block/title" then (match jfield "raw" property with + | `Assoc fields when List.assoc_opt "type" fields = Some (`String "string") -> + jtext (Option.value ~default:`Null (List.assoc_opt "value" fields)) + | _ -> jtext (jfield "text" property)) else if plain then jtext (jfield "text" property) else "") in let selected = state (raw_selection_values (jfield "raw" property)) in let created = state [] in diff --git a/shared/src/logseq/view_rows.ml b/shared/src/logseq/view_rows.ml index 50c4f9d0..bc094470 100644 --- a/shared/src/logseq/view_rows.ml +++ b/shared/src/logseq/view_rows.ml @@ -229,6 +229,7 @@ let journal_entries rows (current : Model.logseq_model) = let page_entries rows current = let outline = if View_base.journal_home_visible_ current then journal_entries rows current + else if View_base.current_content_is_tag_ current || View_base.current_content_is_property_ current then [] else let outline = outline_entries rows in let parent = match View_base.active_node_projection current with diff --git a/shared/src/logseq/view_screens.ml b/shared/src/logseq/view_screens.ml index 3270a278..f9b75267 100644 --- a/shared/src/logseq/view_screens.ml +++ b/shared/src/logseq/view_screens.ml @@ -530,6 +530,24 @@ let active_overflow_menu model_source send : t = let settings_source = Signal.map View_base.connection_settings_visible_ model_source in + let own source = Signal.own_signal context.ui_scope source in + let rename_source = own (Signal.map (fun current -> + let raw = match View_base.active_node_projection current with + | Some route -> route.Model.node_properties_json + | None -> current.Model.properties_json in + View_property.rename_page raw) model_source) in + let editing = Signal.state context.ui_scheduler None in + let finished = ref None in + ignore (Signal.own context.ui_scope (Signal.observe model_source (fun (current : Model.logseq_model) -> + match Signal.sample (Signal.value editing) with + | Some (graph, owner, _) -> + if current.selected_graph_id <> graph || Option.map fst (Signal.sample rename_source) <> Some owner then + Signal.set editing None + else (match List.find_opt (fun (key, _, _) -> key = "page.rename." ^ owner) current.result_view_feedback with + | Some (_, revision, error) when !finished <> Some revision -> + finished := Some revision; if error = None then Signal.set editing None + | _ -> ()) + | None -> ()))); let kotlin_host = Lui_ui.host context = KotlinHost in (* iOS menu items carry no icons; the Kotlin popup did. *) let item_icon name = if kotlin_host then Some (`app name) else None in @@ -556,6 +574,16 @@ let active_overflow_menu model_source send : t = (Signal.map View_base.active_page_favorite_label model_source) ~on_press:(press send Model.ToggleActivePageFavorite) []); + if_ ~test:(own (Signal.map (fun source -> Option.is_some source) rename_source)) + (menu_item ~text:"Rename page" ~accessibility_identifier:"button.page.rename" + ~on_press:(fun _ -> + let current = Signal.sample model_source in + match Signal.sample rename_source with + | None -> () + | Some (owner, property) -> + finished := Option.map (fun (_, revision, _) -> revision) + (List.find_opt (fun (key, _, _) -> key = "page.rename." ^ owner) current.Model.result_view_feedback); + Signal.set editing (Some (current.selected_graph_id, owner, property))) []); if_ ~test:page_actions_source (menu_item ~text:"Share" ?icon:(item_icon "share") ~on_press:(press send Model.ShareActivePage) @@ -571,14 +599,24 @@ let active_overflow_menu model_source send : t = []); ] in - if kotlin_host then + let menu = if kotlin_host then menu ~icon:(`app "more-vert") ~label:"More" - ~accessibility_identifier:"button.overflow-menu" entries context parent + ~accessibility_identifier:"button.overflow-menu" entries else box ~width:44 ~height:44 [ menu ~icon:(`app "more-horiz") ~style_class:"capsule" ~label:"More" - ~accessibility_identifier:"button.connection" entries ] - context parent + ~accessibility_identifier:"button.connection" entries ] in + (stack [menu; + if_ ~test:(own (Signal.map Option.is_some (Signal.value editing))) + (dyn (function None -> box [] | Some (graph, owner, property) -> + let column = match property with `Assoc fields -> + `Assoc (("title", `String "Rename page") :: List.remove_assoc "title" fields) | value -> value in + View_query.property_editor ~id:("page.rename." ^ owner) ~column + ~submit:(fun fields -> ignore (send (Model.SubmitNodeProperty ("page.rename." ^ owner, + Yojson.Basic.to_string (`Assoc (("blockUuid", `String owner) :: + ("graphId", `String (Option.value ~default:"" graph)) :: fields)))))) + model_source property (fun () -> Signal.set editing None)) (Signal.value editing)); + ]) context parent let main_header_connection model_source send : t = stack diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 817cc1ae..b7a9980c 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -8117,6 +8117,47 @@ let tagged_nodes_replace_the_linked_references_section () = ) ["main"; "node"; "search"] ) [ios_backend; kotlin_backend] +let tag_and_property_pages_hide_blocks_but_keep_objects () = + List.iter (fun backend -> + List.iter (fun destination -> + List.iter (fun kind -> + List.iter (fun populated -> + let application = App.create (backend ()) in + Fun.protect ~finally:(fun () -> ignore (Lui_app.dispose application)) (fun () -> + let rows = if populated then [{(outline_row "hidden-child" "Page child") with Model.page_id = "book"}] else [] in + let views = {|{"ownerUuid":"book","featureType":"tagged","views":[{"uuid":"objects-view","type":"list","name":"All","rows":[{"block":{"uuid":"object-result","title":"Object result","pageId":"elsewhere"}}]}]}|} in + let sidebar = {(empty_sidebar_projection ()) with + Model.selected_page = Some {uuid = "book"; title = "Book"}; + selected_page_is_tag = kind = "tag"; selected_page_is_property = kind = "property"; + result_views_json = views} in + let routes = if destination = "main" then [] else + [{(node_projection "book" "book" "Book" [] []) with + Model.node_is_tag = kind = "tag"; node_is_property = kind = "property"; + node_outliner_rows = rows; node_result_views_json = views}] in + start application; + if destination = "node" then send application (Model.RequestAppNode "book") + else if destination = "search" then begin + send application Model.OpenSearch; + send application (Model.RequestSearchNode "book") + end; + send application (apply_core_snapshot None sidebar [] false "" [] routes + None None [] [] rows false []); + flush application; + let renderer = Lui_app.runtime application in + let root = if destination = "main" then main_root renderer application + else descendant_with_identifier renderer (Lui_app.root_node application) "screen.node" in + check ~msg:"the destination is mounted" (root <> -1); + let find id = descendant_with_identifier renderer root id in + check_eq ~msg:(kind ^ " page child visibility via " ^ destination) + (find "outliner.block.hidden-child" <> -1) (kind = "page" && populated); + check_eq ~msg:(kind ^ " page placeholder visibility via " ^ destination) + (find "button.outliner.placeholder.book" <> -1) (kind = "page"); + check ~msg:"objects remain rendered without the page blocks area" + (find "outliner.block.object-result" <> -1))) [false; true] + ) ["tag"; "property"; "page"] + ) ["main"; "node"; "search"] + ) [ios_backend; kotlin_backend] + let thousand_nested_rows () = List.init 1_000 (fun index -> { (outline_row ("large-" ^ string_of_int index) "Nested block") with @@ -10515,6 +10556,7 @@ let cases = case "search-only-patches-preserve-the-open-outline" search_only_patches_preserve_the_open_outline; case "reference-breadcrumbs-keep-their-navigation-context" reference_breadcrumbs_keep_their_navigation_context; case "tagged-nodes-replace-the-linked-references-section" tagged_nodes_replace_the_linked_references_section; + case "tag-and-property-pages-hide-blocks-but-keep-objects" tag_and_property_pages_hide_blocks_but_keep_objects; case "switching-graphs-resets-the-journal-viewport" switching_graphs_resets_the_journal_viewport; case "large-tag-and-reference-sections-virtualize-each-block" large_tag_and_reference_sections_virtualize_each_block; case "selected-page-waits-for-blocks-before-showing-empty-state" selected_page_waits_for_blocks_before_showing_empty_state; diff --git a/shared/test/logseq/node_property_test.ml b/shared/test/logseq/node_property_test.ml index a155bd02..625515ab 100644 --- a/shared/test/logseq/node_property_test.ml +++ b/shared/test/logseq/node_property_test.ml @@ -248,7 +248,139 @@ let temporal_properties_display_and_edit () = R.with_runtime (fun session runtim check ~msg:"clearing removes the assigned date value" (not (List.exists (fun p -> member "attr" p = `String attr) (properties (result (read session Q.alpha)))))) +let page_rename_guards_follow_master () = + let named n uuid title tags parent = R.entity n uuid title @ + [put uuid "block/name" (Ds.String (Dev_query.Unicode.lowercase title))] @ + List.map (fun ident -> put uuid "block/tags" (Ds.Ref_to (Ds.Ident ident))) tags @ + (match parent with None -> [] | Some parent -> [put uuid "block/parent" (Ds.Ref_to (Q.ref_uuid parent))]) in + let target = "40000000-0000-4000-8000-000000000001" in + let other = "40000000-0000-4000-8000-000000000002" in + let base = Ds.db_with (named 100 target "Original" ["logseq.class/Page"] None) (R.fixture ()) in + let validate message db uuid title accepted = + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid uuid)) in + let expected_title = Option.value ~default:"" (Graph_read.string_value (Graph_read.value db id "block/title")) in + let result = Pending_projection.compile db (Pending_ops.Save_title {uuid; expected_title; title}) in + check_eq ~msg:message (Result.is_ok result) accepted in + List.iter (fun title -> validate ("invalid page title: " ^ title) base target title false) + [""; " \t\n"; " "; "Has #tag"; "Nested/Page"; "2026-10-23"; "10/23/2026"]; + List.iter (fun (source, expected) -> + check_eq ~msg:("page lookup names match JavaScript lowercase and NFC: " ^ source) (Node_title.name source) expected) + ["ΟΣ", "ος"; "ΟΣΑ", "οσα"; "Σ", "σ"; "ΣΣ", "σς"; "AΣ'A", "aσ'a"; "AΣ'", "aς'"; "İ", "i̇"]; + validate "ordinary blocks can still be empty" base Q.alpha "" true; + validate "ordinary block text can contain hashtags and slashes" base Q.alpha "A #tag / text" true; + validate "built-in flag protects title changes" + (Ds.db_with [put target "logseq.property/built-in?" (Ds.Bool true)] base) target "New" false; + validate "internal property identifiers are protected even without a built-in flag" + (Ds.db_with [put target "db/ident" (Ds.Keyword "logseq.property/internal-name")] base) target "New" false; + validate "file entities are protected" + (Ds.db_with [put target "file/path" (Ds.String "logseq/rename-test.edn")] base) target "New" false; + validate "journal title is not a rename target" + (Ds.db_with [put target "block/journal-day" (Ds.Int64 20261023L)] base) target "New" false; + let duplicate = Ds.db_with (named 101 other "Existing" ["logseq.class/Page"] None) base in + validate "ordinary page duplicates are case insensitive" duplicate target "EXISTING" false; + let unicode = Ds.db_with (named 101 other "Café" ["logseq.class/Page"] None) base in + validate "page name uniqueness uses Unicode NFC" unicode target "CAFÉ" false; + let parented = Ds.db_with [put other "block/parent" (Ds.Ref_to (Q.ref_uuid Q.page))] duplicate in + validate "ordinary page names in another parent do not collide" parented target "Existing" true; + let matching_parent = Ds.db_with [put target "block/parent" (Ds.Ref_to (Q.ref_uuid Q.page))] parented in + validate "ordinary page names within the same parent collide" matching_parent target "Existing" false; + let different_tags = Ds.db_with [put target "block/tags" (Ds.Ref_to (Q.ref_uuid Q.project)); + put other "block/tags" (Ds.Ref_to (Ds.Ident "logseq.class/Task"))] duplicate in + validate "Page-only overlap allows differently tagged objects" different_tags target "Existing" true; + validate "a shared additional tag still collides" + (Ds.db_with [put other "block/tags" (Ds.Ref_to (Q.ref_uuid Q.project))] different_tags) target "Existing" false; + let tag = Ds.db_with (named 101 other "Another tag" ["logseq.class/Tag"] None) base in + validate "tag names are exact-title unique" tag other "Project" false; + validate "tag names are case sensitive" tag other "project" true; + validate "property names cannot begin with page reference syntax" base R.summary_property "[[Bad]]" false; + validate "user property names are exact-title unique" base R.summary_property "Score" false; + validate "user property names are case sensitive" base R.summary_property "score" true; + validate "user properties may share a built-in property's name" base R.summary_property "Status" true + +let page_rename_updates_lookup_search_and_references () = + let db = Ds.db_with [put Q.page "block/tags" (Ds.Ref_to (Ds.Ident "logseq.class/Page")); + put Q.alpha "block/title" (Ds.String ("See [[" ^ Q.page ^ "]]")); + put Q.alpha "block/refs" (Ds.Ref_to (Q.ref_uuid Q.page))] (R.fixture ()) in + Graph_runtime_test.with_search_runtime db (fun _ conn runtime -> + let session = Rpc_session.create_session {Rpc_session.default_options with + load_graph_catalog = Some (fun () -> Some Rpc_test.plain_graph_catalog); + graph_db = Some (fun () -> Some (Graph_runtime.db runtime)); + sync_cursor = Some (fun () -> Some (Graph_runtime.state runtime).server_t); + stage_operation = Some (Graph_runtime.stage runtime); prepare_operation = Some (Graph_runtime.prepare_sync runtime); + graph_sidebar_pages = Some (fun () -> Some {Graph_read.favorites = [{uuid = Q.page; title = "Workspace"}]; + recent_pages = [{uuid = Q.page; title = "Workspace"}]}); + graph_node_destination = Some (fun uuid -> Graph_read.node_destination (fun value -> Ok value) (Graph_runtime.db runtime) uuid); + graph_page_blocks = Some (fun uuid -> Some (Graph_runtime.blocks_for_page runtime uuid)); + graph_search_page = Some (Graph_runtime.search_page runtime)} + |> Rpc_test.configure_plain_session in + ignore (result (Rpc_test.dispatch_json session "selectPage" Q.page)); + ignore (result (Rpc_test.dispatch_json session "openNode" (J.to_string (`Assoc ["uuid", `String Q.page])))); + let before = result (Rpc_test.dispatch_json session "searchNodes" "Workspace") in + check ~msg:"the original name is retained in an active search before renaming" + (List.exists (fun hit -> member "uuid" hit = `String Q.page) (items "searchResults" before)); + let rename = member "rename" (member "nodeProperties" (result (read session Q.page))) in + check_eq ~msg:"an ordinary page exposes its shared rename editor capability" (member "attr" rename) (`String "block/title"); + check_eq ~msg:"a rename cannot target a graph that is no longer active" + (member "ok" (edit session Q.page rename "Wrong graph" ["graphId", `String "other-graph"])) (`Bool false); + let after = result (edit session Q.page rename "Café Notes" []) in + check_eq ~msg:"the already selected page title updates in the actual RPC response" + (member "title" (member "selectedPage" after)) (`String "Café Notes"); + check_eq ~msg:"an already pushed native destination updates in place" + (member "title" (member "page" (List.hd (items "nodeRoutes" after)))) (`String "Café Notes"); + List.iter (fun key -> check_eq ~msg:(key ^ " use the current title") + (member "title" (List.hd (items key after))) (`String "Café Notes")) ["favorites"; "recentPages"]; + check ~msg:"the retained old-name search drops renamed pages and references immediately" + (not (List.exists (fun hit -> List.mem (member "uuid" hit) [`String Q.page; `String Q.alpha]) (items "searchResults" after))); + let db = Graph_runtime.db runtime in + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid Q.page)) in + check_eq ~msg:"the page lookup key updates with its title" (Graph_read.value db id "block/name") (Some (Ds.String "café notes")); + let hits, _ = Graph_runtime.search_page runtime ~offset:0 ~limit:20 "Café" in + check ~msg:"renamed pages become searchable immediately" + (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = Q.page && hit.title = "Café Notes") hits); + check ~msg:"references are reindexed with the renamed display title" + (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = Q.alpha && String_kit.includes ~sub:"Café Notes" hit.title) hits); + check_eq ~msg:"stale rename submissions never overwrite a newer title" (member "ok" (edit session Q.page rename "Stale" [])) (`Bool false); + List.iter (fun operation -> check_ok ~msg:"page rename compiles for sync" + (Graph_runtime.prepare_sync runtime operation)) (Graph_runtime.pending_operations runtime); + let protected = Ds.db_with [put Q.page "logseq.property/built-in?" (Ds.Bool true)] (Graph_runtime.db runtime) in + let protected_session, _ = R.make_session protected in + check_eq ~msg:"built-in pages do not expose Rename page" (member "rename" (member "nodeProperties" (result (read protected_session Q.page)))) `Null) + +let native_page_rename_uses_shared_editor () = + R.with_runtime (fun session _ _ -> + let node_properties = member "nodeProperties" (result (read session Q.page)) in + List.iter (fun backend -> + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "plain-1"; + "selectedPage", `Assoc ["uuid", `String Q.page; "title", `String "Workspace"]; + "nodeProperties", node_properties; "outlinerRows", `List []]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + let click id = let node = find id in check ~msg:id (node <> -1); + App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in + click "button.page.rename"; + let id = "page.rename." ^ Q.page in + let input = find ("view.edit.value." ^ id) in + check ~msg:"Rename page reuses the shared property text editor" (input <> -1); + check_eq (App_test.property_string host input Lui_protocol.TextValue) "Workspace"; + check_eq ~msg:"Rename page has no Clear value action" (find ("view.edit.clear." ^ id)) (-1); + App_test.dispatch app (Lui_protocol.TextChanged (input, "Renamed page")); App_test.flush app; + click ("view.edit.save." ^ id); + let payload = List.find_map (function Model.NodePropertyEffect (_, key, body) when key = id -> Some (J.from_string body) | _ -> None) + (App.model app).pending_effects in + check ~msg:"Rename page sends one existing node-property mutation" (payload <> None); + let payload = Option.get payload in + check_eq (member "blockUuid" payload) (`String Q.page); + check_eq (member "graphId" payload) (`String "plain-1"); + check_eq (member "attr" payload) (`String "block/title"); + check_eq (member "value" payload) (`String "Renamed page"))) + [App_test.ios_backend; App_test.kotlin_backend]) + let cases = [ + case "Rename page uses the protected shared native editor" native_page_rename_uses_shared_editor; + case "page rename guards match master names, built-ins and duplicate scopes" page_rename_guards_follow_master; + case "page rename updates lookup, live search, references and sync" page_rename_updates_lookup_search_and_references; case "dates and datetimes display, edit and clear without epoch labels" temporal_properties_display_and_edit; case "properties follow node navigation without expanding ordinary blocks" projection_and_navigation; case "inherited empty properties, hidden metadata and closed values" inherited_empty_and_closed_values; diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index 836bdd05..1fd80d80 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -649,7 +649,11 @@ let native_direct_value_selection_and_creation () = App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in click ("view.tab." ^ table_view); click ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/title"); - check_eq ~msg:"Name is read-only in a table" (find ("view.edit.sheet." ^ table_view)) (-1); + check ~msg:"Name opens the shared text editor" (find ("view.edit.value." ^ table_view) <> -1); + check_eq ~msg:"Name editing preserves source markup instead of saving its display label" + (App_test.property_string host (find ("view.edit.value." ^ table_view)) Lui_protocol.TextValue) "Alpha **result**"; + check_eq ~msg:"Name has no Clear value action" (find ("view.edit.clear." ^ table_view)) (-1); + click ("view.edit.cancel." ^ table_view); click ("view.cell." ^ table_view ^ "." ^ alpha ^ "." ^ score); check ~msg:"other properties retain Clear value" (find ("view.edit.clear." ^ table_view) <> -1); check ~msg:"a cell immediately opens its searchable choices" @@ -935,11 +939,22 @@ let compact_local_timestamps_and_columns () = check ~msg:"updated timestamps remain available for master-compatible filters and sorting" (List.exists (fun p -> member "attr" p = `String "block/updated-at") (items "properties" view)) -let table_identity_and_timestamps_are_read_only () = with_runtime (fun session runtime conn -> +let table_name_is_editable_and_timestamps_are_read_only () = with_runtime (fun session runtime conn -> ignore (Ds.transact_conn conn [put alpha "block/created-at" (Ds.Int64 1792632600000L); put alpha "block/updated-at" (Ds.Int64 1792633600000L)]); Graph_runtime.rebase runtime (Graph_runtime.state runtime).server_t [] [alpha]; let view = find_view table_view (read session) in + let name = List.find (fun column -> member "attr" column = `String "block/title") (items "properties" view) in + check_eq ~msg:"Name is editable in a table" (member "editable" name) (`Bool true); + let expected = member "raw" (Result_view.property_json (Graph_runtime.db runtime) + ~owner:alpha ~attr:"block/title" ~encode_block:(fun _ -> `Null)) in + let rename = base_action "editCell" table_view @ ["blockUuid", `String alpha; + "attr", `String "block/title"; "expected", expected; "value", `String "Renamed project"] in + check_eq ~msg:"table Name edits save through the graph mutation boundary" (member "ok" (action session rename)) (`Bool true); + let id = Option.get (Ds.entid (Graph_runtime.db runtime) "block/uuid" (Ds.Uuid alpha)) in + check_eq (Graph_read.value (Graph_runtime.db runtime) id "block/title") (Some (Ds.String "Renamed project")); + check_eq ~msg:"a stale Name edit must not overwrite a newer title" (member "ok" (action session rename)) (`Bool false); + check_eq (Graph_read.value (Graph_runtime.db runtime) id "block/title") (Some (Ds.String "Renamed project")); List.iter (fun attr -> let metadata = List.find (fun column -> member "attr" column = `String attr) (items "properties" view) in check_eq ~msg:(attr ^ " cannot be edited in a table") (member "editable" metadata) (`Bool false); @@ -952,7 +967,7 @@ let table_identity_and_timestamps_are_read_only () = with_runtime (fun session r check_eq ~msg:"RPC rejects read-only table edits" (member "ok" result) (`Bool false); check_eq ~msg:"rejected edits leave the graph unchanged" (Graph_read.value (Graph_runtime.db runtime) id attr) before) - ["block/title"; "block/created-at"; "block/updated-at"]) + ["block/created-at"; "block/updated-at"]) let table_tags_are_editable_and_keep_typed_references () = with_runtime (fun session runtime _ -> let view = find_view table_view (read session) in @@ -987,14 +1002,16 @@ let table_cells_have_column_dividers_on_both_hosts () = ["header"; alpha; beta; gamma]; App_test.dispatch app (Lui_protocol.Press (find ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/title"))); App_test.flush app; - check_eq ~msg:"read-only Name never opens a value editor" (find ("view.edit.sheet." ^ table_view)) (-1); + check ~msg:"Name opens its text editor beside the column dividers" (find ("view.edit.sheet." ^ table_view) <> -1); + App_test.dispatch app (Lui_protocol.Press (find ("view.edit.cancel." ^ table_view))); + App_test.flush app; App_test.dispatch app (Lui_protocol.Press (find ("view.cell." ^ table_view ^ "." ^ alpha ^ ".block/tags"))); App_test.flush app; check ~msg:"Tags opens the shared native selector" (find ("view.edit.sheet." ^ table_view) <> -1)))) [App_test.ios_backend; App_test.kotlin_backend] let cases = [ - case "table identity and timestamps remain read-only at the RPC boundary" table_identity_and_timestamps_are_read_only; + case "table Name saves with stale-edit protection while timestamps stay read-only" table_name_is_editable_and_timestamps_are_read_only; case "table tags edit typed references and create tag objects" table_tags_are_editable_and_keep_typed_references; case "table cells have column dividers and typed editors on both hosts" table_cells_have_column_dividers_on_both_hosts; case "compact local timestamps and mobile table metadata order" compact_local_timestamps_and_columns; From e7b7157bdc36ac777028bc038d243416140ec4bb Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 04:39:37 +0800 Subject: [PATCH 18/25] Record native rename checks, device install and crash correlation --- .../UI/navigation-restoration-stability.swift | 120 ++++++++++++++++++ .../016-mobile-interaction-regressions.md | 42 +++++- 2 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apple/Tests/UI/navigation-restoration-stability.swift diff --git a/apple/Tests/UI/navigation-restoration-stability.swift b/apple/Tests/UI/navigation-restoration-stability.swift new file mode 100644 index 00000000..d425e976 --- /dev/null +++ b/apple/Tests/UI/navigation-restoration-stability.swift @@ -0,0 +1,120 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@main struct NavigationRestorationStabilityCheck { + @MainActor static func main() async throws { + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func controllers(_ controller: UIViewController) -> [UIViewController] { + [controller] + controller.children.flatMap(controllers) + } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<150 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw NSError(domain: "NavigationRestoration", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + for search in [false, true] { + for restoreBeforeMount in [true, false] { + var routes: [String] = [] + var rowCount = 96 + func rows(_ page: String) -> [[String: Any]] { + (0.. String { + let result: [String: Any] = [ + "graphName": "Restoration", "selectedGraphId": "restoration", "isGraphUnlocked": true, + "outlinerRows": rows("journal"), "searchQuery": "Restored", + "searchResults": [["uuid": "Search target", "title": "Restored result", "isPage": true]], + "nodeRoutes": routes.map { uuid -> [String: Any] in + ["uuid": uuid, "page": ["uuid": uuid, "title": uuid], "outlinerRows": rows(uuid)] + } + ] + return String(decoding: try! JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in + if let data = request.params.payload?.data(using: .utf8), + let effect = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + switch effect["kind"] as? String { + case "open-node", "open-search-node", "open-search-result": + try? await Task.sleep(for: .milliseconds(30)) + routes.append(effect["text"] as! String) + case "close-node": _ = routes.popLast() + default: break + } + } + return snapshot() + })) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + func restore() throws { + let session: [String: Any] = [ + "graphId": "restoration", "destination": "journals", "assets": [], "composerExpanded": false, + "searchOpen": search, "query": search ? "Restored" : "", + "appPath": search ? [] : ["First", "Second"], "searchPath": search ? ["Search target"] : [], + "selectedPageId": NSNull(), "settingsOpen": false, "recentGraphIds": [] + ] + try runtime.applyHostUpdate(kind: "restore-ui-session", payload: String(decoding: + try JSONSerialization.data(withJSONObject: session), as: UTF8.self)) + } + if restoreBeforeMount { try restore() } + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + if !restoreBeforeMount { + try await Task.sleep(for: .milliseconds(80)) + try restore() + } + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + let expectedDepth = search ? 2 : 3 + var retainedNavigation: UINavigationController? + func navigation() -> UINavigationController? { + if let retainedNavigation { return retainedNavigation } + let root = search ? window.rootViewController?.presentedViewController : window.rootViewController + return root.flatMap { controllers($0).compactMap { $0 as? UINavigationController } + .first { $0.viewControllers.count == expectedDepth } } + } + do { + try await wait("Restored \(search ? "search" : "app") routes must reach their native depth") { + navigation()?.viewControllers.count == expectedDepth && navigation()?.transitionCoordinator == nil + } + } catch { + print("RESTORE_STATE routes=\(routes) native=\(navigation()?.viewControllers.count ?? -1) error=\(runtime.lastError ?? "none")") + throw error + } + retainedNavigation = navigation() + let target = search ? "Search target" : "Second" + try await wait("The restored top route must contain real lazy rows") { + views(navigation()!.topViewController!.view).contains { + $0.accessibilityIdentifier == "outliner.block-action." + target + "-child-0" + } + } + let retained = navigation()!.topViewController! + for count in [250, 1, 96, 250, 96] { + rowCount = count + try runtime.applyCoreResponse(snapshot()) + try await Task.sleep(for: .milliseconds(90)) + guard navigation()?.topViewController === retained else { + throw NSError(domain: "NavigationRestoration", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Hydrating a restored route replaces its native controller"]) + } + } + _ = navigation()!.popToRootViewController(animated: true) + try await wait("Native back must acknowledge every restored route") { + navigation()?.viewControllers.count == 1 && navigation()?.transitionCoordinator == nil && routes.isEmpty + } + guard runtime.lastError == nil else { throw NSError(domain: "NavigationRestoration", code: 3, + userInfo: [NSLocalizedDescriptionKey: runtime.lastError!]) } + print("PASS restoration search=\(search) beforeMount=\(restoreBeforeMount)") + } + } + print("PASS: cold and late app/search restoration, lazy-row hydration and native multilevel back") + } +} diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 3d44ce30..7aed58ac 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -38,8 +38,8 @@ NOTE: I will write *all* tests before I add any implementation behavior. | 5 | Nested/search long press | The destination-toolbar fix from `bb4575f` passes native direct/nested/search/Recent-root/linked-from-Recent checks through the effect boundary. Actual 800ms touch holds also show the native selection bar on Journal, nested destinations, Recent root, a destination from Recent and a search result; Copy clears it. | | 6 | Master-compatible clipboard | Thirteen graph-backed round-trip cases cover Markdown/EDN snapshots, collapsed descendants, fresh identities, typed properties/ref remapping, nonadjacent selection, empty/nonempty/parent placement, UTF-16 ranges, paragraphs, alternate list markers, fenced code/math, foreign graphs and atomic failure. The master audit additionally reproduced and fixed nested links when replacing selected text inside an existing formatted link. Native clipboard/selection/rapid-typing/failed-save checks and rebuilt packages pass on both hosts. | | 7 | Date-format preference | Implemented the master graph property, shared 27-format lexer/formatter, all journal creation paths, live journal/reference/property/navigation labels and graph-scoped native settings menus. Twelve graph/shared-host cases and the full 1,036-test gate pass. Coverage includes storage failure, restart/sync, graph changes, all formats, invalid legacy days, UUID date commands and search-index refresh. Native iOS theme/alignment/confirmed-choice checks and all 19 Android query/editor/property/outliner/settings checks pass. | -| 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name/timestamps are read-only; Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | -| 9 | TestFlight crash | The mobile URL redirects to the public App Store; browser login is required and its form is open. Xcode Organizer's existing account successfully refreshed recent crash clusters and downloaded logs, but no verified mapping to the supplied feedback ID is available. The configured API key returns 404 even for this app resource, so it cannot read the requested report. Specific crash/build/root cause remain unproven pending authenticated access to that report. | +| 8 | Table borders and editability | Thirty core cases, the complete iOS native query/view flow and all sixteen Android query/editor/property/outliner checks pass. Name is now editable with protected-name validation; timestamps remain read-only and Tags use the shared typed editor. The Android stretch root cause is fixed upstream with a verified Paparazzi geometry snapshot. | +| 9 | TestFlight crash | The exact report is now correlated through Xcode Organizer's downloaded feedback metadata to crash point `BaQJ15uF9O9nqkuDQ1AILr`, build 2.0.2 (18), revision `ce301717`. Browser login is no longer needed. The main-thread AttributeGraph/LazySubviewPrefetcher failure stack includes native navigation updates, 1.1534 seconds after launch, on iOS 27.0.1. Root cause and a causal fix remain under investigation; the available simulator runs iOS 26.0. | | 10 | Delivery | The interaction fixes pass the full 1,038-test core gate, native iOS checks and all 28 Android cases. Commit `04c10b4` was installed non-destructively as build `1792698528` and process `23494` was verified alive. PR 69 remains open. Delivery must be repeated after the specified TestFlight crash is resolved. | ## Editing and navigation @@ -142,7 +142,7 @@ Date-format implementation notes from the master audit: ## Question No product clarification blocks the interaction fixes. -The requested TestFlight report still needs authenticated access. Both the in-app browser and Chrome lack a valid web session; the user has been asked to complete Apple login while independent build/install work continues. Xcode can fetch current crash clusters, including AttributeGraph failures, but the supplied feedback ID has not been correlated to a cluster. The configured API key also cannot see app `1601013908` (`GET /v1/apps/1601013908` returns 404), so its report-specific 404 must not be interpreted as evidence that the report does not exist. Do not infer this report's root cause from an uncorrelated cluster or older local logs. +The exact TestFlight report was subsequently found in Xcode Organizer's downloaded feedback cache without accessing credentials. Feedback `APr-g7HZYfbevY4eiVzs--k` is explicitly associated with crash point `BaQJ15uF9O9nqkuDQ1AILr`. Its binary UUID `A65B791F-2BEC-3FCE-8616-424B118A4E6C` matches the archived build 18 executable (revision `ce301717`, LUI `e91ee491`). The report describes a background crash notification; the log identifies role NonUI and a main-thread EXC_BAD_ACCESS in AttributeGraph dependency allocation, through LazySubviewPrefetcher and NavigationStackCoordinator, 1.1534 seconds after launch. The device runs iOS 27.0.1. Sanitized correlation evidence is saved in `/tmp/logseq-APr-correlation.json`. The new cold/late app/search restoration check passes on the available iOS 26.0 simulator (`/tmp/logseq-restoration-current.log`), but it has not reproduced this crash or established that the existing navigation repair fixes it. Browser login is no longer required. Do not claim a causal crash fix without further evidence. --- @@ -151,3 +151,39 @@ The requested TestFlight report still needs authenticated access. Both the in-ap At the user's next "install first" request, build `1792695320` from mobile commit `89b151a` was built, signed, installed and launched at 02:56 on the paired iPhone. Process `23154` was verified alive. The install preserves the data container and includes the structured multi-block clipboard changes. Date-format work is subsequent and was not included. Evidence: `/tmp/logseq-latest-device-build.log`, `/tmp/logseq-latest-device-install.json`, `/tmp/logseq-latest-device-launch.json` and `/tmp/logseq-latest-device-processes.json`. At 03:50, build `1792698528` (`LogseqRevision=04c10b4f`) was installed and launched on the same iPhone without uninstalling or resetting its container. Process `23494` was confirmed in the device process list. This build includes date-format settings, the direct core navigation path, larger query chevrons and final clipboard link-selection repair. Evidence: `/tmp/logseq-interaction-audit-device-final-build.log`, `/tmp/logseq-interaction-audit-device-install.json`, `/tmp/logseq-interaction-audit-device-launch.json` and `/tmp/logseq-interaction-audit-device-processes.json`. The specified TestFlight report remains an outstanding requirement; this install does not constitute completion of its crash investigation. + + +## Query disclosure and hidden date-format follow-up + +The user's collapsed query screenshot exposed an unregistered iOS `app:chevron-right` name, which rendered the native missing-icon placeholder. Both query renderers now use the existing `disclosure-right`/`disclosure-down` vocabulary; iOS resolves these to the exact `arrowtriangle.right.fill`/`arrowtriangle.down.fill` symbols used by blocks. Android resolves them to the same arrows as its block disclosure. Shared tests fail before the change (`/tmp/logseq-query-disclosure-core-red.log`) and all 51 query/result-view cases pass afterward (`/tmp/logseq-query-disclosure-core-green.log`). The extended iOS check verifies both actual native icon resolutions, captures both states and passes the complete native interaction flow (`/tmp/logseq-query-disclosure-ios-green.log`). Both screenshots were inspected. All five Android query-view checks also pass (`/tmp/logseq-query-disclosure-android-green.log`). + +The user subsequently requested that Date format not appear anywhere in settings. Both native settings entries and their now-unused shared UI builder are removed. Reading the graph's existing format and its underlying data behavior remain supported. Updated shared and iOS native checks first fail on the visible control (`/tmp/logseq-hide-date-format-core-red.log`, `/tmp/logseq-hide-date-format-ios-red.log`). This request supersedes the earlier date-format menu UI requirement and historical menu verification above. + + +## Object pages and editable table Name follow-up + +The user requested that tag/property pages hide their blocks area, including the trailing placeholder. `View_rows.page_entries` now omits only the page outline/placeholder for these two page types and retains object views and references. A shared UI regression covers both native hosts, sidebar roots, linked destinations and search destinations, empty/populated pages and ordinary-page positive controls. It fails before the fix (`/tmp/logseq-object-pages-core-red.log`) and passes afterward (`/tmp/logseq-object-pages-core-green.log`); the iOS native check independently reproduces the old behavior (`/tmp/logseq-object-pages-ios-red.log`). + +The user's latest table requirement supersedes the earlier read-only Name rule: Name is editable; Created/Updated timestamps remain read-only. Name uses the existing shared text editor and `Save_title` mutation, keeps its original markup/reference source in the editing field and never offers Clear value. RPC checks cover successful saving, stale-edit rejection and timestamp rejection. The initial metadata regression fails in `/tmp/logseq-name-edit-core-red.log`. Native fixture metadata and interaction checks now follow this updated contract. + + +## Protected page rename + +The requested rename behavior is audited against master `05c3ae73e08498e9e20f1b11c3eac5525e5e588d`, specifically `deps/outliner/src/logseq/outliner/validate.cljs`, `core.cljs`, `deps/common/src/logseq/common/util.cljs` and DB property validation. A common `Node_title` policy protects built-in flags, internal identifiers and file entities, excludes journal renaming, rejects blank page names, hashtags, invalid slashes and page-to-journal conversion, and matches master duplicate scopes and case rules for ordinary pages, tags and properties. `Uunf` is pinned to its Dune Unicode 17 release to match master's NFC lookup keys rather than introducing a partial normalizer. + +`Save_title` validates at the pending-operation compile boundary and updates `block/name` for pages. Table Name mutations validate through the same policy; protected rows expose a read-only Name. The page properties projection carries a separate rename capability, allowing ordinary page menus to reuse the existing property editor without exposing a Name row in the properties panel. Rename captures the owner and graph, retains failed edits for retry, closes on graph changes and uses the graph-scoped existing node-property mutation. It never offers Clear value. Existing search invalidation already refreshes a renamed entity and its referring entities; the new graph test verifies this behavior for a warmed index and checks sync compilation. + +The new menu, blank-name protection and rename projection fail before implementation in `/tmp/logseq-rename-policy-core-red.log`. The graph-isolation regression independently fails in `/tmp/logseq-rename-scope-red.log`. All twelve node-property cases pass `/tmp/logseq-rename-policy-core-all.log`, including the added native-host menu and graph/search checks. An initial file-protection fixture reused the bootstrap config path and hit its unique constraint; it was corrected to a distinct file path before assessing the policy. No production behavior was changed to accommodate that fixture error. + + +The final name audit also found that the shared lowercase helper lacked JavaScript's context-sensitive Final_Sigma mapping. `/tmp/logseq-rename-unicode-red.log` reproduces the mismatch, and the expected Greek/apostrophe/Turkish-I cases are verified directly against JavaScript `toLowerCase().normalize("NFC")`. The shared helper now uses Unicode Cased/Case_Ignorable context in linear time. The full core gate passes all 1042 tests in `/tmp/logseq-rename-unicode-core-final.log` (66.242s). + +A real RPC follow-up then exposed retained summaries and search pages staying stale even though the index had updated. `/tmp/logseq-rename-retained-red.log` fails on the already selected page's title. Snapshot summaries now resolve the current entity title, and successful title mutations refresh an existing search while retaining its loaded result limit. `/tmp/logseq-rename-retained-green.log` passes selected-page and pushed-route titles, favorites/Recent, old-query membership removal, new-query page/reference matches, stale/graph rejection and sync compilation. + +Native iOS rename checks pass `/tmp/logseq-page-rename-ios.log`, covering the shared sheet, failed storage/retry, title replacement, protected-menu removal and graph-change dismissal. The editor/failure screenshots in `/tmp/logseq-page-rename-ios-artifacts` were visually inspected. An initial harness assertion incorrectly expected `runtime.lastError == nil` after an intentionally failed save; it now expects the injected storage error while checking that editing remains available. All nine Android query/object-page, hidden-date-format and actual overflow-menu rename checks pass `/tmp/logseq-page-rename-android-checks.log` (56.563s). The complete native iOS query flow also passes `/tmp/logseq-rename-query-ios-final.log`, including editable Name without Clear value and both resolved disclosure triangles. + + +The final retained-state fix passes the complete core gate again: `/tmp/logseq-rename-retained-core-final.log`, 1042 tests in 67.575s. The new header/menu also passes cold and late app/search restoration and lazy hydration (`/tmp/logseq-restoration-rename.log`). The final Android app and test APK rebuild succeeds (`/tmp/logseq-rename-final-android-build.log`); the nine targeted native checks above exercise the same final UI behavior. The specified TestFlight crash remains a separate unresolved requirement, with exact report correlation established but no causal crash reproduction or verified fix. + + +At 04:39, iPhone build `1792701491` (2.0.2, `LogseqRevision=e5c39f73`) was installed over the existing application and launched without resetting or uninstalling its data. Process `23768` was verified alive. This includes the block disclosure icons, hidden Date format controls, object-page blocks/placeholder suppression, editable table Name, protected page rename and retained title/search refresh. Evidence: `/tmp/logseq-page-rename-device-build.log`, `/tmp/logseq-page-rename-device-install.json`, `/tmp/logseq-page-rename-device-launch.json` and `/tmp/logseq-page-rename-device-processes.json`. The final rebuilt Android app/test APKs also pass all nine targeted checks (`/tmp/logseq-rename-final-android-checks.log`, 47.51s). Mobile PR 69 is updated and remains open; the TestFlight crash investigation is not marked complete. From aa71b9c2fa0b2f547ac268dcaa3fc7740e8d0f42 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 04:56:09 +0800 Subject: [PATCH 19/25] Record isolated navigation crash experiments with ordered diagnostics --- apple/Tests/UI/run-simulator-runtime-check.py | 4 +--- apple/Tests/UI/search-result-navigation.swift | 19 ++++++++++++++++++- .../016-mobile-interaction-regressions.md | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apple/Tests/UI/run-simulator-runtime-check.py b/apple/Tests/UI/run-simulator-runtime-check.py index 4738b05f..a934b416 100644 --- a/apple/Tests/UI/run-simulator-runtime-check.py +++ b/apple/Tests/UI/run-simulator-runtime-check.py @@ -128,11 +128,9 @@ def main(): return try: output = subprocess.run(["xcrun", "simctl", "launch", "--console", "--terminate-running-process", - SIMULATOR, bundle], capture_output=True, text=True, timeout=180 if testing else (90 if benchmark else 30), env=launch_environment) + SIMULATOR, bundle], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180 if testing else (90 if benchmark else 30), env=launch_environment) print(output.stdout) - print(output.stderr) if "RUNTIME_CHECK_COMPLETE" not in output.stdout: - print(output.stderr, file=sys.stderr) raise SystemExit("Simulator runtime check did not complete") finally: subprocess.run(["xcrun", "simctl", "terminate", SIMULATOR, bundle], capture_output=True) diff --git a/apple/Tests/UI/search-result-navigation.swift b/apple/Tests/UI/search-result-navigation.swift index 12246aec..ec74f702 100644 --- a/apple/Tests/UI/search-result-navigation.swift +++ b/apple/Tests/UI/search-result-navigation.swift @@ -5,6 +5,11 @@ import UIKit @main struct SearchResultNavigationCheck { @MainActor static func main() async throws { + func phase(_ name: String) { + let line = "SEARCH_PHASE \(ProcessInfo.processInfo.systemUptime) \(name)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + phase("start") var routes: [String] = [] var query = "" let hits: [[String: Any]] = (0..<80).map { index in @@ -78,7 +83,13 @@ import UIKit let window = UIWindow(windowScene: scene) window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) window.makeKeyAndVisible() - defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + defer { + phase("teardown.begin") + window.isHidden = true + window.rootViewController = nil + runtime.stop() + phase("teardown.end") + } try await Task.sleep(for: .milliseconds(250)) try backend.performPress(node: find("button.search")!) try await wait("Native search must accept input") { @@ -86,6 +97,7 @@ import UIKit } let searchField = views(window).compactMap { $0 as? UISearchTextField } .first { $0.isFirstResponder }! + phase("search.presented") searchField.insertText("Search") try await wait("Search must return page and block results") { query == "Search" && find("search.result.result-0") != nil && find("search.result.result-40") != nil @@ -102,8 +114,10 @@ import UIKit } try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(name)) } + phase("search.results") for cycle in 0..<12 { let uuid = "result-\(cycle.isMultiple(of: 2) ? 0 : 40)" + phase("push.request.\(cycle)") try backend.performPress(node: find("search.result.\(uuid)")!) try await wait("Result must push a visible destination") { navigation.viewControllers.count == 2 && navigation.transitionCoordinator == nil @@ -116,13 +130,16 @@ import UIKit find("outliner.block.\(target)") != nil else { throw failure("Opening a result must retain the search session and its loaded content") } + phase("push.ready.\(cycle)") window.layoutIfNeeded() if cycle == 11 { try screenshot("search-result-destination.png") } + phase("pop.request.\(cycle)") _ = navigation.popViewController(animated: true) try await wait("Back must restore the result list and settle the native route") { navigation.viewControllers.count == 1 && navigation.transitionCoordinator == nil && routes.isEmpty } try await wait("Back must reactivate search input") { searchField.isFirstResponder } + phase("pop.ready.\(cycle)") if cycle == 11 { try screenshot("search-result-navigation.png") } diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 7aed58ac..6c9db211 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -187,3 +187,18 @@ The final retained-state fix passes the complete core gate again: `/tmp/logseq-r At 04:39, iPhone build `1792701491` (2.0.2, `LogseqRevision=e5c39f73`) was installed over the existing application and launched without resetting or uninstalling its data. Process `23768` was verified alive. This includes the block disclosure icons, hidden Date format controls, object-page blocks/placeholder suppression, editable table Name, protected page rename and retained title/search refresh. Evidence: `/tmp/logseq-page-rename-device-build.log`, `/tmp/logseq-page-rename-device-install.json`, `/tmp/logseq-page-rename-device-launch.json` and `/tmp/logseq-page-rename-device-processes.json`. The final rebuilt Android app/test APKs also pass all nine targeted checks (`/tmp/logseq-rename-final-android-checks.log`, 47.51s). Mobile PR 69 is updated and remains open; the TestFlight crash investigation is not marked complete. + +### Isolated TestFlight crash experiments + +The simulator runtime runner previously printed captured stdout before captured stderr. That ordering cannot establish whether an AttributeGraph warning happened during navigation or teardown. The runner now merges the streams, and the search regression writes monotonic phase markers to stderr before/after each push, pop and teardown. With that instrumentation, the current implementation passes twelve page/block search-result push/back cycles without cycle warnings (`/tmp/logseq-search-cycle-baseline.log`). This does not establish that earlier cycle diagnostics were harmless or that the TestFlight crash is fixed. + +Two controlled source substitutions isolate the build 18 candidates while retaining the current core and the rest of the UI. Both experiments restore the exact original source bytes and rebuild the current simulator in a `finally` block; recorded original/restored SHA-256 values match. + +- Exact build 18 `LGNavigationExtension.swift`: search and cold/late restoration checks pass, but the repeated-navigation check fails with native controller counts `[1, 1]`, confirming lost pushes. No AttributeGraph cycle or EXC_BAD_ACCESS is reproduced. Logs and restoration hashes: `/tmp/logseq-navigation-crash-ab-{old-search,old-restore,old-push}.log` and `/tmp/logseq-navigation-crash-ab-state.json`. +- Exact build 18 `LUIVirtualListView`, substituted into the current backend: all three checks pass with no cycle warning or crash. Logs and restoration hashes: `/tmp/logseq-virtual-prefetch-ab-{old-search,old-restore,old-push}.log` and `/tmp/logseq-virtual-prefetch-ab-state.json`. This experiment does not test the complete build 18 combination on iOS 27.0.1. + +A separately signed `com.logseq.logseq.RuntimeCheck` app runs mock snapshots in its own iPhone container; it does not replace the installed Logseq app, access its graph container or use its app group. The current iOS 27.2 device run does not complete: the application changes from active to inactive after entering search, and the first native back does not finish the test. The initial run reports failure to reactivate the retained search field. Subsequent diagnostic runs remain incomplete; a SIGKILL during one debugger attempt has no corresponding retrieved RuntimeCheck crash report and is not evidence of the TestFlight SIGSEGV or of a watchdog failure. An LLDB attach observes the main thread in `mach_msg2_trap`, not the reported AttributeGraph allocation stack. Logs: `/tmp/logseq-device-runtime-check-current{,-2,-3}.log`. Foreground availability/test lifecycle must be established before interpreting these native assertions. The exact crash root cause remains unresolved. + +The test container's diagnostic log confirms `UIApplicationWillResignActiveNotification` immediately after the search keyboard appears (`/tmp/logseq-device-runtime-check-interaction.log`), without establishing why it resigned. After copying this log, the independently installed RuntimeCheck app was removed (`/tmp/logseq-device-runtime-check-cleanup.json`); the regular Logseq installation/container were not modified. Both experimental source replacements are fully restored, and their final simulator rebuilds succeeded. + +Final checks against the restored current simulator pass twelve search-result push/back cycles and all four cold/late app/search restoration cases, with no cycle warning: `/tmp/logseq-crash-audit-current-search.log` and `/tmp/logseq-crash-audit-current-restoration.log`. The runner also passes Python compilation and `git diff --check`. This audit changes test diagnostics/documentation only; the preceding 1042-test core gate and installed application code are unchanged. From 63b10d4da38b5c326ec2af2b3095ab696e42aad5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:27:34 +0800 Subject: [PATCH 20/25] Fix retained editor focus and protect built-in page actions --- .../com/logseq/app/AndroidPageRenameTest.kt | 3 +- .../com/logseq/app/ui/extensions/NodeIcon.kt | 9 +- apple/Package.resolved | 2 +- apple/Package.swift | 2 +- apple/Sources/Logseq/NodeIcon.swift | 12 +- .../UI/navigation-transition-hydration.swift | 111 ++++++++++++++++++ apple/Tests/UI/outliner-editor-focus.swift | 59 ++++++++++ .../UI/outliner-search-editor-focus.swift | 110 +++++++++++++++++ apple/Tests/UI/page-rename.swift | 9 +- apple/Tests/UI/query-block-results.swift | 28 ++++- .../016-mobile-interaction-regressions.md | 32 +++++ logseq.opam | 2 +- shared/src/logseq/core/node_property.ml | 2 +- shared/src/logseq/core/node_title.ml | 1 + shared/src/logseq/core/node_title.mli | 1 + shared/src/logseq/core/pending_projection.ml | 5 +- shared/src/logseq/model.ml | 16 ++- shared/src/logseq/model.mli | 1 + shared/src/logseq/view.ml | 2 +- shared/src/logseq/view_base.ml | 38 ++++-- shared/src/logseq/view_base.mli | 4 +- shared/src/logseq/view_screens.ml | 16 ++- shared/src/logseq/view_screens.mli | 2 +- shared/src/logseq/view_sidebar.ml | 2 +- shared/test/logseq/app_test.ml | 38 ++++++ shared/test/logseq/graph_runtime_test.ml | 18 +++ shared/test/logseq/node_property_test.ml | 31 +++++ 27 files changed, 510 insertions(+), 46 deletions(-) create mode 100644 apple/Tests/UI/navigation-transition-hydration.swift create mode 100644 apple/Tests/UI/outliner-editor-focus.swift create mode 100644 apple/Tests/UI/outliner-search-editor-focus.swift diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt index 093bcd3c..541b4376 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidPageRenameTest.kt @@ -42,7 +42,7 @@ class AndroidPageRenameTest { return JSONObject().put("ok", true).put("result", JSONObject().put("selectedGraphId", graph) .put("graphName", graph).put("selectedPage", JSONObject().put("uuid", "page").put("title", title)) .put("outlinerRows", JSONArray()).put("nodeProperties", JSONObject().put("ownerUuid", "page") - .put("properties", JSONArray()).put("rename", if (rename) property else JSONObject.NULL))).toString() + .put("properties", JSONArray()).put("canDelete", rename).put("rename", if (rename) property else JSONObject.NULL))).toString() } private fun apply(title: String, rename: Boolean = true, graph: String = "rename-check") { @@ -104,6 +104,7 @@ class AndroidPageRenameTest { ui.onNodeWithTag("button.overflow-menu").performClick() ui.onNodeWithText("Share").assertIsDisplayed() ui.onNodeWithTag("button.page.rename").assertDoesNotExist() + ui.onNodeWithText("Delete").assertDoesNotExist() pressBack() ui.runOnIdle { apply("Renamed page") } ui.onNodeWithTag("button.overflow-menu").performClick() diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/NodeIcon.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/NodeIcon.kt index 343810cb..ec1cf085 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/NodeIcon.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/NodeIcon.kt @@ -34,16 +34,17 @@ private object NodeIconAssets { internal fun LuiExtensionRegistry.registerNodeIcon() { register("node-icon", - "lui-extension-v1|9:node-icon|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:4:size:int:required:none,7:payload:string:required:none|events:", + "lui-extension-v1|9:node-icon|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:4:size:int:required:none,5:muted:bool:required:none,7:payload:string:required:none|events:", properties = listOf( LuiExtensionProperty("payload", LuiExtensionValueKind.STRING, required = true), LuiExtensionProperty("size", LuiExtensionValueKind.INTEGER, required = true), + LuiExtensionProperty("muted", LuiExtensionValueKind.BOOLEAN, required = true), ), events = emptyList(), - ) { NodeIcon(string("payload"), int("size") ?: 20) } + ) { NodeIcon(string("payload"), int("size") ?: 20, bool("muted") == true) } } @Composable -private fun NodeIcon(encoded: String?, size: Int) { +private fun NodeIcon(encoded: String?, size: Int, muted: Boolean) { val context = LocalContext.current val catalog = remember { NodeIconAssets.get(context) } val icon = remember(encoded) { runCatching { JSONObject(encoded ?: "{}") }.getOrDefault(JSONObject()) } @@ -55,7 +56,7 @@ private fun NodeIcon(encoded: String?, size: Int) { if (skins != null && skins.length() > 0) skins.optString((icon.optInt("skin", 1) - 1).coerceIn(0, skins.length() - 1)) else if (id.codePoints().anyMatch { it > 0x2000 }) id else "" } else glyph?.let { String(Character.toChars(it.getInt(0))) }.orEmpty() - val color = remember(icon.optString("color")) { + val color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else remember(icon.optString("color")) { runCatching { Color(android.graphics.Color.parseColor(icon.getString("color"))) }.getOrNull() } ?: MaterialTheme.colorScheme.onSurface Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { diff --git a/apple/Package.resolved b/apple/Package.resolved index 781beafa..0c57d70b 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" : "eea10c7bf99ad1a2a40cc84b1c9657a764073d72" + "revision" : "df9b11ce1f7316afa86ac60e61dbb6985786a1b7" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 9b799885..294645ae 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: "eea10c7bf99ad1a2a40cc84b1c9657a764073d72"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "df9b11ce1f7316afa86ac60e61dbb6985786a1b7"), .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/apple/Sources/Logseq/NodeIcon.swift b/apple/Sources/Logseq/NodeIcon.swift index 452db260..998cb220 100644 --- a/apple/Sources/Logseq/NodeIcon.swift +++ b/apple/Sources/Logseq/NodeIcon.swift @@ -72,26 +72,28 @@ enum NodeIconAssets { struct NodeIconView: View { let icon: NodeIconPayload let size: CGFloat + var muted: Bool = false var body: some View { let glyph = NodeIconAssets.glyph(icon) Text(verbatim: glyph.text) .font(glyph.family.map { .custom($0, size: size) } ?? .system(size: size)) - .foregroundStyle(icon.foreground) + .foregroundStyle(muted ? .secondary : icon.foreground) .frame(width: size, height: size) .accessibilityHidden(true) } } @MainActor enum NodeIconExtension { - static let fingerprint = "lui-extension-v1|9:node-icon|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:4:size:int:required:none,7:payload:string:required:none|events:" + static let fingerprint = "lui-extension-v1|9:node-icon|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:4:size:int:required:none,5:muted:bool:required:none,7:payload:string:required:none|events:" static func register(in registry: LUIAppleExtensionRegistry) throws { try registry.register(LUIAppleExtension(identifier: "node-icon", fingerprint: fingerprint, acceptsStandardChildren: false, - properties: [.init(name: "payload", kind: .string, isRequired: true), .init(name: "size", kind: .int, isRequired: true)], events: []) { context in + properties: [.init(name: "payload", kind: .string, isRequired: true), .init(name: "size", kind: .int, isRequired: true), .init(name: "muted", kind: .bool, isRequired: true)], events: []) { context in guard case let .string(encoded) = context.property("payload"), let icon = try? JSONDecoder().decode(NodeIconPayload.self, from: Data(encoded.utf8)), - case let .int(size) = context.property("size") else { return AnyView(EmptyView()) } - return AnyView(NodeIconView(icon: icon, size: CGFloat(size))) + case let .int(size) = context.property("size"), + case let .bool(muted) = context.property("muted") else { return AnyView(EmptyView()) } + return AnyView(NodeIconView(icon: icon, size: CGFloat(size), muted: muted)) }) } diff --git a/apple/Tests/UI/navigation-transition-hydration.swift b/apple/Tests/UI/navigation-transition-hydration.swift new file mode 100644 index 00000000..55148d25 --- /dev/null +++ b/apple/Tests/UI/navigation-transition-hydration.swift @@ -0,0 +1,111 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@main struct NavigationTransitionHydrationCheck { + @MainActor static func main() async throws { + var routes: [String] = [] + var rowCount = 96 + var revisions = 0 + func rows(_ page: String) -> [[String: Any]] { + (0.. String { + let result: [String: Any] = [ + "graphName": "Transition hydration", "selectedGraphId": "transition-hydration", + "outlinerRows": rows("journal"), + "nodeRoutes": routes.map { uuid -> [String: Any] in + ["uuid": uuid, "page": ["uuid": uuid, "title": uuid], "outlinerRows": rows(uuid)] + } + ] + return String(decoding: try! JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in + if let data = request.params.payload?.data(using: .utf8), + let effect = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + switch effect["kind"] as? String { + case "open-node": routes.append(effect["text"] as! String) + case "close-node": _ = routes.popLast() + default: break + } + } + return snapshot() + })) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<150 { + if condition() { return } + try await Task.sleep(for: .milliseconds(16)) + } + throw NSError(domain: "NavigationTransitionHydration", code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } + try await Task.sleep(for: .milliseconds(300)) + try await wait("The root outliner must mount") { + views(window).contains { $0.accessibilityIdentifier == "outliner.block-action.journal-child-0" } + } + var responder: UIResponder? = views(window).first { + $0.accessibilityIdentifier == "outliner.block-action.journal-child-0" + } + var containingNavigation: UINavigationController? + while let current = responder { + if let controller = current as? UIViewController, let navigation = controller.navigationController { + containingNavigation = navigation + break + } + responder = current.next + } + let navigation = containingNavigation! + var observedAnimatedPushes = 0 + var observedAnimatedPops = 0 + for cycle in 0..<6 { + try runtime.applyHostUpdate(kind: "open-node", payload: "\"Destination \(cycle)\"") + try await wait("A real animated push must start") { + navigation.viewControllers.count == 2 && navigation.transitionCoordinator != nil + } + observedAnimatedPushes += 1 + let retained = navigation.topViewController! + for count in [0, 256, 1, 96] { + rowCount = count + revisions += 1 + try runtime.applyCoreResponse(snapshot()) + try await Task.sleep(for: .milliseconds(24)) + } + try await wait("Hydration must finish without replacing the incoming controller") { + navigation.transitionCoordinator == nil && navigation.topViewController === retained + } + _ = navigation.popViewController(animated: true) + guard navigation.transitionCoordinator != nil else { + throw NSError(domain: "NavigationTransitionHydration", code: 2, + userInfo: [NSLocalizedDescriptionKey: "The check must exercise animated native pops"]) + } + observedAnimatedPops += 1 + for count in [256, 0, 96] { + rowCount = count + revisions += 1 + try runtime.applyCoreResponse(snapshot()) + try await Task.sleep(for: .milliseconds(24)) + } + try await wait("Hydration during native back must settle both route owners") { + navigation.viewControllers.count == 1 && navigation.transitionCoordinator == nil && routes.isEmpty + } + } + guard runtime.lastError == nil else { + throw NSError(domain: "NavigationTransitionHydration", code: 3, + userInfo: [NSLocalizedDescriptionKey: runtime.lastError!]) + } + print("PASS: \(observedAnimatedPushes) native pushes, \(observedAnimatedPops) native pops and \(revisions) lazy-row replacements during transitions") + } +} diff --git a/apple/Tests/UI/outliner-editor-focus.swift b/apple/Tests/UI/outliner-editor-focus.swift new file mode 100644 index 00000000..c77229fd --- /dev/null +++ b/apple/Tests/UI/outliner-editor-focus.swift @@ -0,0 +1,59 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class FocusEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct OutlinerEditorFocusCheck { + @MainActor static func main() async throws { + func snapshot(selectedPage: Bool, editing: Bool) throws -> String { + var result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "outlinerRows": [["depth": 0, "block": ["uuid": "target", "title": "Hello world", + "pageId": "journal", "journalDay": 20261023, "journalTitle": "Today"]]], + "outlinerState": editing ? ["editing": ["uuid": "target", "title": "Hello world", "caretUTF16Offset": 11]] : ["selectedBlockIds": []]] + if selectedPage { result["selectedPage"] = ["uuid": "journal", "title": "Today"] } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: FocusEffects()) + try runtime.applyCoreResponse(snapshot(selectedPage: false, editing: false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer).ignoresSafeArea(.keyboard)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func editors() -> [UITextView] { views(window).compactMap { $0 as? UITextView }.filter { + $0.accessibilityIdentifier == "field.outliner.block.target" + } } + try await Task.sleep(for: .milliseconds(400)) + for selectedPage in [false, true] { + try runtime.applyCoreResponse(snapshot(selectedPage: selectedPage, editing: true)) + try await Task.sleep(for: .milliseconds(450)) + guard editors().count == 1, let editor = editors().first else { + throw failure("Expected one editor in selectedPage=\(selectedPage); found \(editors().count)") + } + guard editor.isFirstResponder else { throw failure("The visible editor must own the insertion cursor") } + for offset in [2, 7, 0] { + editor.selectedRange = NSRange(location: offset, length: 0) + editor.delegate?.textViewDidChangeSelection?(editor) + // A repeated core acknowledgement must not restore the old end-of-block caret. + try runtime.applyCoreResponse(snapshot(selectedPage: selectedPage, editing: true)) + try await Task.sleep(for: .milliseconds(150)) + guard editors().count == 1, editors().first === editor, editor.isFirstResponder, + editor.selectedRange == NSRange(location: offset, length: 0) else { + throw failure("A caret tap at \(offset) lost focus, identity or position in selectedPage=\(selectedPage)") + } + } + } + print("PASS: retained journal and selected page keep one focused editor and preserve native caret taps") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "EditorFocus", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/outliner-search-editor-focus.swift b/apple/Tests/UI/outliner-search-editor-focus.swift new file mode 100644 index 00000000..f7e8d386 --- /dev/null +++ b/apple/Tests/UI/outliner-search-editor-focus.swift @@ -0,0 +1,110 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@main struct OutlinerSearchEditorFocusCheck { + @MainActor static func main() async throws { + var routes: [String] = [] + var editing = false + let row: [String: Any] = ["depth": 0, "block": ["uuid": "target", "title": "Hello world", + "pageId": "journal", "journalDay": 20261023, "journalTitle": "Today"]] + func snapshot() throws -> String { + let state: [String: Any] = editing + ? ["editing": ["uuid": "target", "title": "Hello world", "caretUTF16Offset": 11]] + : ["selectedBlockIds": []] + let projections: [[String: Any]] = routes.map { uuid in + ["uuid": uuid, "page": ["uuid": "journal", "title": "Today"], + "outlinerRows": [row], "outlinerState": state] + } + let result: [String: Any] = ["selectedGraphId": "demo", "graphName": "Demo", + "outlinerRows": [row], "outlinerState": state, "nodeRoutes": projections, + "searchQuery": "Today", "searchResults": [["uuid": "journal", "title": "Today", "isPage": true]]] + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { request in + if let data = request.params.payload?.data(using: .utf8), + let effect = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + switch effect["kind"] as? String { + case "open-node", "open-search-result": + if let uuid = effect["text"] as? String { routes.append(uuid); editing = false } + case "close-node": _ = routes.popLast(); editing = false + case "tap-outliner-block": editing = true + default: break + } + } + return try! snapshot() + })) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer).ignoresSafeArea(.keyboard)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + let backend = runtime.renderer.backend + func descendants(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(descendants).last { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + func editors() -> [UITextView] { views(window).compactMap { $0 as? UITextView }.filter { + $0.accessibilityIdentifier == "field.outliner.block.target" + } } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message) + } + try await Task.sleep(for: .milliseconds(400)) + try backend.performPress(node: find("button.search")!) + try await wait("Search must focus its native input") { + views(window).contains { ($0 as? UISearchTextField)?.isFirstResponder == true } + } + views(window).compactMap { $0 as? UISearchTextField }.first { $0.isFirstResponder }!.insertText("Today") + try await wait("Search result must appear") { find("search.result.journal") != nil } + try backend.performPress(node: find("search.result.journal")!) + try await wait("Search must open the page") { routes == ["journal"] } + try await Task.sleep(for: .milliseconds(650)) + // Also cover following a link from that search destination. + for nested in [false, true] { + if nested { + guard let row = backend.rootIDs.flatMap(descendants).last(where: { + backend.extensionModel(id: $0)?.identifier == "outliner-block-row" + }) else { throw failure("The search page must retain its linked row") } + try backend.performExtensionEvent(node: row, name: "open-node", values: ["uuid": .string("target")]) + try await wait("The linked block must push above the search page") { routes.count == 2 } + try await Task.sleep(for: .milliseconds(650)) + } + guard let target = backend.rootIDs.flatMap(descendants).last(where: { + guard let node = backend.extensionModel(id: $0), node.identifier == "outliner-block-row", + case let .string(payload) = node.properties["payload"] else { return false } + return payload.contains("target") + }) else { throw failure("The search destination must render its block") } + try backend.performExtensionEvent(node: target, name: "edit", values: ["uuid": .string("target")]) + try await wait("Tapping the block must enter editing") { !editors().isEmpty } + try await Task.sleep(for: .milliseconds(450)) + guard editors().count == 1, let editor = editors().first, editor.isFirstResponder else { + throw failure("Search destination nested=\(nested) must own one visible focused editor; found \(editors().count)") + } + for offset in [2, 7, 0] { + editor.selectedRange = NSRange(location: offset, length: 0) + editor.delegate?.textViewDidChangeSelection?(editor) + try runtime.applyCoreResponse(snapshot()) + try await Task.sleep(for: .milliseconds(150)) + guard editors().count == 1, editors().first === editor, editor.isFirstResponder, + editor.selectedRange == NSRange(location: offset, length: 0) else { + throw failure("Search destination nested=\(nested) lost its native caret tap at \(offset)") + } + } + } + print("PASS: search-result pages and links from search retain one visible cursor and preserve caret taps") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "SearchEditorFocus", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/page-rename.swift b/apple/Tests/UI/page-rename.swift index 18bfe0bb..fef922f8 100644 --- a/apple/Tests/UI/page-rename.swift +++ b/apple/Tests/UI/page-rename.swift @@ -24,12 +24,13 @@ import UIKit var title = "Original page" var graph = "rename-check" var canRename = true + var canDelete = true func snapshot() throws -> String { let property: [String: Any] = ["attr": "block/title", "title": "Name", "kind": "string", "text": title, "editable": true, "raw": ["type": "string", "value": title]] let result: [String: Any] = ["selectedGraphId": graph, "graphName": graph, "selectedPage": ["uuid": "page", "title": title], "outlinerRows": [], - "nodeProperties": ["ownerUuid": "page", "properties": [], "rename": canRename ? property as Any : NSNull()]] + "nodeProperties": ["ownerUuid": "page", "properties": [], "canDelete": canDelete, "rename": canRename ? property as Any : NSNull()]] return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) } let effects = RenameEffects() @@ -65,7 +66,7 @@ import UIKit try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent(name)) } let editor = "page.rename.page" - try await wait("Ordinary pages offer Rename page") { find("button.page.rename") != nil } + try await wait("Ordinary pages offer Rename page and Delete") { find("button.page.rename") != nil && find("button.page.delete") != nil } try backend.performPress(node: find("button.page.rename")!) try await wait("Rename presents a native sheet") { window.rootViewController?.presentedViewController != nil && find("view.edit.value." + editor) != nil } try await capture("page-rename-editor.png") @@ -89,9 +90,11 @@ import UIKit try await wait("Native dismissal finishes") { window.rootViewController?.presentedViewController == nil } try await capture("page-renamed.png") canRename = false + canDelete = false try runtime.applyCoreResponse(snapshot()) - try await wait("Protected pages do not expose Rename page") { find("button.page.rename") == nil } + try await wait("Built-in pages expose neither Rename page nor Delete") { find("button.page.rename") == nil && find("button.page.delete") == nil } canRename = true + canDelete = true try runtime.applyCoreResponse(snapshot()) try await wait("Returning to an ordinary page restores Rename page") { find("button.page.rename") != nil } try backend.performPress(node: find("button.page.rename")!) diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index 5cc3cc23..af5f47ef 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -305,9 +305,13 @@ import Vision func fields(in view: UIView) -> [UITextField] { ((view as? UITextField).map { [$0] } ?? []) + view.subviews.flatMap { fields(in: $0) } } - for style in [UIUserInterfaceStyle.light, .dark] { + for (mode, style, dark) in [ + ("system", UIUserInterfaceStyle.light, false), ("system", .dark, true), + ("light", .dark, false), ("dark", .light, true) + ] { + try runtime.applyHostUpdate(kind: "settings", payload: "{\"appearance\":\"\(mode)\",\"language\":\"system\",\"spellCheck\":true,\"autoCorrection\":true,\"sidebarTabs\":[],\"baseURL\":\"https://api.logseq.io\",\"version\":\"test\",\"revision\":\"test\"}") window.overrideUserInterfaceStyle = style - _ = try await capture(style == .dark ? "query-search-dark.png" : "query-search-light.png") + _ = try await capture("query-search-\(mode)-\(style == .dark ? "dark" : "light").png") guard let field = fields(in: window.rootViewController!.view).first(where: { $0.placeholder == "Search results" }) else { throw failure("Search must display a native input") } @@ -318,7 +322,27 @@ import Vision guard field.borderStyle == .none else { throw failure("An inline search uses one native search surface, without a separate rounded black input") } + for focused in [false, true] { + if focused { field.becomeFirstResponder() } else { field.resignFirstResponder() } + try await Task.sleep(for: .milliseconds(200)) + let frame = field.convert(field.bounds, to: window) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let pixel = CGPoint(x: frame.maxX - 12, y: frame.midY) + var rgba = [UInt8](repeating: 0, count: 4) + let context = CGContext(data: &rgba, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.translateBy(x: -pixel.x, y: pixel.y - window.bounds.height + 1) + context.draw(image.cgImage!, in: window.bounds) + let expected = dark ? [25, 57, 77] : [248, 248, 248] + guard zip(rgba.prefix(3), expected).allSatisfy({ abs(Int($0.0) - $0.1) <= 3 }) else { + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("query-search-wrong-color.png")) + throw failure("Search must use the theme surface in \(mode)/\(style.rawValue), focused=\(focused): \(rgba), expected \(expected)") + } + } } + try runtime.applyHostUpdate(kind: "settings", payload: #"{"appearance":"system","language":"system","spellCheck":true,"autoCorrection":true,"sidebarTabs":[],"baseURL":"https://api.logseq.io","version":"test","revision":"test"}"#) try backend.performPress(node: find("view.type.option.gallery.table")!) try await wait("The type icon must save the active view's type") { effects.viewEdits.contains { ($0["viewUuid"] as? String) == "gallery" diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 6c9db211..2720f8d2 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -202,3 +202,35 @@ A separately signed `com.logseq.logseq.RuntimeCheck` app runs mock snapshots in The test container's diagnostic log confirms `UIApplicationWillResignActiveNotification` immediately after the search keyboard appears (`/tmp/logseq-device-runtime-check-interaction.log`), without establishing why it resigned. After copying this log, the independently installed RuntimeCheck app was removed (`/tmp/logseq-device-runtime-check-cleanup.json`); the regular Logseq installation/container were not modified. Both experimental source replacements are fully restored, and their final simulator rebuilds succeeded. Final checks against the restored current simulator pass twelve search-result push/back cycles and all four cold/late app/search restoration cases, with no cycle warning: `/tmp/logseq-crash-audit-current-search.log` and `/tmp/logseq-crash-audit-current-restoration.log`. The runner also passes Python compilation and `git diff --check`. This audit changes test diagnostics/documentation only; the preceding 1042-test core gate and installed application code are unchanged. + + +### Visible editor ownership and native search surface + +The user-device interaction log contains two different `outliner-editor` protocol nodes and two attached UITextViews for the same block. Both repeatedly become first responder, leaving the visible editor without a cursor and restoring another editor's end-of-block position. This is an ownership bug across retained panes, not a reason to force the native caret to the latest core offset. + +The historical journal projection only cleared editing while `node_routes` was nonempty. `1c69e277` introduced `navigation_root_model` for retained titles, and `7634b389` applied that projection to the actual iOS root content: it erased the route/search state before the journal projection could check it. In addition, a selected journal page and the retained journal could both match the same editing UUID. Inactive cached node routes could also retain their editor state. + +Decide root interaction ownership before clearing navigation state, clear interaction state in the retained journal unless it is the visible journal, and permit only the active visit in each native route stack to project editing/selection/autocomplete. Keep retained rows and scroll identities. No native caret-reset workaround is introduced. + +- The new shared integration case fails before the fix with duplicate editors and passes for both iOS and Android through journals, selected journal pages and nested routes. The full gate at that stage passes 1043 tests in 66.493s. +- `outliner-editor-focus.swift` fails before the fix with two editors after selecting a journal page. Afterward it preserves one first responder and native selections at UTF-16 offsets 2, 7 and 0 across stale core acknowledgements. Evidence: `/tmp/logseq-editor-focus-{red,green}.log` and `/tmp/logseq-editor-focus-core-{red,green}.log`. +- `outliner-search-editor-focus.swift` opens an actual search result, taps its block to edit, then follows its real `open-node` row event and repeats editing. The exact build-18 implementation fails with two editors on the first search destination; the current implementation passes both destinations and all caret positions. The nested test uses a row link event, not the external host's app-navigation deep-link action. Evidence: `/tmp/logseq-search-editor-focus-{old,green}.log`. +- Four Android native completion/paste/failure-recovery checks pass against the rebuilt app: `/tmp/logseq-editor-focus-android-checks.log`. + +The inline result-search fill used SwiftUI's `.quaternary` foreground hierarchy instead of an app surface token. LUI `df9b11ce` resolves the semantic surface with a system-fill fallback. Native screenshot pixel checks fail on the previous gray fill and pass for light/dark, focused/unfocused and app appearance overriding the system theme. The final pinned consumer query/view workflow also passes (`/tmp/logseq-search-surface-red.log`, `/tmp/logseq-search-surface-green.log`, `/tmp/logseq-query-search-final.log`). Upstream PR 258 is updated. Temporary cached-checkout substitutions were restored before resolving the real new pin. + +### Built-in deletion and sidebar icon follow-up + +Expose a core `canDelete` capability with node properties, hide Delete in protected page menus on both platforms, and reject a protected or stale confirmation at the shared model boundary. Reuse the core built-in classifier for deletion as well as title editing, including internal class/property identifiers without an explicit built-in flag and hidden system pages. Existing ordinary-page deletion remains available. Focused tests first fail on the missing capability and unprotected internal identifier, then pass with no queued operation or deleted-at mutation for protected pages (`/tmp/logseq-builtin-delete-{red,green}.log`). + +Sidebar custom monochrome node icons now use the same subdued native foreground as default page icons. The shared node-icon component carries a presentation-only muted property; page titles and outliner icons retain their normal presentation. + +### Full build-18 crash controls + +A detached full `ce301717` consumer and its exact Swift LUI pin `e91ee491` build successfully. Its OCaml LUI source is identical to the build-18 opam pin. Unlike the earlier file substitutions, this exercises the complete old core/consumer/backend combination. It passes twelve search push/back cycles, all four cold/late restoration cases and the new native transition-hydration stress check on iOS 26. The latter submits 42 snapshots during animated push/pop transitions; this is not a claim that every intermediate snapshot was individually rendered. Logs: `/tmp/logseq-build18-full-{search,restoration,hydration}.log`. The corresponding device core/Swift library build also succeeds, but was not installed over the user's app. + +Archive metadata and the original binary's build-version command confirm SDK 26.0 for build 18, matching this machine's Xcode 26.0 SDK; AppStoreTools 27B47 is not its linked SDK. Across the three retrieved crash reports, AttributeGraph's allocation fault follows an invalid free-list offset. This narrows the failure signature but does not identify who corrupted it. No exact EXC_BAD_ACCESS is reproduced, and the specific iOS 27.0.1 TestFlight crash remains unresolved. + +The combined focus, deletion and sidebar changes pass the full core gate: `/tmp/logseq-focus-builtin-core-final.log`, 1045 tests in 66.323s. This includes matching iOS/Android node-icon extension fingerprints, protected delete capability/menu checks and unchanged ordinary-page deletion. + +The final rebuilt iOS simulator passes all six native checks: visible editor ownership, search-result/link caret taps, protected page menu/rename failure-retry, sidebar node icons, editor/image position retention and nested/search/Recent selection actions. Logs use `/tmp/logseq-focus-final-.log`. The sidebar screenshot was inspected: custom monochrome glyphs match the native document/control secondary gray while content titles retain their normal color. diff --git a/logseq.opam b/logseq.opam index 63ca75ce..f2fd711e 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#eea10c7bf99ad1a2a40cc84b1c9657a764073d72"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#df9b11ce1f7316afa86ac60e61dbb6985786a1b7"] ["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/shared/src/logseq/core/node_property.ml b/shared/src/logseq/core/node_property.ml index cc7b9231..03875662 100644 --- a/shared/src/logseq/core/node_property.ml +++ b/shared/src/logseq/core/node_property.ml @@ -31,7 +31,7 @@ let render db ~owner ~encode_block = let rename = if Node_title.can_rename_page db id then Result_view.property_json ~include_candidates:false db ~owner ~attr:"block/title" ~encode_block else `Null in - Ok (`Assoc ["ownerUuid", `String owner; "rename", rename; "properties", `List (List.map (fun attr -> + Ok (`Assoc ["ownerUuid", `String owner; "canDelete", `Bool (Node_title.can_delete db id); "rename", rename; "properties", `List (List.map (fun attr -> Result_view.property_json db ~owner ~attr ~encode_block) attrs)]) let mutation db ~now ~fresh_uuid input = diff --git a/shared/src/logseq/core/node_title.ml b/shared/src/logseq/core/node_title.ml index c2542882..bebd0c66 100644 --- a/shared/src/logseq/core/node_title.ml +++ b/shared/src/logseq/core/node_title.ml @@ -18,6 +18,7 @@ let builtin db id = get db id "logseq.property/built-in?" = Some (D.Bool true) let journal db id = get db id "block/journal-day" <> None || tagged db id "logseq.class/Journal" let can_edit db id = not (builtin db id || journal db id) let can_rename_page db id = is_page db id && can_edit db id +let can_delete db id = not (builtin db id) && get db id "logseq.property/hide?" <> Some (D.Bool true) let nfc text = let normalizer = Uunf.create `NFC in diff --git a/shared/src/logseq/core/node_title.mli b/shared/src/logseq/core/node_title.mli index 3fec6450..7f95e941 100644 --- a/shared/src/logseq/core/node_title.mli +++ b/shared/src/logseq/core/node_title.mli @@ -1,4 +1,5 @@ val is_page : Datascript.db -> int -> bool +val can_delete : Datascript.db -> int -> bool val can_edit : Datascript.db -> int -> bool val can_rename_page : Datascript.db -> int -> bool val name : string -> string diff --git a/shared/src/logseq/core/pending_projection.ml b/shared/src/logseq/core/pending_projection.ml index 80132229..ae5cb982 100644 --- a/shared/src/logseq/core/pending_projection.ml +++ b/shared/src/logseq/core/pending_projection.ml @@ -771,10 +771,7 @@ let rec compile db (intent : Ops.pending_intent) = = Some recycle_eid then Ok [] else if - bool_value (one_value db reference "logseq.property/built-in?") - = Some true - || bool_value (one_value db reference "logseq.property/hide?") - = Some true + not (Node_title.can_delete db page_eid) then Error "Built-in page cannot be deleted" else let parent = diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index e9968363..41c329a8 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -1409,6 +1409,16 @@ let active_page (current : logseq_model) = | None -> None) end +let page_can_delete (current : logseq_model) uuid = + let raw = match List.find_opt (fun (route : node_projection) -> route.node_uuid = uuid) current.node_routes with + | Some route -> route.node_properties_json + | None -> if Option.fold ~none:false ~some:(fun (page : sidebar_page) -> page.uuid = uuid) current.selected_page + then current.properties_json else "" in + match (try Yojson.Basic.from_string raw with _ -> `Null) with + | `Assoc fields when List.assoc_opt "ownerUuid" fields = Some (`String uuid) -> + List.assoc_opt "canDelete" fields <> Some (`Bool false) + | _ -> true + let page_is_favorite_ (current : logseq_model) uuid = List.exists (fun (page : sidebar_page) -> page.uuid = uuid) current.favorites @@ -2716,17 +2726,19 @@ let rec update (current : logseq_model) action = | None -> current) | RequestDeleteActivePage -> (match active_page current with - | Some page -> + | Some page when page_can_delete current page.uuid -> { current with connection_menu_open = false; pending_page_deletion = Some page; } - | None -> current) + | _ -> current) | CancelDeleteActivePage -> { current with pending_page_deletion = None } | ConfirmDeleteActivePage -> (match current.pending_page_deletion with + | Some page when not (page_can_delete current page.uuid) -> + { current with pending_page_deletion = None } | Some page -> let updated = { current with pending_page_deletion = None } in let delete_id = updated.next_effect_id in diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index 4e673b41..a29718bc 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -676,6 +676,7 @@ val indexed_outliner_autocomplete_candidates : val valid_authentication_state_ : string -> bool val current_settings : logseq_model -> settings_projection val active_page : logseq_model -> sidebar_page option +val page_can_delete : logseq_model -> string -> bool val page_is_favorite_ : logseq_model -> string -> bool val page_share_text : sidebar_page -> outline_row list -> string val page_share_asset_paths : outline_row list -> string list diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index b2ad4c30..586c6f98 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -108,7 +108,7 @@ let node_icon_schema () = let open Lui_protocol in let open Lui_extension in Lui_extension.component "node-icon" [profile IOS SwiftUIHost; profile AndroidOS KotlinHost] false [] - [property "payload" StringScalar true None; property "size" IntScalar true None] [] + [property "payload" StringScalar true None; property "size" IntScalar true None; property "muted" BoolScalar true None] [] let outliner_block_row_schema () = let open Lui_protocol in diff --git a/shared/src/logseq/view_base.ml b/shared/src/logseq/view_base.ml index 057d368f..541a1d57 100644 --- a/shared/src/logseq/view_base.ml +++ b/shared/src/logseq/view_base.ml @@ -3,13 +3,14 @@ open Lui_protocol let node_icon (current : Model.logseq_model) uuid = Option.value ~default:"" (String_map.find_opt uuid current.node_icons) -let node_icon_view ~size source : Lui_elements.t = +let node_icon_view ?(muted = false) ~size source : Lui_elements.t = Lui_elements.if_ ~test:(Signal.map (fun value -> value <> "") source) (fun context parent -> let node = Lui_ui.extension context "node-icon" in Lui_elements.attach context parent node; Lui_ui.extension_property_signal context node "payload" (Signal.map (fun value -> StringValue value) source); Lui_ui.extension_property context node "size" (IntValue size); + Lui_ui.extension_property context node "muted" (BoolValue muted); node) let string_wire_value value = StringValue value @@ -1364,7 +1365,20 @@ let selected_page_title (current : Model.logseq_model) = | Some page -> page.title | None -> "" +let without_outliner_interaction (current : Model.logseq_model) = + { current with + outliner_selected_block_ids = []; + outliner_editing = None; + outliner_autocomplete = None; + outliner_autocomplete_candidates = []; + } + let navigation_root_model (current : Model.logseq_model) = + (* Decide ownership before projecting away the active navigation path. *) + let current = + if journal_root_visible_ current then current + else without_outliner_interaction current + in { current with outliner_rows = (if current.node_routes = [] then current.outliner_rows else current.navigation_root_rows); node_routes = []; @@ -1394,18 +1408,12 @@ let journal_navigation_model (current : Model.logseq_model) = outliner_section_markers = Model.journal_section_markers rows; } in - if current.node_routes = [] then root - else - { - root with - outliner_selected_block_ids = []; - outliner_editing = None; - outliner_autocomplete = None; - outliner_autocomplete_candidates = []; - } + if journal_home_visible_ current then root + else without_outliner_interaction root -let node_route_model (current : Model.logseq_model) +let node_route_model ~active (current : Model.logseq_model) (route : Model.node_projection) = + let projected = { current with node_routes = [ route ]; @@ -1417,6 +1425,14 @@ let node_route_model (current : Model.logseq_model) outliner_autocomplete_candidates = route.node_outliner_autocomplete_candidates; } + in + let visible = active + && (not current.search_open || current.search_navigation_path <> []) + && Option.fold ~none:false + ~some:(fun (last : Model.node_projection) -> last.node_uuid = route.node_uuid) + (Model.last_opt current.node_routes) + in + if visible then projected else without_outliner_interaction projected let app_navigation_depth (current : Model.logseq_model) = navigation_path_depth current.app_navigation_path diff --git a/shared/src/logseq/view_base.mli b/shared/src/logseq/view_base.mli index a9128879..7ef99ddf 100644 --- a/shared/src/logseq/view_base.mli +++ b/shared/src/logseq/view_base.mli @@ -1,5 +1,5 @@ val node_icon : Model.logseq_model -> string -> string -val node_icon_view : size:int -> string Signal.signal -> Lui_elements.t +val node_icon_view : ?muted:bool -> size:int -> string Signal.signal -> Lui_elements.t val string_wire_value : string -> Lui_protocol.wire_value val int_wire_value : int -> Lui_protocol.wire_value val bool_wire_value : bool -> Lui_protocol.wire_value @@ -354,7 +354,7 @@ val selected_page_title : Model.logseq_model -> string val navigation_root_model : Model.logseq_model -> Model.logseq_model val journal_navigation_model : Model.logseq_model -> Model.logseq_model val node_route_model : - Model.logseq_model -> Model.node_projection -> Model.logseq_model + active:bool -> Model.logseq_model -> Model.node_projection -> Model.logseq_model val app_navigation_depth : Model.logseq_model -> int val app_back_depth : Model.logseq_model -> int val search_navigation_depth : Model.logseq_model -> int diff --git a/shared/src/logseq/view_screens.ml b/shared/src/logseq/view_screens.ml index f9b75267..e0ae5152 100644 --- a/shared/src/logseq/view_screens.ml +++ b/shared/src/logseq/view_screens.ml @@ -588,8 +588,12 @@ let active_overflow_menu model_source send : t = (menu_item ~text:"Share" ?icon:(item_icon "share") ~on_press:(press send Model.ShareActivePage) []); - if_ ~test:page_actions_source + if_ ~test:(Signal.map (fun current -> + match Model.active_page current with + | Some page -> Model.page_can_delete current page.uuid + | None -> false) model_source) (menu_item ~text:"Delete" ~variant:`destructive + ~accessibility_identifier:"button.page.delete" ?icon:(item_icon "toolbar-delete") ~on_press:(press send Model.RequestDeleteActivePage) []); @@ -646,7 +650,8 @@ let native_node_destination ~source:title_source content : t = let native_node_screen (context : Lui_ui.ui_context) model_source ~source:route_source send : t = let route_model_source = - Signal.map2 View_base.node_route_model model_source route_source + Signal.map2 (fun current (route, active) -> + View_base.node_route_model ~active current route) model_source route_source in if Lui_ui.host context = KotlinHost then box ~grow:1.0 ~background:"background" @@ -661,8 +666,8 @@ let native_node_screen (context : Lui_ui.ui_context) model_source route_model_source) (column ~gap:0 [ View_outliner.node_screen context route_model_source send; - if_ ~test:(Signal.map2 (fun (current : Model.logseq_model) (route : Model.node_projection) -> - route.node_outliner_selected_block_ids <> [] + if_ ~test:(Signal.map2 (fun (current : Model.logseq_model) ((route : Model.node_projection), active) -> + active && route.node_outliner_selected_block_ids <> [] && Option.fold ~none:false ~some:(fun (active : Model.node_projection) -> active.node_uuid = route.node_uuid) (Model.last_opt current.node_routes)) model_source route_source) (View_outliner.outliner_selection_toolbar context route_model_source send); @@ -679,7 +684,8 @@ let native_node_stack context model_source ~source:routes_source send = ~cmp:compare ~mount:(fun visit_source -> native_node_screen context model_source - ~source:(reactive (fun (_, route) -> route) visit_source) send) + ~source:(Signal.map2 (fun routes (index, route) -> + (route, index = List.length routes - 1)) routes_source visit_source) send) let native_search_view (_context : Lui_ui.ui_context) model_source send : t = diff --git a/shared/src/logseq/view_screens.mli b/shared/src/logseq/view_screens.mli index f0f6dfa8..2d6d4e71 100644 --- a/shared/src/logseq/view_screens.mli +++ b/shared/src/logseq/view_screens.mli @@ -51,7 +51,7 @@ val main_header_connection : val native_node_screen : Lui_ui.ui_context -> Model.logseq_model Signal.signal -> - source:Model.node_projection Signal.signal -> + source:(Model.node_projection * bool) Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val native_search_view : Lui_ui.ui_context -> diff --git a/shared/src/logseq/view_sidebar.ml b/shared/src/logseq/view_sidebar.ml index 97846013..5d3e8425 100644 --- a/shared/src/logseq/view_sidebar.ml +++ b/shared/src/logseq/view_sidebar.ml @@ -19,7 +19,7 @@ let sidebar_page_row (context : Lui_ui.ui_context) model_source ~accessibility_identifier:(View_base.sidebar_page_identifier page) ~on_press:navigate (if custom then [row ~gap:10 ~cross:`center [ - View_base.node_icon_view ~size:20 icon_source; + View_base.node_icon_view ~muted:true ~size:20 icon_source; text ~value:(reactive View_base.sidebar_page_title page_source) []]] else [])) in column ~gap:0 [ if_ ~test:(Signal.map (fun icon -> icon <> "") icon_source) (item true); diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index b7a9980c..f6e032ef 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -6427,6 +6427,43 @@ let kotlin_selected_pages_retain_the_hidden_journal_pane () = let list = descendant_with_identifier renderer journal "list.outliner" in check ~msg:"the retained journal is not placed or interactive" (not (property_bool renderer list Lui_protocol.Selected)) +let retained_panes_mount_only_the_visible_editor () = + List.iter (fun backend -> + let application = App.create backend in + let row = journal_outline_row "shared-block" "journal-page" "Hello world" "Today" 20261023 0 in + let editing = { Model.editing_uuid = "shared-block"; editing_title = "Hello world"; caret_utf16_offset = 5 } in + let projection sidebar routes = + { (empty_core_projection ()) with + graph_name = Some "Work"; selected_graph_id = Some "test-graph"; + sidebar; outliner_rows = [row]; journal_outliner_rows = [row]; + node_routes = routes; projection_outliner_editing = Some editing } + in + let editor_count () = + Hashtbl.fold (fun _ identifier count -> + if identifier = "outliner-editor" then count + 1 else count) + (Lui_app.runtime application).Lui_runtime.runtime_extension_nodes 0 + in + start application; + send application (Model.ApplyCoreSnapshot (projection (empty_sidebar_projection ()) [])); + flush application; + check_eq ~msg:"the visible journal owns exactly one native editor" (editor_count ()) 1; + let sidebar = { (empty_sidebar_projection ()) with Model.selected_page = Some { Model.uuid = "journal-page"; title = "Today" } } in + send application (Model.ApplyCoreSnapshot (projection sidebar [])); + flush application; + check_eq ~msg:"opening a journal as a page must not focus a hidden duplicate editor" (editor_count ()) 1; + send application (Model.RequestAppNode "journal-page"); + let route = { (node_projection "journal-page" "journal-page" "Today" [] []) with + Model.node_outliner_rows = [row]; node_outliner_editing = Some editing } in + send application (Model.ApplyCoreSnapshot (projection sidebar [route])); + flush application; + check_eq ~msg:"a pushed page owns the editor while its retained root is read-only" (editor_count ()) 1; + send application (Model.RequestAppNode "shared-block"); + let child = {route with Model.node_uuid = "shared-block"} in + send application (Model.ApplyCoreSnapshot (projection sidebar [route; child])); + flush application; + check_eq ~msg:"only the active route may mount the shared block's editor" (editor_count ()) 1) + [ios_backend (); kotlin_backend ()] + let kotlin_selected_page_navigation_renders_the_opened_node () = let application = App.create (kotlin_backend ()) in let page = { Model.uuid = "page-a"; title = "Project" } in @@ -10512,6 +10549,7 @@ let cases = case "kotlin-navigation-and-search-own-composed-standard-children" kotlin_navigation_and_search_own_composed_standard_children; case "kotlin-navigation-renders-each-active-node-row-once" kotlin_navigation_renders_each_active_node_row_once; case "kotlin-selected-pages-retain-the-hidden-journal-pane" kotlin_selected_pages_retain_the_hidden_journal_pane; + case "retained-panes-mount-only-the-visible-editor" retained_panes_mount_only_the_visible_editor; case "kotlin-selected-page-navigation-renders-the-opened-node" kotlin_selected_page_navigation_renders_the_opened_node; case "native-navigation-retains-the-journal-and-every-node-route" native_navigation_retains_the_journal_and_every_node_route; case "native-navigation-retains-repeated-node-visits" native_navigation_retains_repeated_node_visits; diff --git a/shared/test/logseq/graph_runtime_test.ml b/shared/test/logseq/graph_runtime_test.ml index 9895cf85..dca27f69 100644 --- a/shared/test/logseq/graph_runtime_test.ml +++ b/shared/test/logseq/graph_runtime_test.ml @@ -287,6 +287,23 @@ let page_deletion_is_optimistic_durable_and_rejects_built_ins () = | _ -> false); check (not (is_ok (Runtime.delete_page current "recycle-page" "built-in" 101)))) +let protected_page_deletion_never_stages_mutations () = + List.iter (fun (label, protection) -> with_store (fun path -> + let database = Ds.db_with ([ + add 20 "block/uuid" (Ds.Uuid "recycle-page"); + add 20 "block/title" (Ds.String "Recycle"); + add 20 "block/name" (Ds.String "recycle"); + add 20 "logseq.property/built-in?" (Ds.Bool true) + ] @ protection) (base_db "Text") in + let current = Runtime.create path 42 (Ds.conn_from_db database) Runtime.default_options in + check ~msg:(label ^ " cannot be deleted") (not (is_ok (Runtime.delete_page current "page" "protected-delete" 100))); + check_eq ~msg:"rejected deletion does not enqueue a durable operation" (Runtime.pending_operations current) []; + check_eq ~msg:"rejected deletion leaves the page unchanged" (Graph_read.value (Runtime.db current) 1 "logseq.property/deleted-at") None)) + ["built-in flag", [add 1 "logseq.property/built-in?" (Ds.Bool true)]; + "hidden system page", [add 1 "logseq.property/hide?" (Ds.Bool true)]; + "built-in class without a flag", [add 1 "db/ident" (Ds.Keyword "logseq.class/Protected")]; + "built-in property without a flag", [add 1 "db/ident" (Ds.Keyword "logseq.property/protected-test")]] + let flashcard_review_is_one_atomic_millisecond_operation () = with_store (fun path -> let now = 1776000000000 in @@ -1871,6 +1888,7 @@ let cases = projected_readers_share_offline_edits_without_changing_authoritative_data; case "favorites update sidebar and encode the link transaction" favorites_update_sidebar_and_encode_the_link_transaction; + case "protected deletions never stage mutations" protected_page_deletion_never_stages_mutations; case "page deletion is optimistic durable and rejects built-ins" page_deletion_is_optimistic_durable_and_rejects_built_ins; case "flashcard review is one atomic millisecond operation" diff --git a/shared/test/logseq/node_property_test.ml b/shared/test/logseq/node_property_test.ml index 625515ab..f1eac941 100644 --- a/shared/test/logseq/node_property_test.ml +++ b/shared/test/logseq/node_property_test.ml @@ -346,6 +346,36 @@ let page_rename_updates_lookup_search_and_references () = let protected_session, _ = R.make_session protected in check_eq ~msg:"built-in pages do not expose Rename page" (member "rename" (member "nodeProperties" (result (read protected_session Q.page)))) `Null) +let builtin_pages_hide_delete_and_reject_deletion_requests () = + let base = R.fixture () in + List.iter (fun (label, additions, allowed) -> + let db = Ds.db_with additions base in + let session, _ = R.make_session db in + let properties = member "nodeProperties" (result (read session Q.page)) in + check_eq ~msg:(label ^ " projects delete capability") (member "canDelete" properties) (`Bool allowed); + List.iter (fun backend -> + let response = J.to_string (`Assoc ["ok", `Bool true; "result", `Assoc [ + "selectedGraphId", `String "plain-1"; + "selectedPage", `Assoc ["uuid", `String Q.page; "title", `String "Workspace"]; + "nodeProperties", properties; "outlinerRows", `List []]]) in + Q.with_ui backend response (fun app -> + let host = Lui_app.runtime app in + let item = App_test.descendant_with_identifier host (Lui_app.root_node app) "button.page.delete" in + check_eq ~msg:(label ^ " menu delete visibility") (item <> -1) allowed; + let requested = Model.update (App.model app) Model.RequestDeleteActivePage in + check_eq ~msg:(label ^ " cannot open a protected delete confirmation") + (requested.pending_page_deletion <> None) allowed; + if not allowed then ( + let stale = { requested with Model.pending_page_deletion = Some {Model.uuid = Q.page; title = "Workspace"} } in + let confirmed = Model.update stale Model.ConfirmDeleteActivePage in + check ~msg:"a stale confirmation cannot enqueue a protected deletion" + (not (List.exists (function Model.DeletePageEffect _ -> true | _ -> false) confirmed.pending_effects))))) + [App_test.ios_backend; App_test.kotlin_backend]) + ["ordinary page", [], true; + "built-in page", [put Q.page "logseq.property/built-in?" (Ds.Bool true)], false; + "built-in property without flag", [put Q.page "db/ident" (Ds.Keyword "logseq.property/protected-test")], false; + "hidden system page", [put Q.page "logseq.property/hide?" (Ds.Bool true)], false] + let native_page_rename_uses_shared_editor () = R.with_runtime (fun session _ _ -> let node_properties = member "nodeProperties" (result (read session Q.page)) in @@ -378,6 +408,7 @@ let native_page_rename_uses_shared_editor () = [App_test.ios_backend; App_test.kotlin_backend]) let cases = [ + case "built-in page menus hide Delete and reject stale requests" builtin_pages_hide_delete_and_reject_deletion_requests; case "Rename page uses the protected shared native editor" native_page_rename_uses_shared_editor; case "page rename guards match master names, built-ins and duplicate scopes" page_rename_guards_follow_master; case "page rename updates lookup, live search, references and sync" page_rename_updates_lookup_search_and_references; From e302dbba075b99b787cfa1270e13fedc91ed4890 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:30:18 +0800 Subject: [PATCH 21/25] Record final native focus checks and iPhone installation --- docs/agent-guide/016-mobile-interaction-regressions.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 2720f8d2..079f25de 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -234,3 +234,7 @@ Archive metadata and the original binary's build-version command confirm SDK 26. The combined focus, deletion and sidebar changes pass the full core gate: `/tmp/logseq-focus-builtin-core-final.log`, 1045 tests in 66.323s. This includes matching iOS/Android node-icon extension fingerprints, protected delete capability/menu checks and unchanged ordinary-page deletion. The final rebuilt iOS simulator passes all six native checks: visible editor ownership, search-result/link caret taps, protected page menu/rename failure-retry, sidebar node icons, editor/image position retention and nested/search/Recent selection actions. Logs use `/tmp/logseq-focus-final-.log`. The sidebar screenshot was inspected: custom monochrome glyphs match the native document/control secondary gray while content titles retain their normal color. + +The final Android app/test APKs rebuild successfully, and the actual protected-page overflow menu plus command/reference/tag completion and paste/failure recovery pass all five instrumentation cases: `/tmp/logseq-focus-builtin-android-final-checks.log`, 34.118s. + +At 05:29, iPhone build `1792704538` (2.0.2, `LogseqRevision=63b10d4d`) was installed over the existing app and launched successfully; process `24324` was verified alive. The regular app was not uninstalled and graph data was not reset. This includes the search-destination caret fix, built-in Delete omission, subdued sidebar glyphs and semantic search surface. Evidence: `/tmp/logseq-focus-builtin-device-{build.log,install.json,launch.json,processes.json}`. From 72ae7c22d7f4efb3865598e8754f2ce858cd7e0c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:58:28 +0800 Subject: [PATCH 22/25] Fix native command form focus and outliner view controls --- .../logseq/app/AndroidEditorCompletionTest.kt | 16 ++++ .../logseq/app/AndroidQueryResultViewsTest.kt | 19 +++++ .../app/ui/extensions/LogseqExtensions.kt | 3 +- .../app/ui/extensions/OutlinerEditor.kt | 3 +- apple/Package.resolved | 2 +- apple/Package.swift | 2 +- .../Logseq/LGOutlinerEditorExtension.swift | 4 +- .../Sources/Logseq/OutlinerInlineEditor.swift | 13 ++- .../Tests/UI/editor-command-form-focus.swift | 84 +++++++++++++++++++ apple/Tests/UI/node-icons.swift | 14 ++++ apple/Tests/UI/object-pages.swift | 13 +++ apple/Tests/UI/query-block-results.swift | 7 +- apple/Tests/UI/run-simulator-runtime-check.py | 3 +- .../016-mobile-interaction-regressions.md | 15 ++++ logseq.opam | 2 +- shared/src/logseq/core/node_presentation.ml | 5 +- shared/src/logseq/model.ml | 1 + shared/src/logseq/model.mli | 1 + shared/src/logseq/response_snapshot.ml | 1 + shared/src/logseq/view.ml | 1 + shared/src/logseq/view_base.ml | 4 + shared/src/logseq/view_base.mli | 1 + shared/src/logseq/view_outliner.ml | 15 ++-- shared/src/logseq/view_query.ml | 47 +++++++---- shared/test/logseq/app_test.ml | 29 ++++++- shared/test/logseq/editor_command_test.ml | 25 ++++++ shared/test/logseq/query_block_test.ml | 4 +- shared/test/logseq/result_view_test.ml | 2 +- 28 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 apple/Tests/UI/editor-command-form-focus.swift diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt index ffe85852..e2954504 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorCompletionTest.kt @@ -109,6 +109,22 @@ class AndroidEditorCompletionTest { } } + @Test fun linkAndImageFormsKeepNativeInputFocusAcrossSnapshots() { + start("/", "link") + for (kind in listOf("link", "image")) { + ui.runOnIdle { bridge.applySnapshot(snapshot(kind, "/")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("field.outliner.autocomplete.value").assertIsDisplayed().performClick().performTextInput("https://example.com/image.png") + ui.runOnIdle { bridge.applySnapshot(snapshot(kind, "/")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("field.outliner.autocomplete.value").assertIsFocused() + ui.onNodeWithTag("field.outliner.autocomplete.label").performClick().performTextInput("A useful label") + ui.runOnIdle { bridge.applySnapshot(snapshot(kind, "/")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("field.outliner.autocomplete.label").assertIsFocused() + ui.onNodeWithTag("button.outliner.autocomplete.dismiss").performClick() + ui.runOnIdle { bridge.applySnapshot(snapshot(null, "/")).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.onNodeWithTag("field.outliner.block.editing").assertIsFocused() + } + } + @Test fun nativePasteCarriesItsOriginAcrossTheBridge() = checkNativePaste(null) @Test fun nativeRichPasteKeepsTheBlockSnapshot() = checkNativePaste("{:graph \"demo\" :op :copy :blocks []}") diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt index 3fde5dc0..017761a3 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidQueryResultViewsTest.kt @@ -92,6 +92,25 @@ class AndroidQueryResultViewsTest { private var selectedView = "list" + @Test fun allViewSelectorAlignsLeadingAndKeepsAdaptiveWidth() { + mount { result -> + result.put("selectedPage", JSONObject().put("uuid", "collection").put("title", "Collection")) + result.put("selectedPageIsTag", true) + result.put("resultViews", JSONObject().put("ownerUuid", "collection").put("featureType", "tagged") + .put("views", JSONArray().put(JSONObject().put("uuid", "all").put("name", "All") + .put("type", "list").put("rows", JSONArray())))) + } + val selector = ui.onNodeWithTag("view.select.collection").assertIsDisplayed() + val label = ui.onNodeWithText("All", useUnmergedTree = true).assertIsDisplayed() + val controlBounds = selector.getUnclippedBoundsInRoot() + val textBounds = label.getUnclippedBoundsInRoot() + assertTrue("All must align with the native selector's leading edge", + kotlin.math.abs(controlBounds.left.value - textBounds.left.value) <= 1f) + assertTrue("The selector must retain an adaptive width", (controlBounds.right - controlBounds.left).value < 100f) + selector.performClick() + ui.onNodeWithTag("view.tab.all").assertIsDisplayed() + } + private fun selectView(name: String) { ui.onNodeWithTag("view.type.$selectedView").performClick() ui.onNodeWithText("Saved views").performClick() diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt index 01a6c9da..d4d75b16 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt @@ -57,7 +57,7 @@ import dev.lui.LuiExtensionValueKind private const val EDITOR_FINGERPRINT = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin," + "ios/swiftui|standard-children:0|children:|" + - "properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:" + + "properties:12:input-policy:string:required:none,13:focus-enabled:bool:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:" + "required:none,8:block-id:string:required:none|events:11:text-change[" + "18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[" + "18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:" + @@ -105,6 +105,7 @@ fun logseqExtensionRegistry( LuiExtensionProperty("caret-utf16-offset", LuiExtensionValueKind.INTEGER), LuiExtensionProperty("selection-revision", LuiExtensionValueKind.INTEGER), LuiExtensionProperty("input-policy", LuiExtensionValueKind.STRING), + LuiExtensionProperty("focus-enabled", LuiExtensionValueKind.BOOLEAN, required = true), LuiExtensionProperty("title", LuiExtensionValueKind.STRING), LuiExtensionProperty("block-id", LuiExtensionValueKind.STRING), ), diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt index 61e93cba..ce4b75d6 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt @@ -58,6 +58,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val modelCaret = context.int("caret-utf16-offset") ?: -1 val selectionRevision = context.int("selection-revision") ?: 0 val inputPolicy = context.string("input-policy") ?: "{}" + val focusEnabled = context.bool("focus-enabled") == true val multiline = remember(inputPolicy) { runCatching { org.json.JSONObject(inputPolicy).optBoolean("multiline") }.getOrDefault(false) } val focusRequester = remember { FocusRequester() } @@ -148,7 +149,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(focusEnabled) { if (focusEnabled) focusRequester.requestFocus() } // Debounced text-change emission, cancelled by the next edit. LaunchedEffect(debounceSeq) { if (debounceSeq == 0 || awaitingHandoff || value.composition != null) return@LaunchedEffect diff --git a/apple/Package.resolved b/apple/Package.resolved index 0c57d70b..6dde8c9f 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" : "df9b11ce1f7316afa86ac60e61dbb6985786a1b7" + "revision" : "ebfc32735f3647a825e9daa558dab28b70ae2503" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 294645ae..772ae9e8 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: "df9b11ce1f7316afa86ac60e61dbb6985786a1b7"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "ebfc32735f3647a825e9daa558dab28b70ae2503"), .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/apple/Sources/Logseq/LGOutlinerEditorExtension.swift b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift index 0353914e..a30b7c43 100644 --- a/apple/Sources/Logseq/LGOutlinerEditorExtension.swift +++ b/apple/Sources/Logseq/LGOutlinerEditorExtension.swift @@ -4,7 +4,7 @@ import SwiftUI @MainActor enum LGOutlinerEditorExtension { static let identifier = "outliner-editor" - static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + static let fingerprint = "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,13:focus-enabled:bool:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" static func register(in registry: LUIAppleExtensionRegistry) throws { try registry.register( @@ -17,6 +17,7 @@ enum LGOutlinerEditorExtension { .init(name: "caret-utf16-offset", kind: .int, isRequired: true), .init(name: "selection-revision", kind: .int, isRequired: true), .init(name: "input-policy", kind: .string, isRequired: true), + .init(name: "focus-enabled", kind: .bool, isRequired: true), ], events: [ .init(name: "text-change", fields: [ @@ -58,6 +59,7 @@ private struct LGOutlinerEditor: View { desiredCaretUTF16Offset: intProperty("caret-utf16-offset"), inputPolicy: stringProperty("input-policy"), selectionRevision: intProperty("selection-revision"), + focusEnabled: context.property("focus-enabled") == .bool(true), onTextChange: { title, caret in emit(name: "text-change", values: [ "title": .string(title), diff --git a/apple/Sources/Logseq/OutlinerInlineEditor.swift b/apple/Sources/Logseq/OutlinerInlineEditor.swift index 1af47080..06f5d6ff 100644 --- a/apple/Sources/Logseq/OutlinerInlineEditor.swift +++ b/apple/Sources/Logseq/OutlinerInlineEditor.swift @@ -9,6 +9,7 @@ struct OutlinerInlineEditor: View { let desiredCaretUTF16Offset: Int? var inputPolicy: String = "{}" var selectionRevision: Int = 0 + var focusEnabled: Bool = true let onTextChange: (String, Int) -> Void var onPaste: ((String, Int) -> Void)? = nil var onPasteContent: ((String) -> Void)? = nil @@ -29,6 +30,7 @@ struct OutlinerInlineEditor: View { desiredCaretUTF16Offset: desiredCaretUTF16Offset, inputPolicy: inputPolicy, selectionRevision: selectionRevision, + focusEnabled: focusEnabled, onTextChange: onTextChange, onPaste: onPaste, onPasteContent: onPasteContent, @@ -142,6 +144,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { let desiredCaretUTF16Offset: Int? var inputPolicy: String = "{}" var selectionRevision: Int = 0 + var focusEnabled: Bool = true let onTextChange: (String, Int) -> Void var onPaste: ((String, Int) -> Void)? = nil var onPasteContent: ((String) -> Void)? = nil @@ -178,7 +181,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { location: min(desiredCaretUTF16Offset ?? text.utf16.count, text.utf16.count), length: 0 ) - textView.requestFocusWhenAttached() + if focusEnabled { textView.requestFocusWhenAttached() } LGInteractionDiagnostics.editor(textView, blockID: blockID, event: "editor.make") return textView } @@ -265,7 +268,9 @@ private struct NativeOutlinerTextView: UIViewRepresentable { context.coordinator.activeBlockID = blockID } textView.accessibilityIdentifier = accessibilityIdentifier - if InlineEditorFocusPolicy.shouldRequestFocus( + if !focusEnabled { + (textView as? FocusRetainingTextView)?.cancelPendingFocus() + } else if InlineEditorFocusPolicy.shouldRequestFocus( isAttachedToWindow: textView.window != nil, isFirstResponder: textView.isFirstResponder ) { @@ -471,6 +476,10 @@ private final class FocusRetainingTextView: OutlinerLayoutTextView { } } + func cancelPendingFocus() { + focusPending = false + } + func requestFocusWhenAttached() { guard !isFirstResponder else { focusPending = false diff --git a/apple/Tests/UI/editor-command-form-focus.swift b/apple/Tests/UI/editor-command-form-focus.swift new file mode 100644 index 00000000..9e975325 --- /dev/null +++ b/apple/Tests/UI/editor-command-form-focus.swift @@ -0,0 +1,84 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend +@testable import Logseq + +@MainActor private final class CommandFormEffects: LGEffectExecuting { + var submissions: [String] = [] + func execute(_ effect: LGEffect) async -> LGEffectResolution { + if effect.kind == "editor-outliner-event" { submissions.append(effect.text) } + return .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct EditorCommandFormFocusCheck { + @MainActor static func main() async throws { + func snapshot(_ kind: String?) throws -> String { + var state: [String: Any] = ["editing": ["uuid": "editing", "title": "/", "caretUTF16Offset": 1]] + if let kind { state["autocomplete"] = ["kind": kind, "query": ""] } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + "selectedGraphId": "demo", "graphName": "Demo", "outlinerRows": [["depth": 0, + "block": ["uuid": "editing", "title": "/", "pageId": "page"]]], "outlinerState": state]]), as: UTF8.self) + } + let effects = CommandFormEffects() + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(snapshot(nil)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + let backend = runtime.renderer.backend + func descendants(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(descendants) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(descendants).first { backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier } + } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message) + } + try await wait("The original block must be editing") { + views(window).contains { ($0 as? UITextView)?.isFirstResponder == true } + } + let original = views(window).compactMap { $0 as? UITextView }.first { $0.isFirstResponder }! + for kind in ["link", "image"] { + try runtime.applyCoreResponse(snapshot(kind)) + try await wait("The command form must present") { window.rootViewController?.presentedViewController != nil } + try await Task.sleep(for: .milliseconds(500)) + guard let url = views(window).compactMap({ $0 as? UITextField }).first(where: { $0.placeholder == "https://example.com" }), + let label = views(window).compactMap({ $0 as? UITextField }).first(where: { $0.placeholder == "Label (optional)" }) else { + throw failure("The \(kind) form must contain native URL and label inputs") + } + for (field, value) in [(url, "https://example.com/image.png"), (label, "A useful label")] { + guard field.becomeFirstResponder() else { throw failure("The native \(kind) input must accept taps") } + field.insertText(value) + try runtime.applyCoreResponse(snapshot(kind)) + try await Task.sleep(for: .milliseconds(250)) + guard field.isFirstResponder, field.text == value, !original.isFirstResponder else { + throw failure("The \(kind) form lost input focus to the block during a core update") + } + } + try backend.performPress(node: find("button.outliner.autocomplete.submit")!) + await runtime.drainEffectsForTesting() + guard effects.submissions.contains(where: { $0.contains("https://example.com/image.png") && $0.contains("A useful label") }) else { + throw failure("Insert must submit both typed values") + } + try runtime.applyCoreResponse(snapshot(nil)) + try await wait("Closing the form must restore the original editor and cursor") { + window.rootViewController?.presentedViewController == nil && original.isFirstResponder + } + } + print("PASS: Link and Image link forms accept native URL/label input across updates and restore block focus") + } + static func failure(_ message: String) -> NSError { + NSError(domain: "CommandFormFocus", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/node-icons.swift b/apple/Tests/UI/node-icons.swift index 914bfb11..0d44a0c6 100644 --- a/apple/Tests/UI/node-icons.swift +++ b/apple/Tests/UI/node-icons.swift @@ -91,6 +91,20 @@ import CoreText try runtime.applyCoreResponse(String(decoding: JSONSerialization.data(withJSONObject: journal), as: UTF8.self)) try await Task.sleep(for: .milliseconds(500)) try screenshot("journal-icon.png") + let embedded: [String: Any] = ["ok": true, "result": ["selectedGraphId": "icons", "graphName": "Existing graph icons", + "selectedPage": ["uuid": "page", "title": "Embedded objects"], + "outlinerRows": [["depth": 0, "block": ["uuid": "embed", "title": "Embedded object", "pageId": "page", + "isEmbed": true, "listType": "number", "listNumber": "3."]]]]] + try runtime.applyCoreResponse(String(decoding: JSONSerialization.data(withJSONObject: embedded), as: UTF8.self)) + try await Task.sleep(for: .milliseconds(500)) + let arrow = try JSONDecoder().decode(NodeIconPayload.self, from: Data(#"{"type":"tabler-icon","id":"arrow-narrow-right"}"#.utf8)) + guard let bullet = views(window).compactMap({ $0 as? UIButton }).first(where: { + $0.accessibilityIdentifier == "button.outliner.zoom.embed.Embedded object" + }), let expected = NodeIconExtension.image(arrow, size: 18, traits: bullet.traitCollection), + bullet.image(for: .normal)?.pngData() == expected.pngData() else { + throw failure("Embedded objects must visibly use the master arrow glyph instead of a bullet or list number") + } + try screenshot("embedded-object-arrow.png") print("PASS: existing graph icons and colors render in page, favorites, recents, native text and rich rows; removing icons resets reused rows") } static func failure(_ message: String) -> NSError { diff --git a/apple/Tests/UI/object-pages.swift b/apple/Tests/UI/object-pages.swift index 0f55f676..8536bbec 100644 --- a/apple/Tests/UI/object-pages.swift +++ b/apple/Tests/UI/object-pages.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import Vision @testable import LUIAppleBackend @testable import Logseq @@ -55,6 +56,18 @@ import UIKit window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("objects-" + kind + ".png")) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: image.cgImage!).perform([request]) + guard let text = request.results?.first(where: { $0.topCandidates(1).first?.string == "All" }) else { + throw NSError(domain: "ObjectPages", code: 3, userInfo: [NSLocalizedDescriptionKey: "The native view selector must visibly render All"]) + } + let x = text.boundingBox.minX * window.bounds.width + let leading = window.safeAreaInsets.left + 16 + guard abs(x - leading) <= 3 else { + throw NSError(domain: "ObjectPages", code: 4, userInfo: [NSLocalizedDescriptionKey: + "All must align with the selector's leading edge: text=\(x), heading=\(leading)"]) + } } print("PASS: tag/property pages hide blocks and placeholders, ordinary pages retain them, and objects stay mounted") } diff --git a/apple/Tests/UI/query-block-results.swift b/apple/Tests/UI/query-block-results.swift index af5f47ef..56cc1bd9 100644 --- a/apple/Tests/UI/query-block-results.swift +++ b/apple/Tests/UI/query-block-results.swift @@ -123,10 +123,13 @@ import Vision find("query.result.query-owner.2") != nil } func verifyDisclosure(expanded: Bool, screenshot: String) async throws { - guard let toggle = find("query.toggle.query-owner"), - let icon = backend.model(id: toggle)?.property(.icon)?.stringValue else { + guard let iconID = find("query.disclosure.query-owner"), let model = backend.model(id: iconID) else { throw failure("Results must expose a disclosure icon") } + let icon = model.iconName + guard model.iconWidth == 10 && model.iconHeight == 10 else { + throw failure("Query disclosure must match the block's 10pt glyph") + } let symbol = expanded ? "arrowtriangle.down.fill" : "arrowtriangle.right.fill" guard backend.iconSource(for: icon) == .systemName(symbol), UIImage(systemName: symbol) != nil else { throw failure("Results must resolve the same native triangle as blocks: \(icon), expanded=\(expanded)") diff --git a/apple/Tests/UI/run-simulator-runtime-check.py b/apple/Tests/UI/run-simulator-runtime-check.py index a934b416..078ea77d 100644 --- a/apple/Tests/UI/run-simulator-runtime-check.py +++ b/apple/Tests/UI/run-simulator-runtime-check.py @@ -128,7 +128,8 @@ def main(): return try: output = subprocess.run(["xcrun", "simctl", "launch", "--console", "--terminate-running-process", - SIMULATOR, bundle], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180 if testing else (90 if benchmark else 30), env=launch_environment) + SIMULATOR, bundle], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + timeout=180 if testing else (90 if benchmark else int(os.environ.get("LOGSEQ_RUNTIME_CHECK_TIMEOUT", "30"))), env=launch_environment) print(output.stdout) if "RUNTIME_CHECK_COMPLETE" not in output.stdout: raise SystemExit("Simulator runtime check did not complete") diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 079f25de..239e2d66 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -238,3 +238,18 @@ The final rebuilt iOS simulator passes all six native checks: visible editor own The final Android app/test APKs rebuild successfully, and the actual protected-page overflow menu plus command/reference/tag completion and paste/failure recovery pass all five instrumentation cases: `/tmp/logseq-focus-builtin-android-final-checks.log`, 34.118s. At 05:29, iPhone build `1792704538` (2.0.2, `LogseqRevision=63b10d4d`) was installed over the existing app and launched successfully; process `24324` was verified alive. The regular app was not uninstalled and graph data was not reset. This includes the search-destination caret fix, built-in Delete omission, subdued sidebar glyphs and semantic search surface. Evidence: `/tmp/logseq-focus-builtin-device-{build.log,install.json,launch.json,processes.json}`. + + +### Embed, command-form and view-control follow-up + +Embedded nodes now carry an explicit presentation marker from their `block/link` source relation. Outliner rows reuse the existing node-icon renderer for the master `arrow-narrow-right` glyph, including ordered-list embeds; ordinary nodes retain their own icon/number. The existing wrapper-only deletion test remains in the gate. The new core integration first fails without the embed marker and then passes both native row profiles (`/tmp/logseq-embed-arrow-core-red.log`, `/tmp/logseq-form-embed-core-green.log`). The iOS rendered-arrow snapshot and image comparison pass in `/tmp/logseq-native-menu-final/NodeIconsCheck`. + +Link/Image link forms compete with the mounted block editor when it unconditionally requests first responder on updates. The shared extension now explicitly transfers focus ownership to picker inputs while keeping the block editor mounted. Native URL and label inputs accept edits across snapshot acknowledgements, submit both values, and return focus to the retained editor on dismissal. Command/reference/tag completion continues to own the block editor. Core focus policy is covered by a failing-then-passing integration check; native iOS form, editor and search/link caret checks pass. Android completion/form/paste/failure-recovery cases also pass (five cases in `/tmp/logseq-menu-forms-android-native.log`; the separate All test in that initial combined run exposed the upstream validation omission described below). + +Query result disclosure now uses a separately sized child icon: the existing filled triangle at 10 points on iOS and the platform block chevron at 24 dp on Android. The complete iOS query/view workflow passes, including both disclosure states, cell editing, filters, sort, gallery, search themes and contained errors (`/tmp/logseq-query-menu-final.log`). Its multi-appearance workflow exceeded the old 30-second runtime-check limit under concurrent compilation; the runner now accepts an optional timeout, and the successful run used 60 seconds. + +The view selector opts into leading text alignment and retains native adaptive sizing and its touch region. LUI now admits this property in the shared/native schemas and honors it in SwiftUI Menu and Material TextButton labels. Its Android restrictive-property value validator needed the corresponding semantic-value case as well. Protocol RED: `/tmp/lui-menu-alignment-schema-red.log`; GREEN: 20 protocol cases and 9 schema-generator checks. The standalone Android store suite passes 10 cases, including valid start/center/end values and rejection of invalid string/numeric values (`/tmp/lui-menu-alignment-android-unit.log`). The consumer pins LUI `ebfc3273`. iOS screenshot OCR verifies All at the content leading edge while switching ordinary/tag/property pages; Android verifies the actual label edge, adaptive width and opening the selector (`/tmp/logseq-all-leading-android-final.log`, one passing case). + +All six iOS native follow-up checks pass: object pages/alignment, link/image forms, the complete query workflow, actual embed glyph, visible editor focus, and search/link caret focus. Evidence is in `/tmp/logseq-native-menu-final` plus the query workflow rerun above. This follow-up does not establish a fix for the exact TestFlight AttributeGraph crash, which remains open. + +The final full core gate passes 1047 tests in 77.289 seconds (`/tmp/logseq-menu-forms-core-final.log`). diff --git a/logseq.opam b/logseq.opam index f2fd711e..b2ab1e6e 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#df9b11ce1f7316afa86ac60e61dbb6985786a1b7"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#ebfc32735f3647a825e9daa558dab28b70ae2503"] ["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/shared/src/logseq/core/node_presentation.ml b/shared/src/logseq/core/node_presentation.ml index 68f6e130..8099ae0b 100644 --- a/shared/src/logseq/core/node_presentation.ml +++ b/shared/src/logseq/core/node_presentation.ml @@ -105,9 +105,10 @@ let decorate db = | _ -> parsed in fun uuid encoded -> match Ds.entid db "block/uuid" (Ds.Uuid uuid), encoded with | Some eid, `Assoc fields -> - let decorated = Read.ref_eids db eid "block/link" <> [] + let embedded = Read.ref_eids db eid "block/link" <> [] in + let decorated = embedded || Read.value db eid "logseq.property/heading" <> None || Read.value db eid "logseq.property.code/lang" <> None in let fields = if decorated then ("markup", M.to_yojson (markup [] 16 eid)) :: List.remove_assoc "markup" fields else fields in - `Assoc (("taskDates", task_dates uuid eid) :: (match list_number eid with Some n -> ["listNumber", `Int n] | None -> []) @ fields) + `Assoc (("isEmbed", `Bool embedded) :: ("taskDates", task_dates uuid eid) :: (match list_number eid with Some n -> ["listNumber", `Int n] | None -> []) @ fields) | _ -> encoded diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index 41c329a8..73ce8798 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -124,6 +124,7 @@ type outline_row = ; row_task_dates_json : string ; row_list_number : int option ; row_display_type : string option + ; row_is_embed : bool ; markup_json : string ; query_json : string ; youtube_target_url : string option diff --git a/shared/src/logseq/model.mli b/shared/src/logseq/model.mli index a29718bc..98ee2f41 100644 --- a/shared/src/logseq/model.mli +++ b/shared/src/logseq/model.mli @@ -121,6 +121,7 @@ type outline_row = ; row_task_dates_json : string ; row_list_number : int option ; row_display_type : string option + ; row_is_embed : bool ; markup_json : string ; query_json : string ; youtube_target_url : string option diff --git a/shared/src/logseq/response_snapshot.ml b/shared/src/logseq/response_snapshot.ml index 508e98b2..dbc1ee28 100644 --- a/shared/src/logseq/response_snapshot.ml +++ b/shared/src/logseq/response_snapshot.ml @@ -242,6 +242,7 @@ let outline_row_from_block youtube_target_url opens_as_page depth { Model.row_uuid; row_title; row_task_dates_json = (match List.assoc_opt "taskDates" fields with Some (`Assoc _ as value) -> Yojson.Basic.to_string value | _ -> ""); row_list_number = int_member "listNumber" fields; + row_is_embed = bool_member "isEmbed" fields; row_display_type = string_member "displayType" fields; row_loaded = not (bool_member "isShell" fields); row_cached = false; query_json = (match List.assoc_opt "query" fields with diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index 586c6f98..d19262ac 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -49,6 +49,7 @@ let outliner_editor_schema () = Lui_extension.property "caret-utf16-offset" IntScalar true None; Lui_extension.property "selection-revision" IntScalar true None; Lui_extension.property "input-policy" StringScalar true None; + Lui_extension.property "focus-enabled" BoolScalar true None; ] [ Lui_extension.event "text-change" diff --git a/shared/src/logseq/view_base.ml b/shared/src/logseq/view_base.ml index 541a1d57..0e07f099 100644 --- a/shared/src/logseq/view_base.ml +++ b/shared/src/logseq/view_base.ml @@ -3,6 +3,10 @@ open Lui_protocol let node_icon (current : Model.logseq_model) uuid = Option.value ~default:"" (String_map.find_opt uuid current.node_icons) +let outliner_node_icon current (row : Model.outline_row) = + if row.row_is_embed then {|{"type":"tabler-icon","id":"arrow-narrow-right"}|} + else node_icon current row.row_uuid + let node_icon_view ?(muted = false) ~size source : Lui_elements.t = Lui_elements.if_ ~test:(Signal.map (fun value -> value <> "") source) (fun context parent -> diff --git a/shared/src/logseq/view_base.mli b/shared/src/logseq/view_base.mli index 7ef99ddf..8475081a 100644 --- a/shared/src/logseq/view_base.mli +++ b/shared/src/logseq/view_base.mli @@ -1,3 +1,4 @@ +val outliner_node_icon : Model.logseq_model -> Model.outline_row -> string val node_icon : Model.logseq_model -> string -> string val node_icon_view : ?muted:bool -> size:int -> string Signal.signal -> Lui_elements.t val string_wire_value : string -> Lui_protocol.wire_value diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 956773a8..2f20d4f1 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -12,6 +12,11 @@ let outliner_editor_view model_source block_id_source title_source caret_source && List.mem row.row_display_type [Some "code"; Some "math"]) current.outliner_rows in StringValue (if multiline then {|{"multiline":true,"emptyPairs":[{"text":"[[]]","deleteOffset":1}]}|} else Outliner_state.native_input_policy)) model_source block_id_source); + Lui_ui.extension_property_signal context node "focus-enabled" + (Signal.map (fun (current : Model.logseq_model) -> BoolValue + (match current.outliner_autocomplete with + | Some {autocomplete_kind = Model.PickerAutocomplete _; _} -> false + | _ -> true)) model_source); Lui_ui.extension_property_signal context node "selection-revision" (Signal.map (fun (current : Model.logseq_model) -> IntValue current.outliner_selection_revision) model_source); @@ -171,15 +176,15 @@ let outliner_zoom_control (context : Lui_ui.ui_context) model_source [ row ~width:24 ~height:24 ~main:`center ~cross:`center [ - if_ ~test:(Signal.map (fun (row : Model.outline_row) -> row.row_list_number <> None) row_source) + if_ ~test:(Signal.map (fun (row : Model.outline_row) -> row.row_list_number <> None && not row.row_is_embed) row_source) (text ~value_signal:(Signal.map (fun (row : Model.outline_row) -> Option.fold ~none:"" ~some:(fun n -> string_of_int n ^ ".") row.row_list_number) row_source) ~font_size:"14" ~foreground:"muted-foreground" ~accessibility_identifier:("outliner.number." ^ View_base.outliner_row_uuid current_row) []); View_base.node_icon_view ~size:18 (Signal.map2 (fun current (row : Model.outline_row) -> - View_base.node_icon current row.row_uuid) model_source row_source); + View_base.outliner_node_icon current row) model_source row_source); if_ ~test:(Signal.map2 (fun current (row : Model.outline_row) -> - View_base.node_icon current row.row_uuid = "" && row.row_list_number = None) model_source row_source) + View_base.outliner_node_icon current row = "" && row.row_list_number = None) model_source row_source) (icon ~name_signal:(Signal.map (fun (row : Model.outline_row) -> `app (if row.is_collapsed && row.has_children then "outliner-bullet-closed" else "outliner-bullet")) row_source) ~width:24 ~height:24 ~foreground:"border" @@ -438,8 +443,8 @@ let native_outliner_row model_source retained_source row_source send : t = let row = View_rows.retained_row_value retained in Yojson.Basic.to_string (`Assoc [ "uuid", `String row.row_uuid; "title", `String row.row_title; - "listNumber", (match row.row_list_number with Some n -> `Int n | None -> `Null); - "nodeIcon", (let icon = View_base.node_icon current row.row_uuid in + "listNumber", (match (if row.row_is_embed then None else row.row_list_number) with Some n -> `Int n | None -> `Null); + "nodeIcon", (let icon = View_base.outliner_node_icon current row in if icon = "" then `Null else Yojson.Basic.from_string icon); "markup", (try Yojson.Basic.from_string row.markup_json with _ -> `List []); "depth", `Int row.depth; "hasChildren", `Bool row.has_children; diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 7bbaf218..5511d0fd 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -59,6 +59,16 @@ let result_item owner render_block send source : t = ~mount:(fun source -> render_block source send) | None -> text ~value_signal:(Signal.map (fun item -> item.title) source) ~text_alignment:`start [])]) context parent +let results_disclosure context identifier expanded label = + let size = if Lui_ui.host context = KotlinHost then 24 else 10 in + row ~gap:8 ~cross:`center [ + icon ~name_signal:(Signal.map (fun expanded -> + `app (if expanded then "disclosure-down" else "disclosure-right")) expanded) + ~width:size ~height:size ~foreground:"muted-foreground" + ~accessibility_identifier:identifier []; + text ~value_signal:label []; + ] + let legacy_view ~render_block _model_source row_source send : t = fun context parent -> let owner = (Signal.sample row_source).Model.row_uuid in @@ -78,10 +88,10 @@ let legacy_view ~render_block _model_source row_source send : t = ~accessibility_identifier:(identifier "block") [column ~gap:4 [ button ~variant:`ghost ~text_alignment:`start ~min_height:44 - ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "disclosure-down" else "disclosure-right")) (Signal.value expanded))) - ~text_signal:(own (Signal.map (fun query -> string_of_int (List.length query.rows) ^ " results") query)) ~label:"Toggle query results" ~accessibility_identifier:(identifier "toggle") - ~on_press:(fun _ -> Signal.set expanded (not (Signal.sample (Signal.value expanded)))) []; + ~on_press:(fun _ -> Signal.set expanded (not (Signal.sample (Signal.value expanded)))) + [results_disclosure context (identifier "disclosure") (Signal.value expanded) + (own (Signal.map (fun query -> string_of_int (List.length query.rows) ^ " results") query))]; if_ ~test:(Signal.value expanded) (column ~gap:4 ~accessibility_identifier:(identifier "rows") [ if_ ~test:(own (Signal.map (fun query -> query.error <> None) query)) @@ -558,18 +568,20 @@ let collection ~render_block model_source raw_source send : t = match List.find_opt (fun view -> view.id = id) views with Some view -> [view] | None -> list_take 1 views) views (Signal.value active)) in let results_toggle () = - button ~variant:`ghost ~text_alignment:`start ~grow:1.0 - ~accessibility_identifier:("query.toggle." ^ owner) - ~disabled_signal:(waiting model_source owner) - ~icon_signal:(own (Signal.map (fun expanded -> `app (if expanded then "disclosure-down" else "disclosure-right")) (Signal.value expanded))) - ~text_signal:(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") views (Signal.value expanded))) - ~on_press:(fun _ -> - let next = not (Signal.sample (Signal.value expanded)) in - if is_query_expansion (Signal.sample raw_source) then - ignore (send (Model.SubmitResultView (owner, payload owner feature "" "setExpanded" ["expanded", `Bool next]))) - else Signal.set expanded next) [] in + 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") + views (Signal.value expanded)) in + View_base.with_label_signal label + (button ~variant:`ghost ~text_alignment:`start ~grow:1.0 + ~accessibility_identifier:("query.toggle." ^ owner) + ~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 + 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 let mount view_source : t = fun context parent -> let own value = Signal.own_signal context.ui_scope value in @@ -635,13 +647,14 @@ let collection ~render_block model_source raw_source send : t = row ~gap:0 ~cross:`center ~accessibility_identifier:("view.toolbar." ^ owner) [ (if feature = "query-result" then results_toggle () else row ~grow:1.0 ~cross:`center [ - menu ~text_signal:(own (Signal.map (fun view -> view.name) view_source)) + View_base.with_string_prop TextAlignment "start" + (menu ~text_signal:(own (Signal.map (fun view -> view.name) view_source)) ~label:"Views" ~accessibility_identifier:("view.select." ^ owner) [ keyed ~source:views ~key:(fun view -> view.id) ~cmp:String.compare ~mount:(fun source -> let saved = Signal.sample source in menu_item ~text_signal:(own (Signal.map (fun view -> view.name) source)) ~selected:(saved.id = view.id) ~accessibility_identifier:("view.tab." ^ saved.id) - ~on_press:(fun _ -> Signal.set active saved.id) [])]; + ~on_press:(fun _ -> Signal.set active saved.id) [])]); box ~grow:1.0 []]); button ~icon:(`app "view-filter") ~label:"Filters" ~variant:`ghost ~width:40 ~height:44 ~accessibility_identifier:("view.filter." ^ view.id) ~on_press:(fun _ -> Signal.set settings "filter") []; diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index f6e032ef..fcd2585d 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -431,6 +431,7 @@ let journal_outline_row uuid page_id title journal_title journal_day depth = row_cached = false; row_task_dates_json = ""; row_list_number = None; row_display_type = None; + row_is_embed = false; row_title = title; markup_json = "[]"; query_json = ""; youtube_target_url = None; @@ -2041,7 +2042,7 @@ let authentication_screen_uses_the_product_name_on_every_host () = let outliner_editor_extension_contract_is_pinned () = check_eq ~msg:"the native editor registry must match the LG wire schema" (Lui_extension.fingerprint (View.outliner_editor_schema ())) - "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" + "lui-extension-v1|15:outliner-editor|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:12:input-policy:string:required:none,13:focus-enabled:bool:required:none,18:caret-utf16-offset:int:required:none,18:selection-revision:int:required:none,5:title:string:required:none,8:block-id:string:required:none|events:11:text-change[18:caret-utf16-offset:int:required,5:title:string:required],12:caret-change[18:caret-utf16-offset:int:required],13:paste-content[5:value:string:required],5:paste[18:caret-utf16-offset:int:required,5:title:string:required],6:return[18:caret-utf16-offset:int:required,5:title:string:required],9:backspace[16:selection-length:int:required,5:title:string:required]" let outliner_block_content_extension_contract_is_pinned () = check_eq ~msg:"the rich block renderer must match the LG wire schema" @@ -2768,6 +2769,7 @@ let outline_row uuid title = row_cached = false; row_task_dates_json = ""; row_list_number = None; row_display_type = None; + row_is_embed = false; row_title = title; markup_json = "[]"; query_json = ""; youtube_target_url = None; @@ -6427,6 +6429,30 @@ let kotlin_selected_pages_retain_the_hidden_journal_pane () = let list = descendant_with_identifier renderer journal "list.outliner" in check ~msg:"the retained journal is not placed or interactive" (not (property_bool renderer list Lui_protocol.Selected)) +let editor_pickers_own_focus_without_unmounting_the_block () = + List.iter (fun backend -> + let app = App.create backend in + start app; + let editing = {Model.editing_uuid = "focus-block"; editing_title = "/"; caret_utf16_offset = 1} in + let row = journal_outline_row "focus-block" "journal" "/" "Today" 20261023 0 in + List.iter (fun (kind, enabled) -> + send app (Model.ApplyCoreSnapshot { (empty_core_projection ()) with + graph_name = Some "Work"; selected_graph_id = Some "test-graph"; + outliner_rows = [row]; journal_outliner_rows = [row]; + projection_outliner_editing = Some editing; + projection_outliner_autocomplete = Option.map (fun autocomplete_kind -> + {Model.autocomplete_kind; autocomplete_query = ""}) kind }); + flush app; + let editor = extension_node app "outliner-editor" in + check ~msg:"opening a picker retains the underlying editor" (editor <> -1); + check_eq ~msg:"picker inputs own focus while inline suggestions keep block focus" + (extension_property app editor "focus-enabled") (Some (Lui_protocol.BoolValue enabled))) + ([None, true; Some Model.CommandAutocomplete, true; Some Model.NodeAutocomplete, true; + Some Model.TagAutocomplete, true; Some Model.PropertyAutocomplete, true] + @ List.map (fun kind -> Some (Model.PickerAutocomplete kind), false) + ["link"; "image"; "embed"; "template"; "date"; "deadline"; "scheduled"; "youtube"])) + [ios_backend (); kotlin_backend ()] + let retained_panes_mount_only_the_visible_editor () = List.iter (fun backend -> let application = App.create backend in @@ -10549,6 +10575,7 @@ let cases = case "kotlin-navigation-and-search-own-composed-standard-children" kotlin_navigation_and_search_own_composed_standard_children; case "kotlin-navigation-renders-each-active-node-row-once" kotlin_navigation_renders_each_active_node_row_once; case "kotlin-selected-pages-retain-the-hidden-journal-pane" kotlin_selected_pages_retain_the_hidden_journal_pane; + case "editor-pickers-own-focus-without-unmounting-the-block" editor_pickers_own_focus_without_unmounting_the_block; case "retained-panes-mount-only-the-visible-editor" retained_panes_mount_only_the_visible_editor; case "kotlin-selected-page-navigation-renders-the-opened-node" kotlin_selected_page_navigation_renders_the_opened_node; case "native-navigation-retains-the-journal-and-every-node-route" native_navigation_retains_the_journal_and_every_node_route; diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 130ddb17..fc113106 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -210,6 +210,30 @@ let command_output_has_native_presentation () = R.with_runtime (fun session runt check ~msg:"embedded nodes render their source content" (String_kit.includes ~sub:"Beta result" markup); check_eq ~msg:"rendered embed identity stays the wrapper UUID" (member "uuid" (block ())) (`String Q.alpha)) +let embedded_outliner_bullets_match_master () = R.with_runtime (fun session _ _ -> + ignore (run session "Number list"); save session; + ignore (run session "Node embed"); ignore (choose session Q.beta); + let response = snapshot session in + let embedded = List.find (fun block -> member "uuid" block = `String Q.alpha) (items "blocks" response) in + check_eq ~msg:"only linked wrapper rows carry the embed marker" (member "isEmbed" embedded) (`Bool true); + let ordinary = List.find (fun block -> member "uuid" block = `String Q.beta) (items "blocks" response) in + check ~msg:"source blocks retain their ordinary bullet" (member "isEmbed" ordinary <> `Bool true); + List.iter (fun (backend, ios) -> Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let row = App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.block." ^ Q.alpha) in + check ~msg:"embedded wrapper is present in the outliner" (row <> -1); + let descriptor = if ios then ( + let payload = App_test.native_row_payload app row in + check_eq ~msg:"embed arrows take precedence over numbered bullets" (member "listNumber" payload) `Null; + member "nodeIcon" payload) + else ( + let icon = App_test.descendant_with_extension host app row "node-icon" in + match App_test.extension_property app icon "payload" with + | Some (Lui_protocol.StringValue raw) -> J.from_string raw + | _ -> `Null) in + check_eq ~msg:"both platforms render master's arrow-narrow-right" (member "id" descriptor) (`String "arrow-narrow-right"))) + [App_test.ios_backend, true; App_test.kotlin_backend, false]) + let embedding_in_text_preserves_the_original_block () = R.with_runtime (fun session runtime _ -> ignore (start session "Keep this /"); ignore (command session "Node embed"); @@ -549,6 +573,7 @@ let task_dates_are_visible_and_editable_in_the_outline () = [App_test.ios_backend; App_test.kotlin_backend] let cases = [ + case "embedded outliner bullets use the master arrow" embedded_outliner_bullets_match_master; case "editing hides placeholders but keeps journal headings" placeholders_hide_while_editing_and_keep_empty_journal_headings; case "Backspace keeps the caret at the merge boundary" backspace_at_start_keeps_the_caret_at_the_merge_boundary; case "Enter on a parent matches master placement" enter_on_a_parent_matches_master_placement; diff --git a/shared/test/logseq/query_block_test.ml b/shared/test/logseq/query_block_test.ml index edc38e21..7cd876eb 100644 --- a/shared/test/logseq/query_block_test.ml +++ b/shared/test/logseq/query_block_test.ml @@ -202,10 +202,10 @@ let ui_states_and_disclosure () = let fold = find app "toggle" in check (fold <> -1); check_eq ~msg:"expanded results use the block disclosure icon" - (App_test.property_string (Lui_app.runtime app) fold Lui_protocol.InlineIconName) "app:disclosure-down"; + (App_test.property_string (Lui_app.runtime app) (find app "disclosure") Lui_protocol.IconName) "app:disclosure-down"; App_test.dispatch app (Lui_protocol.Press fold); App_test.flush app; check_eq ~msg:"collapsed results use the block disclosure icon" - (App_test.property_string (Lui_app.runtime app) fold Lui_protocol.InlineIconName) "app:disclosure-right"; + (App_test.property_string (Lui_app.runtime app) (find app "disclosure") Lui_protocol.IconName) "app:disclosure-right"; check_eq (find app "rows") (-1))) [App_test.ios_backend; App_test.kotlin_backend] diff --git a/shared/test/logseq/result_view_test.ml b/shared/test/logseq/result_view_test.ml index 1fd80d80..4fddef8d 100644 --- a/shared/test/logseq/result_view_test.ml +++ b/shared/test/logseq/result_view_test.ml @@ -612,7 +612,7 @@ let native_toolbar_alignment_and_search_expansion () = ["query.toggle." ^ owner; "view.filter." ^ list_view; "view.sort." ^ list_view; "view.search.toggle." ^ owner; "view.type." ^ list_view]; let assert_disclosure expected = check_eq ~msg:"collection results reuse the block disclosure icon" - (App_test.property_string host (find ("query.toggle." ^ owner)) Lui_protocol.InlineIconName) expected in + (App_test.property_string host (find ("query.disclosure." ^ owner)) Lui_protocol.IconName) expected in assert_disclosure "app:disclosure-down"; check_eq ~msg:"search expands only when requested" (find ("view.search." ^ owner)) (-1); click ("view.search.toggle." ^ owner); From 9531c0d75cb21d684a7d918bb13379d023adfe34 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 06:02:42 +0800 Subject: [PATCH 23/25] Pin Android custom button label validation and record native checks --- apple/Package.resolved | 2 +- apple/Package.swift | 2 +- docs/agent-guide/016-mobile-interaction-regressions.md | 7 +++++++ logseq.opam | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apple/Package.resolved b/apple/Package.resolved index 6dde8c9f..5e7a93eb 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" : "ebfc32735f3647a825e9daa558dab28b70ae2503" + "revision" : "4281a5160ca9833d3b71901071dcb40228dcc36d" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 772ae9e8..f1159b97 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: "ebfc32735f3647a825e9daa558dab28b70ae2503"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "4281a5160ca9833d3b71901071dcb40228dcc36d"), .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/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 239e2d66..5cc6df2e 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -253,3 +253,10 @@ The view selector opts into leading text alignment and retains native adaptive s All six iOS native follow-up checks pass: object pages/alignment, link/image forms, the complete query workflow, actual embed glyph, visible editor focus, and search/link caret focus. Evidence is in `/tmp/logseq-native-menu-final` plus the query workflow rerun above. This follow-up does not establish a fix for the exact TestFlight AttributeGraph crash, which remains open. The final full core gate passes 1047 tests in 77.289 seconds (`/tmp/logseq-menu-forms-core-final.log`). + + +The expanded Android query/view suite exposed a second upstream validation mismatch: the renderer and shared protocol support button content, but the Android retained tree treated every button as a metadata-only interactive leaf. Exempt buttons/toggle buttons from that leaf-only restriction while retaining it for text fields. A new real-tree regression first fails with the same error as the consumer, then all 12 Android store cases pass (`/tmp/lui-button-labels-android-{red,green}.log`). LUI `4281a516` contains the fix; its Apple and OCaml sources are unchanged from the already validated `ebfc3273` pin. + +iPhone build `1792706378` (2.0.2, revision `72ae7c22`, LUI `ebfc3273`) was installed over the existing app and launched; process `24529` was verified alive. It contains the leading view selector, native command-form focus ownership, master embed arrow and block-sized query triangles. The later upstream follow-up changes Android validation/tests only. Evidence: `/tmp/logseq-view-controls-device-{build.log,install.json,launch.json,processes.json}`. Graph data was preserved. + +The final rebuilt Android consumer passes all eight query/view instrumentation cases in 33.96 seconds (`/tmp/logseq-query-controls-android-final.log`), including the collapsed-query button label, selector bounds/menu, priority popup, saved views, object pages, table edits, filter/sort sheets, gallery, search and error containment. The upstream store suite passes all 12 cases. diff --git a/logseq.opam b/logseq.opam index b2ab1e6e..61258471 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#ebfc32735f3647a825e9daa558dab28b70ae2503"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#4281a5160ca9833d3b71901071dcb40228dcc36d"] ["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"] From 7ea93ad80bd687d6164b8d78440b87d30501ec04 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 06:20:28 +0800 Subject: [PATCH 24/25] Record single-window background crash controls and device limits --- .../016-mobile-interaction-regressions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index 5cc6df2e..a2bc615c 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -260,3 +260,16 @@ The expanded Android query/view suite exposed a second upstream validation misma iPhone build `1792706378` (2.0.2, revision `72ae7c22`, LUI `ebfc3273`) was installed over the existing app and launched; process `24529` was verified alive. It contains the leading view selector, native command-form focus ownership, master embed arrow and block-sized query triangles. The later upstream follow-up changes Android validation/tests only. Evidence: `/tmp/logseq-view-controls-device-{build.log,install.json,launch.json,processes.json}`. Graph data was preserved. The final rebuilt Android consumer passes all eight query/view instrumentation cases in 33.96 seconds (`/tmp/logseq-query-controls-android-final.log`), including the collapsed-query button label, selector bounds/menu, priority popup, saved views, object pages, table edits, filter/sort sheets, gallery, search and error containment. The upstream store suite passes all 12 cases. + + +### Single-window lifecycle controls for the exact crash audit + +The preceding goal turn made product/test/installation progress. This follow-up replaces the earlier physical probe’s SwiftUI placeholder window plus second UIWindow with one app-owned UIWindow in a UIKit scene delegate. The test task belongs to the scene delegate, so replacing the hosting root does not cancel a SwiftUI `.task`. Every phase records application/scene activation state and monotonic time in the independent probe container. The binary links the complete prebuilt build-18 consumer/core/backend (`ce301717`, original LUI pin); snapshots and effects are synthetic. This is an isolated native runtime control, not the original SwiftUI App shell or a real authentication/network/storage bootstrap. + +The source/build manifests and controls are archived at `/tmp/logseq-lifecycle-probe-sources.zip` (21,677 bytes; SHA-256 `9b66a36820813ccf43278c05379bdf0cf6a1f18ef71ffa63f8bac11190307047`). Both manifests match the current four Swift source hashes. The prototype source/build helpers are in `/tmp/logseq-lifecycle-probe`. Builds use existing native objects and never invoke XCTest or modify the regular app’s graph container. Simulator launches used `simctl --checked-allocations`; this records the requested launch option, not proof that every allocator has an active diagnostic hook. + +On iOS 26, all four cold/late app/search restoration cases pass in the new lifecycle shell. The native animated hydration control also completes. Forcing that animation assertion while the scene is backgrounded instead fails its prerequisite (an animated push must start); this is not the reported EXC_BAD_ACCESS. The dedicated background control therefore validates real phase transitions, submits data/session updates while backgrounded, then verifies the final native UILabel content after foreground restoration. It runs three cases: already-loaded rows; no initial graph projection/graph-picker state; and the production-like initial `graph-loading=true` navigation shell before graph data arrives. Each submits ten row/depth changes and completes foreground recovery. Warm and loading-shell logs show actual native navigation depths 1/2/3 during background updates; the graph-picker case defers native navigation creation until foreground. These observations must not be conflated. Logs: `/tmp/logseq-lifecycle-background-{warm-control,cold-loading}.log`, `/tmp/logseq-lifecycle-background-cold-control.log`, `/tmp/logseq-lifecycle-simulator-restoration.log`, and merged lifecycle/diagnostic events in `/tmp/logseq-lifecycle-probe-events.log`. The final loading-shell rerun captures merged stdout/stderr and writes monotonic phase markers directly to stderr (`/tmp/logseq-lifecycle-probe/background-simulator-console.log`, `/tmp/logseq-lifecycle-background-console-control.log`). It completes in 8.7143 seconds without the allocation fault or a cycle warning in that console capture. The earlier phase/unified-log-only files cannot establish stderr-warning absence. These simulator cases launch in the foreground and are then backgrounded by the host after readiness; they are not no-activate background-only launches. + +The real-device background launch was rejected by SpringBoard with `FBSOpenApplicationErrorDomain` code 7: the device was locked and could not be unlocked. `/tmp/logseq-lifecycle-background-launch.json` contains the authoritative rejection; there is no physical probe execution or successful device assertion to report. An asynchronous unlock/availability question is pending. The latest independently signed probe is built and installed under `com.logseq.logseq.LifecycleProbe`, with its own container and no app-group entitlement; the regular installed build remains `1792706378`, and its process `24529` was still present during this audit. + +Device inventory also limits comparison: the connected phone is `iPhone15,4` (iPhone 15), iOS 27.2 build `24B5110a`; the exact report is `iPhone18,1`, iOS 27.0.1. Other listed devices are unavailable. Even after unlocking, a pass on the connected phone cannot by itself prove the original hardware/OS crash is fixed. The exact crash root cause and causal repair remain outstanding. The crash role/comment provide context, not a complete UIKit scene-activation timeline. From ba2ef1f93cf6465a4bc65a9619538d1d37f933df Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 06:38:32 +0800 Subject: [PATCH 25/25] Record physical recovery and mixed-content crash controls --- .../016-mobile-interaction-regressions.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index a2bc615c..d204235e 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -270,6 +270,20 @@ The source/build manifests and controls are archived at `/tmp/logseq-lifecycle-p On iOS 26, all four cold/late app/search restoration cases pass in the new lifecycle shell. The native animated hydration control also completes. Forcing that animation assertion while the scene is backgrounded instead fails its prerequisite (an animated push must start); this is not the reported EXC_BAD_ACCESS. The dedicated background control therefore validates real phase transitions, submits data/session updates while backgrounded, then verifies the final native UILabel content after foreground restoration. It runs three cases: already-loaded rows; no initial graph projection/graph-picker state; and the production-like initial `graph-loading=true` navigation shell before graph data arrives. Each submits ten row/depth changes and completes foreground recovery. Warm and loading-shell logs show actual native navigation depths 1/2/3 during background updates; the graph-picker case defers native navigation creation until foreground. These observations must not be conflated. Logs: `/tmp/logseq-lifecycle-background-{warm-control,cold-loading}.log`, `/tmp/logseq-lifecycle-background-cold-control.log`, `/tmp/logseq-lifecycle-simulator-restoration.log`, and merged lifecycle/diagnostic events in `/tmp/logseq-lifecycle-probe-events.log`. The final loading-shell rerun captures merged stdout/stderr and writes monotonic phase markers directly to stderr (`/tmp/logseq-lifecycle-probe/background-simulator-console.log`, `/tmp/logseq-lifecycle-background-console-control.log`). It completes in 8.7143 seconds without the allocation fault or a cycle warning in that console capture. The earlier phase/unified-log-only files cannot establish stderr-warning absence. These simulator cases launch in the foreground and are then backgrounded by the host after readiness; they are not no-activate background-only launches. -The real-device background launch was rejected by SpringBoard with `FBSOpenApplicationErrorDomain` code 7: the device was locked and could not be unlocked. `/tmp/logseq-lifecycle-background-launch.json` contains the authoritative rejection; there is no physical probe execution or successful device assertion to report. An asynchronous unlock/availability question is pending. The latest independently signed probe is built and installed under `com.logseq.logseq.LifecycleProbe`, with its own container and no app-group entitlement; the regular installed build remains `1792706378`, and its process `24529` was still present during this audit. +The first real-device background launch was rejected by SpringBoard with `FBSOpenApplicationErrorDomain` code 7: the device was locked and could not be unlocked (`/tmp/logseq-lifecycle-background-launch.json`). A subsequent launch and physical background/resume control completed; see the follow-up below. The independently signed probe uses `com.logseq.logseq.LifecycleProbe`, its own container and no app-group entitlement. The regular installed build remains `1792706378`, and its process `24529` was still present after the successful physical control. Device inventory also limits comparison: the connected phone is `iPhone15,4` (iPhone 15), iOS 27.2 build `24B5110a`; the exact report is `iPhone18,1`, iOS 27.0.1. Other listed devices are unavailable. Even after unlocking, a pass on the connected phone cannot by itself prove the original hardware/OS crash is fixed. The exact crash root cause and causal repair remain outstanding. The crash role/comment provide context, not a complete UIKit scene-activation timeline. + +### Physical recovery and mixed-content crash controls + +The same Organizer crash point contains three unique incidents with the same `AG::data::zone::alloc_bytes_recycle → LazySubviewPrefetcher → prepareNavigationBar → NavigationStackCoordinator` stack. All report iPhone18,1 / iOS 27.0.1. Build 18 is Non UI at 1.1534 seconds; the two build-17 reports are Foreground at 28.7823 and 16560.9842 seconds. Background cold launch therefore does not explain the entire signature. This establishes neither one reporter nor distinct reporters. Sanitized audits: `/tmp/logseq-attributegraph-{signature-audit,report-environments}.json`. + +The exact feedback was also located in Xcode's cached App Store Connect response. Its supplementary metadata contains no page, graph, navigation trace or additional reproduction steps; the app-uptime field is null. Raw tester metadata and request headers were not copied to the evidence archive. A question requesting reproduction steps or a matching environment remains pending. Xcode's official `-downloadPlatform iOS -buildVersion 27.0.1 -architectureVariant arm64` lookup exits 70 because that runtime is unavailable for download. The only installed simulator runtime is iOS 26.0. + +The independent probe subsequently ran on the connected phone. A no-activate launch succeeded, but its phase log records an active scene before backgrounding: `activatedWhenStarted=false` does not establish a background-only execution. Its search-restoration animation prerequisite failed while backgrounded; this is not the reported allocation fault (`/tmp/logseq-lifecycle-device-background-cold-final.log`). + +The dedicated physical warm control completed ten row/navigation updates while both app and scene were backgrounded, with observed native navigation depths 1/2/3. Reactivating the same process verified revision 10 in native content, then recorded `CHECK_COMPLETE` at 35.0722 seconds (`/tmp/logseq-lifecycle-device-warm-final.log`). The cause of the initial background transition was not recorded. A transient CoreDevice log-copy socket failure was retried without restarting the process. The regular Logseq app was brought back to the foreground afterward, and process 24529 was verified alive. This validates background-update recovery on iPhone15,4 / iOS 27.2, not a repair of the original crash. + +The old build was also exercised with stable row identities changing between bold text, quotes, formulas, highlighted code, local images and node references during six real native pushes and six pops. Screenshot inspection caught an invalid image-type fixture and then an unfinished image decode; the final fixture uses `png` and waits for the rendered orange image before starting navigation. The saved screenshot confirms actual image, formula, code and quote content. All 42 content submissions occur while a native transition coordinator exists. The final merged stdout/stderr run completes at 8.9337 seconds without the matching fault or a cycle warning (`/tmp/logseq-lifecycle-mixed-verified-image-console.log`). A physical mixed-content attempt was rejected before launch because the phone was locked; no mixed-content device pass is claimed. + +The follow-up simulator sources, verified source-hash manifest, screenshot and sanitized controls are archived at `/tmp/logseq-lifecycle-mixed-followup-sources.zip` (266,883 bytes; SHA-256 `e91119bb9c7f411624a3b738b5caf25934e83fd1d603aa778a629909ced72bfe`). This is separate from the earlier lifecycle archive; its current simulator manifest must not be used to identify the earlier physical binary. No production code or app installation changed in this crash-audit follow-up. The exact crash has not been reproduced and its causal repair remains open.