diff --git a/.github/actions/db-sync-server/action.yml b/.github/actions/db-sync-server/action.yml index 2f6c0e13..caf74ea7 100644 --- a/.github/actions/db-sync-server/action.yml +++ b/.github/actions/db-sync-server/action.yml @@ -2,11 +2,8 @@ name: Start db-sync server description: >- Check out logseq@feature/native-mobile-app, build the db-sync node adapter, and start it on the given port (default 8787). The app e2e flows create sync - graphs against it. + graphs against it. logseq/logseq is public, so no credential is needed. inputs: - token: - description: GitHub token with access to the private logseq/logseq repo. - required: true ref: description: logseq/logseq ref to check out. required: false @@ -35,7 +32,6 @@ runs: repository: logseq/logseq ref: ${{ inputs.ref }} path: logseq-server - token: ${{ inputs.token }} fetch-depth: 1 - uses: actions/setup-java@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39f834a5..42d64a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ jobs: ocaml-core: runs-on: ubuntu-latest env: - LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} steps: - uses: actions/checkout@v4 @@ -63,14 +62,13 @@ jobs: run: | eval $(opam env) # opam clears file-based git config for its fetch subprocesses, but - # GIT_CONFIG_* env pairs pass through; use them to authenticate the - # private pin-depends (git+ssh://github.com/logseq/...) via PAT. + # GIT_CONFIG_* env pairs pass through; rewrite the ssh pin-depends + # URLs to https (the pinned repos are public, no credential needed). export GIT_TERMINAL_PROMPT=0 export GIT_CONFIG_COUNT=3 export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" - export GIT_CONFIG_KEY_2="credential.helper" - export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + export GIT_CONFIG_KEY_2="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_2="git+ssh://git@github.com/" opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main @@ -113,12 +111,11 @@ jobs: run: | set -e eval $(opam env) - # The lockfile only lists public deps; private pin-depends are cloned - # at their pinned commits below. Authenticate clones with the PAT. - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" + # The lockfile only lists public deps; pin-depends are cloned at + # their pinned commits below over public https. + git config --global url."https://github.com/".insteadOf "git@github.com:" + git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git+ssh://git@github.com/" # Call the binary directly: the `opam monorepo` plugin shim rejects # plugins built under a different opam version (restored caches). # Retry: release-tarball downloads occasionally fail with HTTP 5xx. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d94c74ca..abec6b8e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -35,7 +35,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: - LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} steps: - uses: actions/checkout@v4 @@ -84,8 +83,7 @@ jobs: export GIT_CONFIG_COUNT=3 export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" - export GIT_CONFIG_KEY_2="credential.helper" - export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + export GIT_CONFIG_KEY_2="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_2="git+ssh://git@github.com/" opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main @@ -115,10 +113,10 @@ jobs: run: | set -e eval $(opam env) - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" + # All pins are public; ssh URLs just need rewriting to https. + git config --global url."https://github.com/".insteadOf "git@github.com:" + git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git+ssh://git@github.com/" # Call the binary directly: the `opam monorepo` plugin shim rejects # plugins built under a different opam version (restored caches). # Retry: release-tarball downloads occasionally fail with HTTP 5xx. @@ -154,16 +152,15 @@ jobs: runs-on: macos-15 timeout-minutes: 90 env: - LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} - # SwiftPM clones lui over ssh://; rewrite to authenticated https. + # SwiftPM clones lui over ssh://; rewrite to https (repos are public). GIT_CONFIG_COUNT: 3 GIT_CONFIG_KEY_0: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_0: "git@github.com:" GIT_CONFIG_KEY_1: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_1: "ssh://git@github.com/" - GIT_CONFIG_KEY_2: credential.helper - GIT_CONFIG_VALUE_2: '!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + GIT_CONFIG_KEY_2: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_2: "git+ssh://git@github.com/" steps: - uses: actions/checkout@v4 @@ -215,8 +212,7 @@ jobs: export GIT_CONFIG_COUNT=3 export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" - export GIT_CONFIG_KEY_2="credential.helper" - export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + export GIT_CONFIG_KEY_2="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_2="git+ssh://git@github.com/" opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main @@ -271,7 +267,6 @@ jobs: # Builds while the iOS app build runs; "Wait for db-sync" polls it. uses: ./.github/actions/db-sync-server with: - token: ${{ secrets.LOGSEQ_GITHUB_PAT }} background: "true" - name: Restore iOS build artifacts @@ -346,7 +341,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 env: - LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} LOGSEQ_CHAT_ANDROID_ABI: x86_64 LOGSEQ_CHAT_E2E_BASE_URL: http://127.0.0.1:8787 @@ -401,8 +395,7 @@ jobs: export GIT_CONFIG_COUNT=3 export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" - export GIT_CONFIG_KEY_2="credential.helper" - export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + export GIT_CONFIG_KEY_2="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_2="git+ssh://git@github.com/" opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main @@ -468,24 +461,21 @@ jobs: # step polls the port before the flows run. uses: ./.github/actions/db-sync-server with: - token: ${{ secrets.LOGSEQ_GITHUB_PAT }} background: "true" - name: Run Android e2e module uses: reactivecircus/android-emulator-runner@v2 env: - # pub get clones the lui git dep over ssh; rewrite to https + PAT - # credential helper (file-based git config doesn't reliably reach - # the emulator-runner's script environment). - GIT_CONFIG_COUNT: 4 + # pub get clones the lui git dep over ssh; rewrite to https + # (file-based git config doesn't reliably reach the + # emulator-runner's script environment). + GIT_CONFIG_COUNT: 3 GIT_CONFIG_KEY_0: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_0: "git@github.com:" GIT_CONFIG_KEY_1: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_1: ssh://git@github.com/ GIT_CONFIG_KEY_2: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_2: git+ssh://git@github.com/ - GIT_CONFIG_KEY_3: credential.helper - GIT_CONFIG_VALUE_3: '!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' with: api-level: 34 target: google_apis diff --git a/apple/Package.resolved b/apple/Package.resolved index 510c5a8c..6d2d4f15 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "95360ea85c846a4a2655c81c615521c2d64d2de6e4f150207def2adc2ea40933", + "originHash" : "a1e9fd3714274e1fe7acf319749d7e933e78a1252db8f37cad22dfc3b02476d7", "pins" : [ { "identity" : "highlightswift", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "branch" : "55166025", - "revision" : "5516602566127dd437c4d2ad9d1700c474b76970" + "branch" : "b7b7641", + "revision" : "b7b76415b6b787f707b7cb012f9c918875f8f02b" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 72f1aca1..3b45f15e 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -34,7 +34,7 @@ let package = Package( .library(name: "LogseqChatModel", type: .dynamic, targets: ["LogseqChatModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "55166025"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "b7b7641"), .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/LogseqChat/LGChatComposerAssetExtension.swift b/apple/Sources/LogseqChat/LGChatComposerAssetExtension.swift deleted file mode 100644 index 6a074eb8..00000000 --- a/apple/Sources/LogseqChat/LGChatComposerAssetExtension.swift +++ /dev/null @@ -1,68 +0,0 @@ -import ImageIO -import LogseqChatModel -import LUIAppleBackend -import SwiftUI - -@MainActor -enum LGChatComposerAssetExtension { - static func register(in registry: LUIAppleExtensionRegistry) throws { - try registry.register(LUIAppleExtension( - identifier: "composer-asset", - fingerprint: "lui-extension-v1|14:composer-asset|profiles:ios/swiftui|standard-children:0|children:|properties:10:local-path:string:required:none,5:title:string:required:none|events:", - properties: [ - .init(name: "title", kind: .string, isRequired: true), - .init(name: "local-path", kind: .string, isRequired: true), - ], - events: [] - ) { context in - AnyView(LGChatComposerAssetPreview(context: context)) - }) - } -} - -private struct LGChatComposerAssetPreview: View { - let context: LUIAppleExtensionViewContext - @State private var thumbnail: CGImage? - - private var title: String { - guard case let .string(value) = context.property("title") else { return "" } - return value - } - - private var path: String { - guard case let .string(value) = context.property("local-path") else { return "" } - return value - } - - var body: some View { - Group { - if let thumbnail { - Image(decorative: thumbnail, scale: 1) - .resizable().scaledToFill() - } else { - VStack(spacing: 4) { - Image(systemName: "doc") - Text(verbatim: title).font(.caption2).lineLimit(2) - } - .padding(4) - } - } - .frame(width: 128, height: 128) - .background(.quaternary) - .clipShape(.rect(cornerRadius: 12)) - .accessibilityLabel(title) - .task(id: path) { - let localPath = path - let name = title - thumbnail = await Task.detached(priority: .utility) { - guard let url = LocalAssetPath.resolve(localPath, title: name, assetType: ""), - let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil as CGImage? } - return CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: 384, - ] as CFDictionary) - }.value - } - } -} diff --git a/apple/Sources/LogseqChat/LGChatEffects.swift b/apple/Sources/LogseqChat/LGChatEffects.swift index 3e34dd2e..91a42514 100644 --- a/apple/Sources/LogseqChat/LGChatEffects.swift +++ b/apple/Sources/LogseqChat/LGChatEffects.swift @@ -107,7 +107,7 @@ public final class LGChatPlatformEffectHandler: LGChatEffectExecuting { private let exportGraphDatabase: (@MainActor () async -> Bool)? private let graphEffect: (@MainActor (LGChatEffect) async -> LGChatEffectResolution)? private let presentAttachment: (@MainActor (String) async -> Bool)? - private let presentAsset: (@MainActor (LGChatAssetPresentationPayload) async -> Bool)? + private let presentAsset: (@MainActor (LGChatAssetPresentationPayload) async -> URL?)? private let presentPageShare: (@MainActor (LGChatPageSharePayload) async -> Bool)? private let syncNow: (@MainActor () -> Void)? @@ -122,7 +122,7 @@ public final class LGChatPlatformEffectHandler: LGChatEffectExecuting { exportGraphDatabase: (@MainActor () async -> Bool)? = nil, graphEffect: (@MainActor (LGChatEffect) async -> LGChatEffectResolution)? = nil, presentAttachment: (@MainActor (String) async -> Bool)? = nil, - presentAsset: (@MainActor (LGChatAssetPresentationPayload) async -> Bool)? = nil, + presentAsset: (@MainActor (LGChatAssetPresentationPayload) async -> URL?)? = nil, presentPageShare: (@MainActor (LGChatPageSharePayload) async -> Bool)? = nil, syncNow: (@MainActor () -> Void)? = nil ) { @@ -301,11 +301,24 @@ public final class LGChatPlatformEffectHandler: LGChatEffectExecuting { LGChatAssetPresentationPayload.self, from: Data(metadata.utf8) ) - let succeeded = await presentAsset(asset) + guard let url = await presentAsset(asset) else { + return LGChatEffectResolution( + succeeded: false, + message: "The local asset is unavailable", + output: .discard + ) + } + let payload = String( + data: try JSONEncoder().encode([ + "title": asset.title, + "path": url.path, + ]), + encoding: .utf8 + ) ?? "{}" return LGChatEffectResolution( - succeeded: succeeded, - message: succeeded ? "" : "The local asset is unavailable", - output: .discard + succeeded: true, + message: payload, + output: .hostUpdate("asset-preview-resolved") ) case "present-page-share": guard let presentPageShare, let metadata = effect.metadata else { @@ -687,7 +700,7 @@ public final class LGChatCoreEffectExecutor: LGChatEffectExecuting { } return Self.resolution(from: await deleteLocalGraph(effect.text)) case "persist-ui-session", "persist-composer-draft", "sign-in", "save-settings", "refresh-runtime-log", - "copy-runtime-log", "sign-out", + "copy-runtime-log", "sign-out", "export-graph-database", "open-external-url", "present-attachment", "present-asset", "present-page-share", "sync-now": guard let platformEffect else { return LGChatEffectResolution( diff --git a/apple/Sources/LogseqChat/LGChatGraphLifecycle.swift b/apple/Sources/LogseqChat/LGChatGraphLifecycle.swift index 71439aa6..804ba385 100644 --- a/apple/Sources/LogseqChat/LGChatGraphLifecycle.swift +++ b/apple/Sources/LogseqChat/LGChatGraphLifecycle.swift @@ -203,7 +203,7 @@ final class LGChatGraphLifecycle { || store.snapshot.selectedGraphId == graphID if deletingSelected { await syncCoordinator.stopForeground() - await store.resetToCatalog() + await store.resetToCatalog(applyResponse: false) } do { try LogseqGraphLocalStorage.delete(databasePath: databasePath, graphID: graphID) diff --git a/apple/Sources/LogseqChat/LGChatNative.swift b/apple/Sources/LogseqChat/LGChatNative.swift index 9eb3886f..5dc1bd08 100644 --- a/apple/Sources/LogseqChat/LGChatNative.swift +++ b/apple/Sources/LogseqChat/LGChatNative.swift @@ -26,6 +26,9 @@ public protocol LGChatNativeCalling { text: String, value: Int ) -> String + func picked(node: Int, payload: String) -> String + func visibleRange(node: Int, first: Int, last: Int) -> String + func scrollCompleted(node: Int, token: Int, outcome: String) -> String func dispose() -> String func takeEffect() -> String func resolveEffect(id: Int, succeeded: Bool, message: String) -> String @@ -79,6 +82,15 @@ public final class LGChatCoreNativeCaller: LGChatNativeCalling { ) -> String { core.logseq_chat_lui_extension_event(node, identifier, name, text, value) } + public func picked(node: Int, payload: String) -> String { + core.logseq_chat_lui_picked(node, payload) + } + public func visibleRange(node: Int, first: Int, last: Int) -> String { + core.logseq_chat_lui_visible_range(node, first, last) + } + public func scrollCompleted(node: Int, token: Int, outcome: String) -> String { + core.logseq_chat_lui_scroll_completed(node, token, outcome) + } public func dispose() -> String { core.logseq_chat_lui_dispose() } public func takeEffect() -> String { core.logseq_chat_lui_take_effect() } public func resolveEffect(id: Int, succeeded: Bool, message: String) -> String { diff --git a/apple/Sources/LogseqChat/LGChatOverflowMenuExtension.swift b/apple/Sources/LogseqChat/LGChatOverflowMenuExtension.swift deleted file mode 100644 index abdd9b13..00000000 --- a/apple/Sources/LogseqChat/LGChatOverflowMenuExtension.swift +++ /dev/null @@ -1,111 +0,0 @@ -import LUIAppleBackend -import SwiftUI - -@MainActor -enum LGChatOverflowMenuExtension { - static let identifier = "native-overflow-menu" - static let fingerprint = "lui-extension-v1|20:native-overflow-menu|profiles:android/flutter,ios/swiftui|standard-children:0|children:|properties:14:favorite-label:string:required:none,16:settings-visible:bool:required:none,20:page-actions-visible:bool:required:none|events:5:share[],6:delete[],8:favorite[],8:settings[]" - - static func register(in registry: LUIAppleExtensionRegistry) throws { - try registry.register( - LUIAppleExtension( - identifier: identifier, - fingerprint: fingerprint, - properties: [ - .init(name: "page-actions-visible", kind: .bool, isRequired: true), - .init(name: "favorite-label", kind: .string, isRequired: true), - .init(name: "settings-visible", kind: .bool, isRequired: true), - ], - events: [ - .init(name: "favorite", fields: []), - .init(name: "share", fields: []), - .init(name: "delete", fields: []), - .init(name: "settings", fields: []), - ] - ) { context in - AnyView(LGChatOverflowMenu(context: context)) - } - ) - } -} - -@MainActor -private struct LGChatOverflowMenu: View { - let context: LUIAppleExtensionViewContext - - var body: some View { - menuBody - } - - private var menuBody: some View { - Menu { - if pageActionsVisible { - Button { - emit("favorite") - } label: { - Text(verbatim: favoriteLabel) - } - Button { - emit("share") - } label: { - Text("Share", bundle: .module) - } - Button(role: .destructive) { - emit("delete") - } label: { - Text("Delete", bundle: .module) - } - } - if settingsVisible { - Button { - emit("settings") - } label: { - Text("Settings", bundle: .module) - } - } - } label: { - ZStack { - Circle() - .stroke(lineWidth: 2) - Image("more_horiz", bundle: .module) - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - } - .frame(width: 24, height: 24) - } - .frame(width: 44, height: 44) - .foregroundStyle(.primary) - .accessibilityLabel(Text("More", bundle: .module)) - .accessibilityIdentifier("button.connection") - } - - private var pageActionsVisible: Bool { - guard case let .bool(value) = context.property("page-actions-visible") else { - return false - } - return value - } - - private var favoriteLabel: String { - guard case let .string(value) = context.property("favorite-label") else { - return "Favorite" - } - return value - } - - private var settingsVisible: Bool { - guard case let .bool(value) = context.property("settings-visible") else { - return false - } - return value - } - - private func emit(_ name: String) { - do { - try context.emit(name: name, values: [:]) - } catch { - logger.error("Could not emit overflow menu event: \(String(describing: error))") - } - } -} diff --git a/apple/Sources/LogseqChat/LGChatPlatformPresentation.swift b/apple/Sources/LogseqChat/LGChatPlatformPresentation.swift index b0af3dc7..e46a0f75 100644 --- a/apple/Sources/LogseqChat/LGChatPlatformPresentation.swift +++ b/apple/Sources/LogseqChat/LGChatPlatformPresentation.swift @@ -23,10 +23,6 @@ public final class LGChatPlatformPresentationCoordinator { public private(set) var attachmentService: LGChatAttachmentService? public private(set) var attachmentTargetBlockID: String? public private(set) var pendingDeletionBlockIDs: [String] = [] - #if os(iOS) - public private(set) var previewAssetURL: URL? - var pageSharePayload: NodeSharePayload? - #endif public init() { } @@ -62,37 +58,57 @@ public final class LGChatPlatformPresentationCoordinator { } #if os(iOS) - @discardableResult - public func presentAsset(_ asset: LGChatAssetPresentationPayload) -> Bool { - guard let url = LocalAssetPath.resolve( - asset.localPath, - title: asset.title, - assetType: asset.assetType - ) else { return false } - previewAssetURL = url - return true - } - - public func updatePreviewAssetURL(_ url: URL?) { - previewAssetURL = url - } - @discardableResult public func presentPageShare(_ payload: LGChatPageSharePayload) -> Bool { - pageSharePayload = NodeSharePayload( - text: payload.text, - localAssetPaths: payload.localAssetPaths + return presentShareSheet( + NodeSharePayload( + text: payload.text, + localAssetPaths: payload.localAssetPaths + ).items ) - return true } @discardableResult public func presentFile(_ url: URL) -> Bool { guard FileManager.default.fileExists(atPath: url.path) else { return false } - pageSharePayload = NodeSharePayload(fileURL: url) + return presentShareSheet([url]) + } + + /// Presents the activity sheet on the topmost presented controller so it + /// can appear above an already-presented LUI sheet (e.g. Settings); a + /// root-level `.sheet` would queue behind it with no visible feedback. + private func presentShareSheet(_ items: [Any]) -> Bool { + guard let presenter = Self.topmostPresenter() else { return false } + let controller = UIActivityViewController( + activityItems: items, + applicationActivities: nil + ) + if let popover = controller.popoverPresentationController { + popover.sourceView = presenter.view + popover.sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 0, + height: 0 + ) + } + presenter.present(controller, animated: true) return true } + private static func topmostPresenter() -> UIViewController? { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + let root = windows.first(where: { $0.isKeyWindow })?.rootViewController + ?? windows.first?.rootViewController + var top = root + while let presented = top?.presentedViewController { + top = presented + } + return top + } + #endif } @@ -208,10 +224,6 @@ struct LGChatPlatformPresentationHost: ViewModifier { Text("This deletes the block and all of its children. Pages use Recycle instead.") } #if os(iOS) - .quickLookPreview(previewAssetBinding) - .sheet(item: pageSharePayloadBinding) { payload in - NodeShareSheet(items: payload.items) - } .sheet(isPresented: mediaPanelBinding) { let target = coordinator.attachmentTargetBlockID VStack(spacing: 0) { @@ -272,21 +284,6 @@ struct LGChatPlatformPresentationHost: ViewModifier { ) } - #if os(iOS) - private var previewAssetBinding: Binding { - Binding( - get: { coordinator.previewAssetURL }, - set: { coordinator.updatePreviewAssetURL($0) } - ) - } - - private var pageSharePayloadBinding: Binding { - Binding( - get: { coordinator.pageSharePayload }, - set: { coordinator.pageSharePayload = $0 } - ) - } - #endif private var deletionTitle: String { coordinator.pendingDeletionBlockIDs.count > 1 ? "Delete blocks?" : "Delete block?" diff --git a/apple/Sources/LogseqChat/LGChatRenderer.swift b/apple/Sources/LogseqChat/LGChatRenderer.swift index 179c0aa3..1d9ec8ca 100644 --- a/apple/Sources/LogseqChat/LGChatRenderer.swift +++ b/apple/Sources/LogseqChat/LGChatRenderer.swift @@ -13,6 +13,9 @@ public enum LGChatRendererEventKind: Equatable, Sendable { case valueChanged case dismiss case doublePress + case scrollCompleted + case visibleRange + case picked case `extension` } @@ -25,6 +28,11 @@ public struct LGChatRendererEvent: Equatable, Sendable { public let extensionIdentifier: String? public let extensionName: String? public let extensionValues: [String: LUIExtensionValue]? + public let payload: String? + public let first: Int? + public let last: Int? + public let token: Int? + public let outcome: String? public init( kind: LGChatRendererEventKind, @@ -34,7 +42,12 @@ public struct LGChatRendererEvent: Equatable, Sendable { value: Double? = nil, extensionIdentifier: String? = nil, extensionName: String? = nil, - extensionValues: [String: LUIExtensionValue]? = nil + extensionValues: [String: LUIExtensionValue]? = nil, + payload: String? = nil, + first: Int? = nil, + last: Int? = nil, + token: Int? = nil, + outcome: String? = nil ) { self.kind = kind self.nodeID = nodeID @@ -44,6 +57,11 @@ public struct LGChatRendererEvent: Equatable, Sendable { self.extensionIdentifier = extensionIdentifier self.extensionName = extensionName self.extensionValues = extensionValues + self.payload = payload + self.first = first + self.last = last + self.token = token + self.outcome = outcome } } @@ -53,6 +71,7 @@ enum LGChatIconPolicy { "calendar": .assetName("calendar"), "add": .assetName("plus"), "composer-add": .systemName("plus"), + "composer-file": .systemName("doc"), "arrow-up": .systemName("arrow.up"), "close": .assetName("close"), "chevron-down": .assetName("chevron_down"), @@ -141,11 +160,17 @@ public final class LGChatRenderer { private static func makeBackend() -> LUIAppleBackend { do { - return try LUIAppleBackend( + let backend = try LUIAppleBackend( appIcons: LGChatIconPolicy.icons, appIconBundle: .module, extensionRegistry: LGChatExtensionRegistry.makeRegistry() ) + // Effects like local-graph deletion resolve through several RPCs, + // each emitting a patch on its own runloop turn. Coalescing merges + // the burst into one view commit; without it iOS's collection view + // replays stale section mutations and asserts. + backend.coalescesCommits = true + return backend } catch { preconditionFailure( "Invalid LG chat extension registry: \(String(describing: error))" @@ -198,6 +223,22 @@ public final class LGChatRenderer { return LGChatRendererEvent(kind: .dismiss, nodeID: node) case .doublePress(let node): return LGChatRendererEvent(kind: .doublePress, nodeID: node) + case .scrollCompleted(let node, let token, let outcome): + return LGChatRendererEvent( + kind: .scrollCompleted, + nodeID: node, + token: token, + outcome: outcome + ) + case .visibleRange(let node, let first, let last): + return LGChatRendererEvent( + kind: .visibleRange, + nodeID: node, + first: first, + last: last + ) + case .picked(let node, let payload): + return LGChatRendererEvent(kind: .picked, nodeID: node, payload: payload) case .extension(let node, let identifier, let name, let values): return LGChatRendererEvent( kind: .extension, diff --git a/apple/Sources/LogseqChat/LGChatRichBlockExtension.swift b/apple/Sources/LogseqChat/LGChatRichBlockExtension.swift index 9d81808e..de0e3be6 100644 --- a/apple/Sources/LogseqChat/LGChatRichBlockExtension.swift +++ b/apple/Sources/LogseqChat/LGChatRichBlockExtension.swift @@ -15,8 +15,6 @@ enum LGChatExtensionRegistry { try LGChatLiquidGlassTweak.register(in: registry) try LGChatNavigationExtension.register(in: registry) try LGChatSearchPresentationExtension.register(in: registry) - try LGChatOverflowMenuExtension.register(in: registry) - try LGChatComposerAssetExtension.register(in: registry) try LGChatOutlinerEditorExtension.register(in: registry) try LGChatRichBlockExtension.register( in: registry, diff --git a/apple/Sources/LogseqChat/LGChatRuntime.swift b/apple/Sources/LogseqChat/LGChatRuntime.swift index e40ba4a5..639609cc 100644 --- a/apple/Sources/LogseqChat/LGChatRuntime.swift +++ b/apple/Sources/LogseqChat/LGChatRuntime.swift @@ -288,6 +288,20 @@ public final class LGChatRuntime { patch = native.dismiss(node: event.nodeID) case .doublePress: patch = native.doublePress(node: event.nodeID) + case .scrollCompleted: + patch = native.scrollCompleted( + node: event.nodeID, + token: event.token ?? 0, + outcome: event.outcome ?? "" + ) + case .visibleRange: + patch = native.visibleRange( + node: event.nodeID, + first: event.first ?? 0, + last: event.last ?? 0 + ) + case .picked: + patch = native.picked(node: event.nodeID, payload: event.payload ?? "") case .extension: guard let identifier = event.extensionIdentifier, let name = event.extensionName, @@ -539,10 +553,20 @@ public final class LGChatRuntime { private func apply(_ patch: String) throws { guard !patch.isEmpty else { return } #if DEBUG - print( - "LOGSEQ_LG_PATCH apply generation=" - + String(Self.patchGeneration(patch) ?? -1) - ) + let gen = Self.patchGeneration(patch) ?? -1 + print("LOGSEQ_LG_PATCH apply generation=\(gen)") + if let docs = FileManager.default.urls( + for: .documentDirectory, in: .userDomainMask + ).first { + let dir = docs.appendingPathComponent("lui-patches", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true + ) + try? patch.write( + to: dir.appendingPathComponent("gen-\(gen).json"), + atomically: true, encoding: .utf8 + ) + } #endif try renderer.apply(patchJSON: patch) lastError = nil @@ -570,6 +594,22 @@ public final class LGChatRuntime { return } Self.log.notice("apply patch generation=\(Self.patchGeneration(patch) ?? -1, privacy: .public)") + #if DEBUG + if let docs = FileManager.default.urls( + for: .documentDirectory, in: .userDomainMask + ).first { + let dir = docs.appendingPathComponent("lui-patches", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true + ) + try? patch.write( + to: dir.appendingPathComponent( + "gen-\(Self.patchGeneration(patch) ?? -1).json" + ), + atomically: true, encoding: .utf8 + ) + } + #endif try self.renderer.apply(decoded: decoded) self.lastError = nil } catch { diff --git a/apple/Sources/LogseqChat/LogseqChatApp.swift b/apple/Sources/LogseqChat/LogseqChatApp.swift index 2756aa75..6b6bf5fb 100644 --- a/apple/Sources/LogseqChat/LogseqChatApp.swift +++ b/apple/Sources/LogseqChat/LogseqChatApp.swift @@ -153,6 +153,16 @@ public struct LogseqChatRootView : View { } private init() { + #if DEBUG + NSSetUncaughtExceptionHandler { exception in + NSLog( + "LUICRASH %@: %@\n%@", + exception.name.rawValue, + exception.reason ?? "", + exception.callStackSymbols.joined(separator: "\n") + ) + } + #endif try? FileManager.default.removeItem( at: URL.documentsDirectory.appendingPathComponent("cached-home-snapshot.json") ) @@ -249,8 +259,15 @@ public struct LogseqChatRootView : View { presentAttachment: { kind in presentationCoordinator.presentAttachment(kind) }, + // Asset preview is presented by the file-preview node on iOS; the + // effect reports the resolved file URL back to the core so the + // preview only mounts for a path that exists on disk. presentAsset: { asset in - return presentationCoordinator.presentAsset(asset) + LocalAssetPath.resolve( + asset.localPath, + title: asset.title, + assetType: asset.assetType + ) }, presentPageShare: { payload in return presentationCoordinator.presentPageShare(payload) diff --git a/apple/Sources/LogseqChat/NodeSharing.swift b/apple/Sources/LogseqChat/NodeSharing.swift index f5ab8ea1..620e5650 100644 --- a/apple/Sources/LogseqChat/NodeSharing.swift +++ b/apple/Sources/LogseqChat/NodeSharing.swift @@ -25,7 +25,6 @@ enum NodeSharePolicy { } #if os(iOS) -import SwiftUI import UIKit struct NodeSharePayload: Identifiable { @@ -74,13 +73,4 @@ struct NodeSharePayload: Identifiable { } } -struct NodeShareSheet: UIViewControllerRepresentable { - let items: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} -} #endif diff --git a/apple/Sources/LogseqChatCoreABI/include/LogseqChatCoreABI.h b/apple/Sources/LogseqChatCoreABI/include/LogseqChatCoreABI.h index 48a55672..acf308ba 100644 --- a/apple/Sources/LogseqChatCoreABI/include/LogseqChatCoreABI.h +++ b/apple/Sources/LogseqChatCoreABI/include/LogseqChatCoreABI.h @@ -26,6 +26,11 @@ const char *logseq_chat_lui_double_press(int64_t node); const char *logseq_chat_lui_extension_event( int64_t node, const char *identifier, const char *name, const char *text, int64_t value); +const char *logseq_chat_lui_picked(int64_t node, const char *payload); +const char *logseq_chat_lui_visible_range(int64_t node, int64_t first, + int64_t last); +const char *logseq_chat_lui_scroll_completed(int64_t node, int64_t token, + const char *outcome); int64_t logseq_chat_lui_root_node(void); const char *logseq_chat_lui_dispose(void); const char *logseq_chat_lui_take_effect(void); diff --git a/apple/Sources/LogseqChatModel/ViewModel.swift b/apple/Sources/LogseqChatModel/ViewModel.swift index 649b1a62..2ecf071e 100644 --- a/apple/Sources/LogseqChatModel/ViewModel.swift +++ b/apple/Sources/LogseqChatModel/ViewModel.swift @@ -244,6 +244,55 @@ public final class LogseqChatCore { #endif } + public func logseq_chat_lui_picked(_ node: Int, _ payload: String) -> String { + #if LOGSEQ_CHAT_CORE + return invokeCore { + String(cString: LogseqChatCoreABI.logseq_chat_lui_picked( + Int64(node), + payload + )) + } + #else + return "" + #endif + } + + public func logseq_chat_lui_visible_range( + _ node: Int, + _ first: Int, + _ last: Int + ) -> String { + #if LOGSEQ_CHAT_CORE + return invokeCore { + String(cString: LogseqChatCoreABI.logseq_chat_lui_visible_range( + Int64(node), + Int64(first), + Int64(last) + )) + } + #else + return "" + #endif + } + + public func logseq_chat_lui_scroll_completed( + _ node: Int, + _ token: Int, + _ outcome: String + ) -> String { + #if LOGSEQ_CHAT_CORE + return invokeCore { + String(cString: LogseqChatCoreABI.logseq_chat_lui_scroll_completed( + Int64(node), + Int64(token), + outcome + )) + } + #else + return "" + #endif + } + public func logseq_chat_lui_dispose() -> String { #if LOGSEQ_CHAT_CORE return invokeCore { @@ -643,7 +692,7 @@ private struct DeletePagePayload: Encodable { ) } - public func resetToCatalog() async { + public func resetToCatalog(applyResponse: Bool = true) async { guard let openedDatabasePath else { lastError = LogseqChatCoreError( code: "database_not_open", @@ -651,11 +700,18 @@ private struct DeletePagePayload: Encodable { ) return } + // When a caller issues several RPCs back-to-back (e.g. graph deletion: + // catalog reset followed by configure), applying each response renders + // intermediate tree states. Consecutive commits inside one SwiftUI + // update window get replayed as a single coalesced collection batch, + // which can double-apply a row delete and crash; suppressing the + // intermediate apply collapses the transition into one commit. await performAsyncAndWait( LogseqChatRPCRequest( method: "open", params: LogseqChatRPCParams(action: nil, path: openedDatabasePath) - ) + ), + shouldApply: { applyResponse } ) } diff --git a/apple/Tests/LogseqChatTests/LGChatRendererTests.swift b/apple/Tests/LogseqChatTests/LGChatRendererTests.swift index 7458ae70..cfabe8c2 100644 --- a/apple/Tests/LogseqChatTests/LGChatRendererTests.swift +++ b/apple/Tests/LogseqChatTests/LGChatRendererTests.swift @@ -145,23 +145,6 @@ struct LGChatRendererTests { #expect(renderer.rootID == 1) } - @Test("registers the native overflow menu with the LG fingerprint") - func registersNativeOverflowMenu() throws { - let renderer = LGChatRenderer() - - try renderer.apply(patchJSON: """ - {"generation":1,"ops":[ - {"op":"create-node","id":1,"kind":"root"}, - {"op":"create-extension","id":2,"identifier":"native-overflow-menu","fingerprint":"\(LGChatOverflowMenuExtension.fingerprint)"}, - {"op":"set-extension-prop","id":2,"property":"page-actions-visible","value":false}, - {"op":"set-extension-prop","id":2,"property":"favorite-label","value":"Favorite"}, - {"op":"set-extension-prop","id":2,"property":"settings-visible","value":true}, - {"op":"insert-child","parent":1,"child":2,"index":0} - ]} - """) - - #expect(renderer.rootID == 1) - } @Test("forwards renderer events without owning application state") func forwardsEvents() { @@ -792,7 +775,7 @@ struct LGChatRendererTests { signOut: {}, presentAsset: { asset in presented = asset - return true + return URL(fileURLWithPath: "/tmp/Photo.jpg") } ) @@ -809,7 +792,8 @@ struct LGChatRendererTests { localPath: "Assets/Photo.jpg" )) #expect(resolution.succeeded) - #expect(resolution.output == .discard) + #expect(resolution.output == .hostUpdate("asset-preview-resolved")) + #expect(resolution.message.contains(#""path":"/tmp/Photo.jpg""#)) } @Test("page mutations preserve semantic operation payloads") @@ -1649,6 +1633,9 @@ private final class LGChatNativeRuntimeProbe: LGChatNativeCalling { func valueChanged(node: Int, value: Double) -> String { "" } func dismiss(node: Int) -> String { "" } func doublePress(node: Int) -> String { "" } + func picked(node: Int, payload: String) -> String { "" } + func visibleRange(node: Int, first: Int, last: Int) -> String { "" } + func scrollCompleted(node: Int, token: Int, outcome: String) -> String { "" } func extensionEvent( node: Int, identifier: String, diff --git a/apple/Tests/UI/outliner-autosave.swift b/apple/Tests/UI/outliner-autosave.swift index 3e1994e7..a91e2518 100644 --- a/apple/Tests/UI/outliner-autosave.swift +++ b/apple/Tests/UI/outliner-autosave.swift @@ -15,6 +15,9 @@ private final class NativeQueue: LGChatNativeCalling { func valueChanged(node: Int, value: Double) -> String { "" } func dismiss(node: Int) -> String { "" } func doublePress(node: Int) -> String { "" } + func picked(node: Int, payload: String) -> String { "" } + func visibleRange(node: Int, first: Int, last: Int) -> String { "" } + func scrollCompleted(node: Int, token: Int, outcome: String) -> String { "" } func extensionEvent(node: Int, identifier: String, name: String, text: String, value: Int) -> String { "" } func dispose() -> String { "" } func takeEffect() -> String { effects.isEmpty ? "" : effects.removeFirst() } diff --git a/flutter/lib/logseq_chat_extensions.dart b/flutter/lib/logseq_chat_extensions.dart index 6f8f4797..16c83a76 100644 --- a/flutter/lib/logseq_chat_extensions.dart +++ b/flutter/lib/logseq_chat_extensions.dart @@ -48,17 +48,6 @@ const _searchFingerprint = '5:title:string:required:none,9:presented:bool:required:none|events:' '13:query-changed[5:query:string:required],4:back[5:count:int:required],' '7:dismiss[]'; -const _overflowFingerprint = - 'lui-extension-v1|20:native-overflow-menu|profiles:android/flutter,' - 'ios/swiftui|standard-children:0|children:|' - 'properties:14:favorite-label:string:required:none,16:settings-visible:bool:' - 'required:none,20:page-actions-visible:bool:required:none|events:5:share[],' - '6:delete[],8:favorite[],8:settings[]'; -const _composerAssetFingerprint = - 'lui-extension-v1|14:composer-asset|profiles:android/flutter,' - 'ios/swiftui|standard-children:0|children:|' - 'properties:10:local-path:string:required:none,5:title:string:' - 'required:none|events:'; String _identityAssetPath(String path) => path; @@ -193,80 +182,9 @@ LUIFlutterExtensionRegistry logseqChatExtensionRegistry({ ], builder: (context) => _SearchPresentation(context: context), ), - ) - ..register( - LUIFlutterExtension( - identifier: 'native-overflow-menu', - fingerprint: _overflowFingerprint, - properties: [ - _requiredBoolProperty('page-actions-visible'), - _requiredStringProperty('favorite-label'), - _requiredBoolProperty('settings-visible'), - ], - events: [ - LUIExtensionEventSchema(name: 'favorite'), - LUIExtensionEventSchema(name: 'share'), - LUIExtensionEventSchema(name: 'delete'), - LUIExtensionEventSchema(name: 'settings'), - ], - builder: (context) => _OverflowMenu(context: context), - ), - ) - ..register( - LUIFlutterExtension( - identifier: 'composer-asset', - fingerprint: _composerAssetFingerprint, - properties: [ - _requiredStringProperty('title'), - _requiredStringProperty('local-path'), - ], - builder: (context) => _ComposerAssetPreview(context: context), - ), ); } -final class _ComposerAssetPreview extends StatelessWidget { - const _ComposerAssetPreview({required this.context}); - - final LUIFlutterExtensionContext context; - - @override - Widget build(BuildContext buildContext) { - final title = context.property('title') as String? ?? ''; - final localPath = context.property('local-path') as String? ?? ''; - final Widget content = - localPath.isNotEmpty && isAndroidImageAsset('', localPath) - ? Image.file( - File(localPath), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _fallback(buildContext, title), - ) - : _fallback(buildContext, title); - return Semantics( - label: title, - image: true, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox.square(dimension: 128, child: content), - ), - ); - } - - Widget _fallback(BuildContext buildContext, String title) => ColoredBox( - color: Theme.of(buildContext).colorScheme.surfaceContainerHigh, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - const Icon(Icons.attach_file_rounded), - Text(title, maxLines: 2, overflow: TextOverflow.ellipsis), - ], - ), - ), - ); -} - LUIExtensionEventField _requiredStringField(String name) => LUIExtensionEventField( name: name, @@ -505,75 +423,6 @@ final class _SearchPresentationState extends State<_SearchPresentation> } } -final class _OverflowMenu extends StatelessWidget { - const _OverflowMenu({required this.context}); - - final LUIFlutterExtensionContext context; - - @override - Widget build(BuildContext buildContext) { - final pageActions = context.property('page-actions-visible')! as bool; - final settings = context.property('settings-visible')! as bool; - return Semantics( - key: const ValueKey('overflow-menu'), - identifier: 'button.overflow-menu', - button: true, - child: PopupMenuButton( - tooltip: 'More', - icon: const Icon(Icons.more_vert_rounded), - onSelected: (name) => context.emit(name: name), - itemBuilder: (_) => [ - if (pageActions) ...[ - PopupMenuItem( - value: 'favorite', - child: _MenuAction( - icon: - (context.property('favorite-label')! as String) == - 'Unfavorite' - ? Icons.star_rounded - : Icons.star_outline_rounded, - label: context.property('favorite-label')! as String, - ), - ), - const PopupMenuItem( - value: 'share', - child: _MenuAction(icon: Icons.share_outlined, label: 'Share'), - ), - const PopupMenuItem( - value: 'delete', - child: _MenuAction( - icon: Icons.delete_outline_rounded, - label: 'Delete', - ), - ), - ], - if (settings) - const PopupMenuItem( - value: 'settings', - child: _MenuAction( - icon: Icons.settings_outlined, - label: 'Settings', - ), - ), - ], - ), - ); - } -} - -final class _MenuAction extends StatelessWidget { - const _MenuAction({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)], - ); -} - final class _YoutubePlaybackCoordinator extends ValueNotifier> { _YoutubePlaybackCoordinator() : super(const {}); diff --git a/flutter/lib/logseq_chat_icons.dart b/flutter/lib/logseq_chat_icons.dart index bb0928a0..ae5e6513 100644 --- a/flutter/lib/logseq_chat_icons.dart +++ b/flutter/lib/logseq_chat_icons.dart @@ -10,6 +10,7 @@ const logseqChatAppIcons = { 'chevron-right': Icons.chevron_right_rounded, 'close': Icons.close_rounded, 'composer-add': Icons.add_circle_outline_rounded, + 'composer-file': Icons.attach_file_rounded, 'composer-photo': Icons.photo_library_outlined, 'disclosure-down': Icons.expand_more_rounded, 'disclosure-right': Icons.chevron_right_rounded, @@ -22,15 +23,20 @@ const logseqChatAppIcons = { 'graph-remote': Icons.cloud_outlined, 'history': Icons.history_rounded, 'logo': Icons.hub_rounded, + 'more-horiz': Icons.more_horiz_rounded, + 'more-vert': Icons.more_vert_rounded, 'navigation-back': Icons.arrow_back_rounded, 'open-external': Icons.open_in_new_rounded, 'refresh': Icons.refresh_rounded, 'search': Icons.search_rounded, 'send': Icons.send_rounded, 'selected': Icons.check_circle_rounded, + 'settings': Icons.settings_outlined, + 'share': Icons.share_outlined, 'sidebar-toggle': Icons.menu_rounded, 'sign-out': Icons.logout_rounded, 'star': Icons.star_outline_rounded, + 'star-filled': Icons.star_rounded, 'status-dot': Icons.circle, 'sync-status': Icons.sync_rounded, 'task-backlog': Icons.circle_outlined, diff --git a/flutter/lib/logseq_chat_native_bridge.dart b/flutter/lib/logseq_chat_native_bridge.dart index 063aae7e..093a61b4 100644 --- a/flutter/lib/logseq_chat_native_bridge.dart +++ b/flutter/lib/logseq_chat_native_bridge.dart @@ -16,6 +16,18 @@ typedef _NativeTextEvent = Pointer Function(Int64, Pointer); typedef _DartTextEvent = Pointer Function(int, Pointer); typedef _NativeToggleEvent = Pointer Function(Int64, Int32); typedef _DartToggleEvent = Pointer Function(int, int); +typedef _NativeRangeEvent = Pointer Function(Int64, Int32, Int32); +typedef _DartRangeEvent = Pointer Function(int, int, int); +typedef _NativeScrollCompletedEvent = Pointer Function( + Int64, + Int32, + Pointer, +); +typedef _DartScrollCompletedEvent = Pointer Function( + int, + int, + Pointer, +); typedef _NativeValueEvent = Pointer Function(Int64, Double); typedef _DartValueEvent = Pointer Function(int, double); typedef _NativeExtensionEvent = Pointer Function( @@ -83,6 +95,16 @@ final class LogseqChatNativeBridge ); _dismiss = _nodeEvent('logseq_chat_lui_dismiss'); _doublePress = _nodeEvent('logseq_chat_lui_double_press'); + _picked = _library.lookupFunction<_NativeTextEvent, _DartTextEvent>( + 'logseq_chat_lui_picked', + ); + _visibleRange = _library.lookupFunction<_NativeRangeEvent, _DartRangeEvent>( + 'logseq_chat_lui_visible_range', + ); + _scrollCompleted = _library + .lookupFunction<_NativeScrollCompletedEvent, _DartScrollCompletedEvent>( + 'logseq_chat_lui_scroll_completed', + ); _extensionEvent = _library .lookupFunction<_NativeExtensionEvent, _DartExtensionEvent>( 'logseq_chat_lui_extension_event', @@ -122,6 +144,9 @@ final class LogseqChatNativeBridge late final _DartValueEvent _valueChanged; late final _DartNodeEvent _dismiss; late final _DartNodeEvent _doublePress; + late final _DartTextEvent _picked; + late final _DartRangeEvent _visibleRange; + late final _DartScrollCompletedEvent _scrollCompleted; late final _DartExtensionEvent _extensionEvent; late final _DartNoArgument _dispose; late final _DartNoArgument _takeEffect; @@ -186,6 +211,34 @@ final class LogseqChatNativeBridge void doublePress(int node) => _runtimeScheduler.runUi(() => _apply(_doublePress(node))); + @override + void picked(int node, String payload) { + _runtimeScheduler.runUi(() { + final nativePayload = payload.toNativeUtf8(); + try { + _apply(_picked(node, nativePayload)); + } finally { + malloc.free(nativePayload); + } + }); + } + + @override + void visibleRange(int node, int first, int last) => + _runtimeScheduler.runUi(() => _apply(_visibleRange(node, first, last))); + + @override + void scrollCompleted(int node, int token, String outcome) { + _runtimeScheduler.runUi(() { + final nativeOutcome = outcome.toNativeUtf8(); + try { + _apply(_scrollCompleted(node, token, nativeOutcome)); + } finally { + malloc.free(nativeOutcome); + } + }); + } + @override void extensionEvent( int node, @@ -278,9 +331,7 @@ final class LogseqChatNativeBridge } debugPrint('[NativeBridge] applySnapshot call chars=${response.length}'); final result = _read(_applySnapshotWithResponse(response)); - debugPrint( - '[NativeBridge] applySnapshot returned chars=${result.length}', - ); + debugPrint('[NativeBridge] applySnapshot returned chars=${result.length}'); return result; } @@ -296,7 +347,9 @@ final class LogseqChatNativeBridge @override String applyHostUpdate({required String kind, required String payload}) { if (_runtimeScheduler.isCoreBusy) { - debugPrint('[NativeBridge] applyHostUpdate kind=$kind queued (core busy)'); + debugPrint( + '[NativeBridge] applyHostUpdate kind=$kind queued (core busy)', + ); _runtimeScheduler.runUi( () => _apply(_applyHostUpdateWithPayload(kind, payload)), ); diff --git a/flutter/lib/lui_dispatch.dart b/flutter/lib/lui_dispatch.dart index 1cfae841..ab06762b 100644 --- a/flutter/lib/lui_dispatch.dart +++ b/flutter/lib/lui_dispatch.dart @@ -10,6 +10,9 @@ abstract interface class LogseqChatNativeDispatch { void change(int node); void valueChanged(int node, double value); void dismiss(int node); + void picked(int node, String payload); + void scrollCompleted(int node, int token, String outcome); + void visibleRange(int node, int first, int last); void doublePress(int node); void extensionEvent( int node, @@ -40,6 +43,12 @@ void dispatchLUIEvent(LUIEvent event, LogseqChatNativeDispatch native) { native.valueChanged(node, value); case LUIDismissEvent(:final node): native.dismiss(node); + case LUIPickedEvent(:final node, :final payload): + native.picked(node, payload); + case LUIScrollCompletedEvent(:final node, :final token, :final outcome): + native.scrollCompleted(node, token, outcome); + case LUIVisibleRangeEvent(:final node, :final first, :final last): + native.visibleRange(node, first, last); case LUIDoublePressEvent(:final node): native.doublePress(node); case LUIExtensionComponentEvent( diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index f969aa76..cd1c0217 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -106,6 +106,7 @@ class _LogseqChatFlutterAppState extends State resolveAssetPath: _platformEffects.platform.resolveAssetPath, ), appIcons: logseqChatAppIcons, + filePathResolver: _platformEffects.platform.resolveAssetPath, ); _patchApplier = FlutterPatchApplier(_backend); final commandHandler = AndroidOutlinerPlatformCommandHandler( diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index f628577a..3fae3b19 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -159,8 +159,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 5516602566127dd437c4d2ad9d1700c474b76970 - resolved-ref: 5516602566127dd437c4d2ad9d1700c474b76970 + ref: "1db993d4e2eaa2ec844620e374c736829798a2d0" + resolved-ref: "1db993d4e2eaa2ec844620e374c736829798a2d0" url: "ssh://git@github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index f5540679..bc6e84fc 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: lui_flutter_backend: git: url: ssh://git@github.com/logseq/lui.git - ref: 5516602566127dd437c4d2ad9d1700c474b76970 + ref: 1db993d4e2eaa2ec844620e374c736829798a2d0 path: platform/flutter webview_flutter: ^4.14.1 diff --git a/flutter/test/logseq_chat_extensions_test.dart b/flutter/test/logseq_chat_extensions_test.dart index cf82a585..94f37d38 100644 --- a/flutter/test/logseq_chat_extensions_test.dart +++ b/flutter/test/logseq_chat_extensions_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logseq_chat_flutter/logseq_chat_extensions.dart'; +import 'package:logseq_chat_flutter/logseq_chat_icons.dart'; import 'package:logseq_chat_flutter/logseq_chat_theme.dart'; import 'package:lui_flutter_backend/lui_flutter_backend.dart'; @@ -433,6 +434,7 @@ void main() { final events = []; final backend = LUIFlutterBackend( extensionRegistry: logseqChatExtensionRegistry(), + appIcons: logseqChatAppIcons, onEvent: events.add, ); addTearDown(backend.dispose); @@ -444,9 +446,13 @@ void main() { expect(find.byType(PopScope), findsWidgets); expect(find.byType(SearchBar), findsOneWidget); - expect(find.byType(PopupMenuButton), findsOneWidget); + expect(find.byType(MenuAnchor), findsWidgets); final overflow = tester.getSemantics( - find.byKey(const ValueKey('overflow-menu')), + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.identifier == 'button.overflow-menu', + ), ); expect(overflow.identifier, 'button.overflow-menu'); expect(find.byType(TextField), findsWidgets); @@ -582,6 +588,7 @@ void main() { testWidgets('overflow menu uses Android action icons', (tester) async { final backend = LUIFlutterBackend( extensionRegistry: logseqChatExtensionRegistry(), + appIcons: logseqChatAppIcons, ); addTearDown(backend.dispose); backend.applyJson(jsonEncode(_extensionPatch)); @@ -590,11 +597,7 @@ void main() { ); expect(find.byIcon(Icons.more_vert_rounded), findsOneWidget); - tester - .state>( - find.byType(PopupMenuButton), - ) - .showButtonMenu(); + await tester.tap(find.byIcon(Icons.more_vert_rounded)); await tester.pumpAndSettle(); expect(find.byIcon(Icons.star_outline_rounded), findsOneWidget); @@ -609,20 +612,22 @@ void main() { 'generation': 2, 'ops': [ { - 'op': 'set-extension-prop', - 'id': 20, - 'property': 'favorite-label', + 'op': 'set-prop', + 'id': 22, + 'property': 'text', 'value': 'Unfavorite', }, + { + 'op': 'set-prop', + 'id': 22, + 'property': 'icon', + 'value': 'app:star-filled', + }, ], }), ); await tester.pump(); - tester - .state>( - find.byType(PopupMenuButton), - ) - .showButtonMenu(); + await tester.tap(find.byIcon(Icons.more_vert_rounded)); await tester.pumpAndSettle(); expect(find.byIcon(Icons.star_rounded), findsOneWidget); @@ -910,10 +915,16 @@ void main() { ); addTearDown(backend.dispose); final listItemPatch = Map.from(_richMarkupPatch); + final richOps = + (_richMarkupPatch['ops']! as List>).skip(1).toList() + ..removeLast(); listItemPatch['ops'] = [ {'op': 'create-node', 'id': 1, 'kind': 'list-item'}, {'op': 'set-prop', 'id': 1, 'property': 'press-enabled', 'value': true}, - ...(_richMarkupPatch['ops']! as List>).skip(1), + {'op': 'create-node', 'id': 26, 'kind': 'column'}, + ...richOps, + {'op': 'insert-child', 'parent': 26, 'child': 2, 'index': 0}, + {'op': 'insert-child', 'parent': 1, 'child': 26, 'index': 0}, ]; backend.applyJson(jsonEncode(listItemPatch)); await tester.pumpWidget( @@ -1140,12 +1151,6 @@ const _searchFingerprint = '5:title:string:required:none,9:presented:bool:required:none|events:' '13:query-changed[5:query:string:required],4:back[5:count:int:required],' '7:dismiss[]'; -const _overflowFingerprint = - 'lui-extension-v1|20:native-overflow-menu|profiles:android/flutter,' - 'ios/swiftui|standard-children:0|children:|' - 'properties:14:favorite-label:string:required:none,16:settings-visible:bool:' - 'required:none,20:page-actions-visible:bool:required:none|events:5:share[],' - '6:delete[],8:favorite[],8:settings[]'; final _editorPatch = { 'generation': 1, @@ -1298,24 +1303,57 @@ final _extensionPatch = { (key, value) => MapEntry(key, value is int ? value + 10 : value), ), ), + {'op': 'create-node', 'id': 20, 'kind': 'menu-trigger'}, { - 'op': 'create-extension', + 'op': 'set-prop', 'id': 20, - 'identifier': 'native-overflow-menu', - 'fingerprint': _overflowFingerprint, + 'property': 'icon', + 'value': 'app:more-vert', }, - for (final entry in { - 'page-actions-visible': true, - 'favorite-label': 'Favorite', - 'settings-visible': true, - }.entries) + { + 'op': 'set-prop', + 'id': 20, + 'property': 'accessibility-label', + 'value': 'More', + }, + { + 'op': 'set-prop', + 'id': 20, + 'property': 'accessibility-identifier', + 'value': 'button.overflow-menu', + }, + {'op': 'create-node', 'id': 21, 'kind': 'dropdown-menu'}, + {'op': 'insert-child', 'parent': 20, 'child': 21, 'index': 0}, + for (final item in [ + (22, 'Favorite', 'app:star', ''), + (23, 'Share', 'app:share', ''), + (24, 'Delete', 'app:toolbar-delete', 'destructive'), + (25, 'Settings', 'app:settings', ''), + ]) ...[ + {'op': 'create-node', 'id': item.$1, 'kind': 'menu-item'}, + {'op': 'set-prop', 'id': item.$1, 'property': 'text', 'value': item.$2}, { - 'op': 'set-extension-prop', - 'id': 20, - 'property': entry.key, - 'value': entry.value, + 'op': 'set-prop', + 'id': item.$1, + 'property': 'icon', + 'value': item.$3, }, - for (final id in [2, 4, 5, 12, 20]) + if (item.$4.isNotEmpty) + { + 'op': 'set-prop', + 'id': item.$1, + 'property': 'variant', + 'value': item.$4, + }, + { + 'op': 'set-prop', + 'id': item.$1, + 'property': 'press-enabled', + 'value': true, + }, + {'op': 'insert-child', 'parent': 21, 'child': item.$1, 'index': 0}, + ], + for (final id in [20, 2, 4, 5, 12]) {'op': 'insert-child', 'parent': 30, 'child': id, 'index': 0}, {'op': 'insert-child', 'parent': 1, 'child': 30, 'index': 0}, ], diff --git a/flutter/test/logseq_chat_icons_test.dart b/flutter/test/logseq_chat_icons_test.dart index 452eb5c3..93905d9c 100644 --- a/flutter/test/logseq_chat_icons_test.dart +++ b/flutter/test/logseq_chat_icons_test.dart @@ -4,12 +4,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logseq_chat_flutter/logseq_chat_icons.dart'; +Iterable _viewSource() => + Directory('../shared/src/logseq_chat') + .listSync() + .whereType() + .where((file) => file.path.endsWith('.ml')) + .map((file) => file.readAsStringSync()); + void main() { test('maps every app icon emitted by the LG view', () { - final source = File('../shared/src/logseq_chat/view.cljc').readAsStringSync(); - final emittedNames = RegExp(r'"app:([a-z0-9]+(?:-[a-z0-9]+)*)"') - .allMatches(source) - .map((match) => match.group(1)!) + final emittedNames = _viewSource() + .expand( + (source) => RegExp( + r'"app:([a-z0-9]+(?:-[a-z0-9]+)*)"|`app "([a-z0-9]+(?:-[a-z0-9]+)*)"', + ) + .allMatches(source) + .map((match) => match.group(1) ?? match.group(2)!), + ) .toSet(); expect(emittedNames, isNotEmpty); @@ -22,10 +33,12 @@ void main() { }); test('routes every literal LG icon through the cross-platform app map', () { - final source = File('../shared/src/logseq_chat/view.cljc').readAsStringSync(); - final bareNames = RegExp(r':icon "(?!app:)([a-z0-9-]+)"') - .allMatches(source) - .map((match) => match.group(1)!) + final bareNames = _viewSource() + .expand( + (source) => RegExp(r'~(?:icon|name):\s*"(?!app:)([a-z0-9-]+)"') + .allMatches(source) + .map((match) => match.group(1)!), + ) .toSet(); expect( diff --git a/flutter/test/lui_dispatch_test.dart b/flutter/test/lui_dispatch_test.dart index 0c061e1c..937480a6 100644 --- a/flutter/test/lui_dispatch_test.dart +++ b/flutter/test/lui_dispatch_test.dart @@ -71,9 +71,20 @@ final class _RecordingNativeDispatch implements LogseqChatNativeDispatch { @override void longPress(int node) => events.add('long:$node'); + @override + void picked(int node, String payload) => events.add('picked:$node:$payload'); + @override void press(int node) => events.add('press:$node'); + @override + void scrollCompleted(int node, int token, String outcome) => + events.add('scroll:$node:$token:$outcome'); + + @override + void visibleRange(int node, int first, int last) => + events.add('visible:$node:$first:$last'); + @override void submit(int node) => events.add('submit:$node'); diff --git a/flutter/test/native_effect_drain_test.dart b/flutter/test/native_effect_drain_test.dart index f1b84637..2a4c27be 100644 --- a/flutter/test/native_effect_drain_test.dart +++ b/flutter/test/native_effect_drain_test.dart @@ -148,17 +148,23 @@ void main() { await drain.drain(); - expect(traces, hasLength(2)); - expect(traces.first, 'start id=14 kind=open-graph'); + expect(traces, hasLength(5)); + expect(traces[0], 'empty patch from dispatch kind=open-graph'); + expect(traces[1], 'start id=14 kind=open-graph'); expect( - traces.last, + traces[2], startsWith( 'finish id=14 kind=open-graph succeeded=false output=discard ' 'elapsedMs=', ), ); - expect(traces.last, contains('message=graph_open_failed')); - expect(traces.last, contains('Snapshot download timed out')); + expect(traces[2], contains('message=graph_open_failed')); + expect(traces[2], contains('Snapshot download timed out')); + expect( + traces[3], + startsWith('applying patch from resolveEffect id=14 succeeded=false'), + ); + expect(traces[4], 'applied patch from resolveEffect id=14 succeeded=false'); }); test('traces search response query and result count without result text', () async { @@ -187,9 +193,12 @@ void main() { await drain.drain(); - expect(traces.last, contains('searchQueryChars=13 searchResults=2')); - expect(traces.last, isNot(contains('private query'))); - expect(traces.last, isNot(contains('sensitive result'))); + final finish = traces.firstWhere( + (trace) => trace.startsWith('finish id=15'), + ); + expect(finish, contains('searchQueryChars=13 searchResults=2')); + expect(finish, isNot(contains('private query'))); + expect(finish, isNot(contains('sensitive result'))); }); test('preserves optional effect values for platform execution', () async { diff --git a/flutter/test/search_layout_contract_test.dart b/flutter/test/search_layout_contract_test.dart index d2343a3f..4827b694 100644 --- a/flutter/test/search_layout_contract_test.dart +++ b/flutter/test/search_layout_contract_test.dart @@ -2,11 +2,22 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +String _viewSource() { + final directory = Directory('../shared/src/logseq_chat'); + final files = directory + .listSync() + .whereType() + .where((file) => file.path.endsWith('.ml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + return files.map((file) => file.readAsStringSync()).join('\n'); +} + void main() { test('Flutter search results list fills the remaining search surface', () { - final source = File('../shared/src/logseq_chat/view.cljc').readAsStringSync(); + final source = _viewSource(); final searchResultsList = RegExp( - r'\[:list\s+\{(?=[^}]*:accessibility-identifier\s+"screen\.search\.results")(?=[^}]*:grow\s+1\.0)[^}]*\}', + r'\blist\b(?=[\s\S]{0,200}~accessibility_identifier:\s*"screen\.search\.results")(?=[\s\S]{0,200}~grow:\s*1\.0)', multiLine: true, ); diff --git a/logseq_chat.opam b/logseq_chat.opam index c99b0e2c..97a02388 100644 --- a/logseq_chat.opam +++ b/logseq_chat.opam @@ -31,7 +31,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#edf381b"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#a2d1cfa"] ["drive.dev" "git+https://github.com/logseq/drive.git#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#868c1459f865b4ba3b227eb440dba40f1c812899"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#553dea6ed8694352527a189747f787365469c9cb"] diff --git a/shared/native/logseq_chat_core_ffi.c b/shared/native/logseq_chat_core_ffi.c index 375b282c..12a8911a 100644 --- a/shared/native/logseq_chat_core_ffi.c +++ b/shared/native/logseq_chat_core_ffi.c @@ -369,6 +369,71 @@ static const char *call_lui_extension_event( CAMLreturnT(const char *, response); } +static const char *call_lui_node_text(const char *name, int64_t node, + const char *text) { + const char *response; + CAMLparam0(); + CAMLlocal2(text_value, result); + const value *callback = caml_named_value(name); + if (callback == NULL) { + response = lui_no_callback(name); + } else if (text == NULL) { + response = lui_bad_argument(name); + } else { + text_value = caml_copy_string(text); + result = caml_callback2_exn(*callback, Val_long(node), text_value); + response = Is_exception_result(result) + ? lui_exception(name, result) + : replace_response(String_val(result)); + } + CAMLreturnT(const char *, response); +} + +static const char *call_lui_visible_range(int64_t node, int64_t first, + int64_t last) { + const char *response; + CAMLparam0(); + CAMLlocal1(result); + const value *callback = caml_named_value("logseq_chat_lui_visible_range"); + if (callback == NULL) { + response = lui_no_callback("logseq_chat_lui_visible_range"); + } else { + result = caml_callback3_exn( + *callback, + Val_long(node), + Val_long(first), + Val_long(last)); + response = Is_exception_result(result) + ? lui_exception("logseq_chat_lui_visible_range", result) + : replace_response(String_val(result)); + } + CAMLreturnT(const char *, response); +} + +static const char *call_lui_scroll_completed(int64_t node, int64_t token, + const char *outcome) { + const char *response; + CAMLparam0(); + CAMLlocal2(outcome_value, result); + const value *callback = caml_named_value("logseq_chat_lui_scroll_completed"); + if (callback == NULL) { + response = lui_no_callback("logseq_chat_lui_scroll_completed"); + } else if (outcome == NULL) { + response = lui_bad_argument("logseq_chat_lui_scroll_completed"); + } else { + outcome_value = caml_copy_string(outcome); + result = caml_callback3_exn( + *callback, + Val_long(node), + Val_long(token), + outcome_value); + response = Is_exception_result(result) + ? lui_exception("logseq_chat_lui_scroll_completed", result) + : replace_response(String_val(result)); + } + CAMLreturnT(const char *, response); +} + #define LUI_RUNTIME_CALL(entry_name, expression) \ LOGSEQ_CHAT_LOG("lui %s enter", entry_name); \ int registration = acquire_ocaml_runtime(); \ @@ -437,6 +502,25 @@ const char *logseq_chat_lui_extension_event( call_lui_extension_event(node, identifier, name, text, value)); } +const char *logseq_chat_lui_picked(int64_t node, const char *payload) { + LUI_RUNTIME_CALL( + "picked", call_lui_node_text("logseq_chat_lui_picked", node, payload)); +} + +const char *logseq_chat_lui_visible_range(int64_t node, int64_t first, + int64_t last) { + LUI_RUNTIME_CALL( + "visible_range", + call_lui_visible_range(node, first, last)); +} + +const char *logseq_chat_lui_scroll_completed(int64_t node, int64_t token, + const char *outcome) { + LUI_RUNTIME_CALL( + "scroll_completed", + call_lui_scroll_completed(node, token, outcome)); +} + int64_t logseq_chat_lui_root_node(void) { int64_t node = -1; LOGSEQ_CHAT_LOG("lui root_node enter"); diff --git a/shared/native/logseq_chat_core_ffi.h b/shared/native/logseq_chat_core_ffi.h index 304c326e..60f2bf8d 100644 --- a/shared/native/logseq_chat_core_ffi.h +++ b/shared/native/logseq_chat_core_ffi.h @@ -26,6 +26,11 @@ const char *logseq_chat_lui_double_press(int64_t node); const char *logseq_chat_lui_extension_event( int64_t node, const char *identifier, const char *name, const char *text, int64_t value); +const char *logseq_chat_lui_picked(int64_t node, const char *payload); +const char *logseq_chat_lui_visible_range(int64_t node, int64_t first, + int64_t last); +const char *logseq_chat_lui_scroll_completed(int64_t node, int64_t token, + const char *outcome); int64_t logseq_chat_lui_root_node(void); const char *logseq_chat_lui_dispose(void); const char *logseq_chat_lui_take_effect(void); diff --git a/shared/src/logseq_chat/core/host_update.ml b/shared/src/logseq_chat/core/host_update.ml index ce3f03cf..96298ae1 100644 --- a/shared/src/logseq_chat/core/host_update.ml +++ b/shared/src/logseq_chat/core/host_update.ml @@ -57,6 +57,7 @@ type host_update = | Authentication of host_authentication | Open_quick_action of string | Open_capture + | Asset_preview_resolved of string * string let string_field name value = Util.to_string (Util.member name value) let bool_field name value = Util.to_bool (Util.member name value) @@ -134,6 +135,10 @@ let decode kind payload = }) | "open-quick-action" -> Ok (Open_quick_action (Util.to_string value)) | "open-capture" -> Ok Open_capture + | "asset-preview-resolved" -> + Ok + (Asset_preview_resolved + (string_field "title" value, string_field "path" value)) | _ -> Error ("Unsupported host update: " ^ kind) with | Yojson.Json_error message -> Error ("Invalid host update JSON: " ^ message) diff --git a/shared/src/logseq_chat/core/host_update.mli b/shared/src/logseq_chat/core/host_update.mli index ae34a5c9..a72139f9 100644 --- a/shared/src/logseq_chat/core/host_update.mli +++ b/shared/src/logseq_chat/core/host_update.mli @@ -57,5 +57,6 @@ type host_update = | Authentication of host_authentication | Open_quick_action of string | Open_capture + | Asset_preview_resolved of string * string val decode : string -> string -> (host_update, string) result diff --git a/shared/src/logseq_chat/core/markup.ml b/shared/src/logseq_chat/core/markup.ml index 4601547c..de59ddef 100644 --- a/shared/src/logseq_chat/core/markup.ml +++ b/shared/src/logseq_chat/core/markup.ml @@ -335,13 +335,13 @@ and convert_node source references tags node position = (match find_summary references (node_name link) with | Some summary -> [ Markup_node_ref (summary.Cache_model.uuid, summary.Cache_model.title) ] - | None -> raw) + | None -> [ Markup_text (node_name link) ]) | Inline.Tag children -> let value = S.trim (String.concat "" (List.map tag_part children)) in (match find_summary tags value with | Some summary -> [ Markup_tag_ref (summary.Cache_model.uuid, summary.Cache_model.title) ] - | None -> raw) + | None -> [ Markup_text ("#" ^ value) ]) | Inline.Link link -> (match link.url with | Inline.Block_ref _ -> raw @@ -352,7 +352,12 @@ and convert_node source references tags node position = Markup_node_ref (summary.Cache_model.uuid, summary.Cache_model.title); ] - | None -> raw) + | None -> + (match link.label with + | [] | [ Inline.Plain "" ] -> [ Markup_text value ] + | label -> + convert_nodes source references tags + (List.map (fun child -> (child, None)) label))) | _ -> [ Markup_link diff --git a/shared/src/logseq_chat/model.ml b/shared/src/logseq_chat/model.ml index 06878feb..bdcfe7a8 100644 --- a/shared/src/logseq_chat/model.ml +++ b/shared/src/logseq_chat/model.ml @@ -19,6 +19,14 @@ type composer_asset = ; payload : string } +(* The asset currently shown by the file-preview node (QuickLook on Apple). + The node stays mounted while this is [Some]; the backend's Dismiss event + clears it through [DismissAssetPreview]. *) +type asset_preview = + { preview_title : string + ; preview_path : string + } + type ui_session = { graph_id : string option ; destination : primary_destination @@ -309,6 +317,7 @@ type chat_model = ; new_graph_encrypted : bool ; pending_graph_deletion : graph option ; pending_page_deletion : sidebar_page option + ; pending_sign_out : bool ; connection_menu_open : bool ; settings_open : bool ; settings_tabs_open : bool @@ -357,6 +366,7 @@ type chat_model = ; app_navigation_previews : node_projection list ; app_navigation_path : navigation_route list ; search_navigation_path : navigation_route list + ; asset_preview : asset_preview option } type chat_action = @@ -380,6 +390,7 @@ type chat_action = | PerformOutlinerToolbarAction of string | ChooseOutlinerAutocomplete of string | OpenOutlinerAsset of string + | DismissAssetPreview | CloseSearch | ExpandComposer | FocusComposer @@ -393,6 +404,7 @@ type chat_action = | RemoveComposerAsset of string | DequeueEffect of int | ResolveEffect of int * bool * string + | ResolveAssetPreview of string * string | OpenAttachmentPicker | CloseAttachmentPicker | ChooseAttachment of string @@ -473,6 +485,8 @@ type chat_action = | ApplyRuntimeLog of runtime_log_record list | RefreshRuntimeLog | CopyRuntimeLog + | RequestSignOut + | CancelSignOut | SignOut | RevealFlashcardCloze | RevealFlashcardAnswer @@ -605,6 +619,7 @@ let initial () = new_graph_encrypted = true; pending_graph_deletion = None; pending_page_deletion = None; + pending_sign_out = false; connection_menu_open = false; settings_open = false; settings_tabs_open = false; @@ -650,6 +665,7 @@ let initial () = last_core_response = None; attachment_picker_open = false; task_status_picker_open = false; + asset_preview = None; app_navigation_previews = []; app_navigation_path = []; search_navigation_path = []; @@ -1751,6 +1767,13 @@ let rec update (current : chat_model) action = | None -> current else current | None -> current) + | ResolveAssetPreview (title, path) -> + { + current with + asset_preview = + Some { preview_title = title; preview_path = path }; + } + | DismissAssetPreview -> { current with asset_preview = None } | CloseSearch -> let path = current.search_navigation_path in let editing_ended = @@ -2411,9 +2434,15 @@ let rec update (current : chat_model) action = let id = current.next_effect_id in enqueue_effect current (CopyRuntimeLogEffect (id, current.runtime_log_records)) + | RequestSignOut -> + { current with pending_sign_out = true; settings_open = false } + | CancelSignOut -> + { current with pending_sign_out = false; settings_open = true } | SignOut -> let id = current.next_effect_id in - enqueue_effect current (SignOutEffect id) + enqueue_effect + { current with pending_sign_out = false } + (SignOutEffect id) | RevealFlashcardCloze -> { current with flashcard_cloze_revealed = true } | RevealFlashcardAnswer -> diff --git a/shared/src/logseq_chat/model.mli b/shared/src/logseq_chat/model.mli index 526137e4..a9e814f3 100644 --- a/shared/src/logseq_chat/model.mli +++ b/shared/src/logseq_chat/model.mli @@ -19,6 +19,11 @@ type composer_asset = ; payload : string } +type asset_preview = + { preview_title : string + ; preview_path : string + } + type ui_session = { graph_id : string option ; destination : primary_destination @@ -309,6 +314,7 @@ type chat_model = ; new_graph_encrypted : bool ; pending_graph_deletion : graph option ; pending_page_deletion : sidebar_page option + ; pending_sign_out : bool ; connection_menu_open : bool ; settings_open : bool ; settings_tabs_open : bool @@ -357,6 +363,7 @@ type chat_model = ; app_navigation_previews : node_projection list ; app_navigation_path : navigation_route list ; search_navigation_path : navigation_route list + ; asset_preview : asset_preview option } type chat_action = @@ -380,6 +387,7 @@ type chat_action = | PerformOutlinerToolbarAction of string | ChooseOutlinerAutocomplete of string | OpenOutlinerAsset of string + | DismissAssetPreview | CloseSearch | ExpandComposer | FocusComposer @@ -393,6 +401,7 @@ type chat_action = | RemoveComposerAsset of string | DequeueEffect of int | ResolveEffect of int * bool * string + | ResolveAssetPreview of string * string | OpenAttachmentPicker | CloseAttachmentPicker | ChooseAttachment of string @@ -473,6 +482,8 @@ type chat_action = | ApplyRuntimeLog of runtime_log_record list | RefreshRuntimeLog | CopyRuntimeLog + | RequestSignOut + | CancelSignOut | SignOut | RevealFlashcardCloze | RevealFlashcardAnswer diff --git a/shared/src/logseq_chat/native_bridge.ml b/shared/src/logseq_chat/native_bridge.ml index 71f28ebe..6310110d 100644 --- a/shared/src/logseq_chat/native_bridge.ml +++ b/shared/src/logseq_chat/native_bridge.ml @@ -99,6 +99,8 @@ let host_action (update : Host_update.host_update) = (authentication.state, authentication.error_message) | Open_quick_action kind -> Model.OpenQuickAction kind | Open_capture -> Model.ExpandComposer + | Asset_preview_resolved (title, path) -> + Model.ResolveAssetPreview (title, path) let apply_host_update kind payload = flush_action @@ -420,6 +422,14 @@ let dismiss node = flush_event (Dismiss node) let double_press node = flush_event (DoublePress node) +let picked node payload = flush_event (Picked (node, payload)) + +let visible_range node first last = + flush_event (VisibleRange (node, first, last)) + +let scroll_completed node token outcome = + flush_event (ScrollCompleted (node, token, outcome)) + let extension_event node identifier name text value = let open Lui_protocol in let values = @@ -477,6 +487,9 @@ let () = Callback.register "logseq_chat_lui_value_changed" value_changed; Callback.register "logseq_chat_lui_dismiss" dismiss; Callback.register "logseq_chat_lui_double_press" double_press; + Callback.register "logseq_chat_lui_picked" picked; + Callback.register "logseq_chat_lui_visible_range" visible_range; + Callback.register "logseq_chat_lui_scroll_completed" scroll_completed; Callback.register "logseq_chat_lui_extension_event" extension_event; Callback.register "logseq_chat_lui_dispose" dispose; Callback.register "logseq_chat_lui_root_node" root_node; diff --git a/shared/src/logseq_chat/native_bridge.mli b/shared/src/logseq_chat/native_bridge.mli index 2e2cd6d2..b7d4af4a 100644 --- a/shared/src/logseq_chat/native_bridge.mli +++ b/shared/src/logseq_chat/native_bridge.mli @@ -72,6 +72,12 @@ val dismiss : int -> string val double_press : int -> string +val picked : int -> string -> string + +val visible_range : int -> int -> int -> string + +val scroll_completed : int -> int -> string -> string + val extension_event : int -> string -> string -> string -> int -> string val dispose : unit -> string diff --git a/shared/src/logseq_chat/view.ml b/shared/src/logseq_chat/view.ml index 02ba9e25..2de96ae4 100644 --- a/shared/src/logseq_chat/view.ml +++ b/shared/src/logseq_chat/view.ml @@ -9,6 +9,7 @@ let chat_theme_tokens : (string * Lui_ui.theme_token_value) list = [ ("background", Adaptive { light = "#FCFCFC"; dark = "#002D38" }); ("surface", Adaptive { light = "#F8F8F8"; dark = "#19394D" }); + ("card", Adaptive { light = "#FFFFFF"; dark = "#1C4556" }); ( "autocomplete-row-background", Adaptive { light = "#6F6F6F1A"; dark = "#9BD3D41A" } ); ("task-backlog", Fixed "#A8A39E"); @@ -34,18 +35,6 @@ let chat_view (context : Lui_ui.ui_context) model_source send = ~mode_signal:(Signal.map chat_theme_mode model_source) (View_screens.chat_view context model_source send) -let composer_asset_schema () = - Lui_extension.component "composer-asset" - [ Lui_protocol.profile Lui_protocol.IOS Lui_protocol.SwiftUIHost; - Lui_protocol.profile Lui_protocol.AndroidOS Lui_protocol.FlutterHost ] - false [] - [ - Lui_extension.property "title" Lui_extension.StringScalar true None; - Lui_extension.property "local-path" Lui_extension.StringScalar true - None; - ] - [] - let outliner_editor_schema () = let open Lui_protocol in let open Lui_extension in @@ -147,24 +136,6 @@ let native_search_presentation_schema () = [ Lui_extension.event_field "query" StringScalar true ]; ] -let native_overflow_menu_schema () = - let open Lui_protocol in - let open Lui_extension in - Lui_extension.component "native-overflow-menu" - [ profile IOS SwiftUIHost; profile AndroidOS FlutterHost ] - false [] - [ - Lui_extension.property "page-actions-visible" BoolScalar true None; - Lui_extension.property "favorite-label" StringScalar true None; - Lui_extension.property "settings-visible" BoolScalar true None; - ] - [ - Lui_extension.event "favorite" []; - Lui_extension.event "share" []; - Lui_extension.event "delete" []; - Lui_extension.event "settings" []; - ] - let liquid_glass_schema () = let open Lui_protocol in let open Lui_extension in @@ -176,12 +147,9 @@ let extension_registry () = Lui_extension.register_component registry (outliner_editor_schema ()); Lui_extension.register_component registry (outliner_block_content_schema ()); - Lui_extension.register_component registry (composer_asset_schema ()); Lui_extension.register_component registry (native_navigation_stack_schema ()); Lui_extension.register_component registry (native_search_presentation_schema ()); - Lui_extension.register_component registry - (native_overflow_menu_schema ()); Lui_extension.register_tweak registry (liquid_glass_schema ()); registry diff --git a/shared/src/logseq_chat/view.mli b/shared/src/logseq_chat/view.mli index fa0b5458..6b6e0974 100644 --- a/shared/src/logseq_chat/view.mli +++ b/shared/src/logseq_chat/view.mli @@ -5,9 +5,6 @@ val chat_view : Model.chat_model Signal.signal -> (Model.chat_action -> bool) -> Lui_elements.t -val composer_asset_schema : - unit -> Lui_extension.extension_component_schema - val outliner_editor_schema : unit -> Lui_extension.extension_component_schema @@ -20,9 +17,6 @@ val native_navigation_stack_schema : val native_search_presentation_schema : unit -> Lui_extension.extension_component_schema -val native_overflow_menu_schema : - unit -> Lui_extension.extension_component_schema - val liquid_glass_schema : unit -> Lui_extension.extension_component_schema diff --git a/shared/src/logseq_chat/view_base.ml b/shared/src/logseq_chat/view_base.ml index 5d993375..365ed284 100644 --- a/shared/src/logseq_chat/view_base.ml +++ b/shared/src/logseq_chat/view_base.ml @@ -49,15 +49,6 @@ let handle_native_search_event input_event send = send (Model.ChangeSearchQuery (extension_string values "query")) | _ -> true -let handle_native_overflow_menu_event input_event send = - match input_event with - | ExtensionEvent (_node, _identifier, name, _values) -> - if name = "favorite" then send Model.ToggleActivePageFavorite - else if name = "share" then send Model.ShareActivePage - else if name = "delete" then send Model.RequestDeleteActivePage - else if name = "settings" then send Model.OpenSettings - else true - | _ -> true let handle_outliner_editor_event input_event block_id_source send = match input_event with @@ -219,6 +210,9 @@ let active_page_favorite_label (current : Model.chat_model) = let page_deletion_pending_ (current : Model.chat_model) = current.pending_page_deletion <> None +let sign_out_pending_ (current : Model.chat_model) = + current.pending_sign_out + let sidebar_page_identifier (page : Model.sidebar_page) = "link.sidebar.page." ^ page.uuid @@ -885,6 +879,39 @@ let composer_asset_identifier (asset : Model.composer_asset) = let composer_assets_present_ (current : Model.chat_model) = current.composer_assets <> [] +(* Matches the platform image-asset policy: extensions the Apple backend can + thumbnail through CGImageSource (including PDF and TIFF first pages). The + Flutter image codec cannot decode PDF or TIFF, so those fall back to the + document tile there. *) +let composer_asset_is_image (context : Lui_ui.ui_context) + (asset : Model.composer_asset) = + let extension = + match String.rindex_opt asset.local_path '.' with + | Some index when index < String.length asset.local_path - 1 -> + String.lowercase_ascii + (String.sub asset.local_path (index + 1) + (String.length asset.local_path - index - 1)) + | _ -> "" + in + let flutter_image_codecs = + Lui_ui.host context = Lui_protocol.FlutterHost + in + List.mem extension + (if flutter_image_codecs then + [ "png"; "jpg"; "jpeg"; "gif"; "webp"; "bmp"; "wbmp"; "heic"; "heif" + ; "avif" ] + else + [ "png"; "jpg"; "jpeg"; "gif"; "webp"; "bmp"; "wbmp"; "heic"; "heif" + ; "avif"; "tif"; "tiff"; "pdf" ]) + +let asset_preview_ (current : Model.chat_model) = + current.asset_preview <> None + +let asset_preview_path (current : Model.chat_model) = + match current.asset_preview with + | Some preview -> preview.preview_path + | None -> "" + let composer_expanded_ (current : Model.chat_model) = current.composer_expanded let composer_draft (current : Model.chat_model) = current.composer_draft let composer_autofocus_ (current : Model.chat_model) = current.composer_autofocus diff --git a/shared/src/logseq_chat/view_base.mli b/shared/src/logseq_chat/view_base.mli index 0dc1de8d..d5aabaaa 100644 --- a/shared/src/logseq_chat/view_base.mli +++ b/shared/src/logseq_chat/view_base.mli @@ -16,8 +16,6 @@ val handle_native_navigation_event : Lui_protocol.event -> (Model.chat_action -> bool) -> bool val handle_native_search_event : Lui_protocol.event -> (Model.chat_action -> bool) -> bool -val handle_native_overflow_menu_event : - Lui_protocol.event -> (Model.chat_action -> bool) -> bool val handle_outliner_editor_event : Lui_protocol.event -> string Signal.signal -> (Model.chat_action -> bool) -> bool @@ -42,6 +40,7 @@ val active_page_actions_visible_ : Model.chat_model -> bool val connection_settings_visible_ : Model.chat_model -> bool val active_page_favorite_label : Model.chat_model -> string val page_deletion_pending_ : Model.chat_model -> bool +val sign_out_pending_ : Model.chat_model -> bool val sidebar_page_identifier : Model.sidebar_page -> string val sidebar_page_title : Model.sidebar_page -> string val sidebar_graph_identifier : Model.graph -> string @@ -233,7 +232,10 @@ val outliner_row_journal_ : Model.chat_model -> Model.outline_row -> bool val composer_asset_title : Model.composer_asset -> string val composer_asset_path : Model.composer_asset -> string val composer_asset_identifier : Model.composer_asset -> string +val composer_asset_is_image : Lui_ui.ui_context -> Model.composer_asset -> bool val composer_assets_present_ : Model.chat_model -> bool +val asset_preview_ : Model.chat_model -> bool +val asset_preview_path : Model.chat_model -> string val composer_expanded_ : Model.chat_model -> bool val composer_draft : Model.chat_model -> string val composer_autofocus_ : Model.chat_model -> bool diff --git a/shared/src/logseq_chat/view_composer.ml b/shared/src/logseq_chat/view_composer.ml index bb5e3b09..40a787ad 100644 --- a/shared/src/logseq_chat/view_composer.ml +++ b/shared/src/logseq_chat/view_composer.ml @@ -85,28 +85,25 @@ let composer_task_status_button (context : Lui_ui.ui_context) send : t = ~on_press:(press send Model.OpenTaskStatusPicker) [] -let composer_asset_preview asset_source : t = - fun context parent -> - let node = Lui_ui.extension context "composer-asset" in - attach context parent node; - Lui_ui.extension_property_signal context node "title" - (Signal.map - (fun (asset : Model.composer_asset) -> - View_base.string_wire_value (View_base.composer_asset_title asset)) - asset_source); - Lui_ui.extension_property_signal context node "local-path" - (Signal.map - (fun (asset : Model.composer_asset) -> - View_base.string_wire_value (View_base.composer_asset_path asset)) - asset_source); - node +let composer_asset_preview (context : Lui_ui.ui_context) asset_source : t = + let asset = Signal.sample asset_source in + let title = View_base.composer_asset_title asset in + let path = View_base.composer_asset_path asset in + if View_base.composer_asset_is_image context asset then + file_image ~path ~max_pixel_size:384 ~fit:`fill ~width:128 ~height:128 + ~corner_radius:12 ~background:"secondary" ~label:title [] + else + column ~width:128 ~height:128 ~main:`center ~cross:`center ~gap:4 + ~padding:4 ~background:"secondary" ~corner_radius:12 + [ icon ~name:(`app "composer-file") []; + text ~value:title ~style_class:"caption line-clamp-3" [] ] -let composer_asset_view asset_source send : t = +let composer_asset_view (context : Lui_ui.ui_context) asset_source send : t = let asset = Signal.sample asset_source in stack ~width:128 ~height:128 ~accessibility_identifier:(View_base.composer_asset_identifier asset) [ - composer_asset_preview asset_source; + composer_asset_preview context asset_source; column ~width:128 ~height:128 ~padding:4 ~main:`start [ row ~main:`end_ ~height:24 @@ -156,13 +153,107 @@ let task_status_picker_dialog model_source send : t = []); ] +let with_press handler (elem : t) : t = + fun context parent -> + let node = elem context parent in + enable context node Lui_protocol.PressEnabled; + register_press context node handler; + node + +(* The expanded composer keeps the surface spec from the previous lui pin + (edf381b): upstream restyled it with a content-hugging `composer-surface` + column, which changed the capture UI. This is that older spec verbatim, + minus the parts chat does not use. *) +let composer_send_button context ?send_icon send_disabled on_send : t = + let android_icon = Option.value send_icon ~default:(`send : icon) in + let apple_icon = Option.value send_icon ~default:(`arrow_up : icon) in + if Lui_ui.platform context = Lui_protocol.AndroidOS then + if Lui_ui.host context = Lui_protocol.FlutterHost then + button ~icon:android_icon ~variant:`primary ~size:`icon ~width:48 + ~height:48 ~label:"Send" ~accessibility_identifier:"button.send" + ?disabled_signal:send_disabled ~on_press:on_send [] + else + button ~icon:android_icon ~variant:`primary ~label:"Send" + ~accessibility_identifier:"button.send" ?disabled_signal:send_disabled + ~on_press:on_send ~text:"Send" [] + else + button ~icon:apple_icon ~variant:`ghost ~width:36 ~height:36 + ~background:"black" ~foreground:"white" ~corner_radius:18 ~label:"Send" + ~accessibility_identifier:"button.send" ?disabled_signal:send_disabled + ~on_press:on_send [] + +let composer_surface ?key ?accessibility_identifier ?attachments + ?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 () + : t = + fun context parent -> + let flutter = Lui_ui.host context = Lui_protocol.FlutterHost in + let attachment_strip = + match attachments with + | None -> [] + | Some content -> + let strip = + scroll ~orientation:`horizontal ~height:140 [ row ~gap:8 [ content ] ] + in + [ + (match attachments_visible_signal with + | None -> strip + | Some test -> if_ ~test strip); + ] + in + let field = + textarea ~min_height:36 ~style_class:"composer-input" ~placeholder + ~label:(match label with Some value -> value | None -> placeholder) + ?text ?text_signal ~autofocus ?submit_on_enter + ~accessibility_identifier:"field.composer" ?on_input ?on_submit [] + in + let field = + match autofocus_signal with + | None -> field + | Some signal_ -> View_base.with_bool_prop_signal Lui_protocol.Autofocus signal_ field + in + let send_button = + match on_send with + | None -> [] + | Some on_send -> + [ composer_send_button context ?send_icon send_disabled_signal on_send ] + in + let capsule = + column ?key ?accessibility_identifier ~grow:1.0 ~min_height:58 ~main:`end_ + ~gap:0 + ~padding_horizontal:(if flutter then 12 else 16) + ~padding_vertical:(if flutter then 12 else 8) + ~background:(if flutter then "surface-container-high" else "glass") + ~corner_radius:24 + ([ box ~height:6 ~accessibility_identifier:"spacer.composer.top" [] ] + @ attachment_strip + @ [ + field; + box ~height:8 + ~accessibility_identifier:"spacer.composer.field-controls" []; + row ~gap:8 ~height:44 ~cross:`center + ~accessibility_identifier:"row.composer.controls" + (actions + @ [ + spacer ~grow:1.0 + ~accessibility_identifier:"spacer.composer.controls" []; + ] + @ send_button); + ]) + in + (match on_press with + | None -> capsule + | Some handler -> with_press handler capsule) + context parent + let composer_view (context : Lui_ui.ui_context) model_source send : t = box ~accessibility_identifier:"surface.composer.root" ~grow:1.0 ~min_height:58 [ if_ ~test:(Signal.map View_base.composer_expanded_ model_source) - (Lui_element_combine.composer + (composer_surface ~placeholder:"Capture" ~label:"Capture" ~text_signal:(reactive View_base.composer_draft model_source) ~autofocus_signal: @@ -173,8 +264,8 @@ let composer_view (context : Lui_ui.ui_context) model_source send : t = (Signal.map View_base.model_composer_assets model_source) ~key:View_base.composer_asset_identifier ~cmp:compare ~mount:(fun asset_source -> - composer_asset_view asset_source send)) - ~attachments_visible: + composer_asset_view context asset_source send)) + ~attachments_visible_signal: (reactive View_base.composer_assets_present_ model_source) ~actions: [ @@ -203,7 +294,7 @@ let composer_view (context : Lui_ui.ui_context) model_source send : t = ~send_icon: (if Lui_ui.platform context = AndroidOS then `app "send" else `app "arrow-up") - ~send_disabled: + ~send_disabled_signal: (reactive View_base.composer_send_disabled_ model_source) ~on_input:(on_input send (fun text -> Model.ChangeComposerDraft text)) diff --git a/shared/src/logseq_chat/view_composer.mli b/shared/src/logseq_chat/view_composer.mli index a773c2f6..1380ba60 100644 --- a/shared/src/logseq_chat/view_composer.mli +++ b/shared/src/logseq_chat/view_composer.mli @@ -4,10 +4,12 @@ val composer_attachment_button : val composer_task_status_button : Lui_ui.ui_context -> (Model.chat_action -> bool) -> Lui_elements.t val composer_asset_preview : - Model.composer_asset Signal.signal -> Lui_elements.t + Lui_ui.ui_context -> Model.composer_asset Signal.signal -> Lui_elements.t val composer_asset_view : + Lui_ui.ui_context -> Model.composer_asset Signal.signal -> - (Model.chat_action -> 'a) -> Lui_elements.t + (Model.chat_action -> 'a) -> + Lui_elements.t val task_status_row : Model.task_status Signal.signal -> (Model.chat_action -> 'a) -> Lui_elements.t diff --git a/shared/src/logseq_chat/view_graphs.ml b/shared/src/logseq_chat/view_graphs.ml index 70338d15..1d2d7fa2 100644 --- a/shared/src/logseq_chat/view_graphs.ml +++ b/shared/src/logseq_chat/view_graphs.ml @@ -323,42 +323,50 @@ let graph_delete_dialog (context : Lui_ui.ui_context) model_source send : t ]; ] else - dialog ~text:"Delete local graph" + (* Text-and-buttons-only children keep the dialog on the native alert + path (rich children fall back to a custom sheet). *) + dialog ~text:"Delete local graph" ~style_class:"alert" + ~accessibility_identifier:"dialog.graph-delete" ~on_dismiss:(press send Model.CancelDeleteGraph) [ - column - [ - text - ~value_signal: - (Signal.map View_base.graph_deletion_message model_source) - ~accessibility_identifier:"text.graph-delete-warning" []; - text - ~value: - "\xE2\x9A\xA0\xEF\xB8\x8F Notice that we can't recover \ - this graph after being deleted. Make sure you have \ - backups before deleting it." - []; - button ~on_press:(press send Model.CancelDeleteGraph) - ~text:"Cancel" []; - button ~on_press:(press send Model.ConfirmDeleteGraph) - ~text:"Confirm" []; - ]; + text + ~value_signal: + (Signal.map View_base.graph_deletion_message model_source) + ~accessibility_identifier:"text.graph-delete-warning" []; + text + ~value: + "Notice that we can't recover this graph after being \ + deleted. Make sure you have backups before deleting it." + []; + button ~accessibility_identifier:"button.graph-delete.cancel" + ~on_press:(press send Model.CancelDeleteGraph) + ~text:"Cancel" []; + button ~variant:`destructive + ~accessibility_identifier:"button.graph-delete.confirm" + ~on_press:(press send Model.ConfirmDeleteGraph) + ~text:"Delete" []; ] let graph_picker_overflow_menu send : t = fun context parent -> - let node = Lui_ui.extension context "native-overflow-menu" in - attach context parent node; - Lui_ui.extension_property context node "page-actions-visible" - (BoolValue false); - Lui_ui.extension_property context node "favorite-label" - (StringValue "Favorite"); - Lui_ui.extension_property context node "settings-visible" - (BoolValue true); - Lui_ui.on_event context node (fun input_event -> - ignore - (View_base.handle_native_overflow_menu_event input_event send)); - node + let flutter_host = Lui_ui.host context = FlutterHost in + let entries = + [ menu_item ~text:"Settings" + ?icon:(if flutter_host then Some (`app "settings") else None) + ~on_press:(press send Model.OpenSettings) + [] + ] + in + if flutter_host then + menu ~icon:(`app "more-vert") ~label:"More" + ~accessibility_identifier:"button.overflow-menu" entries context parent + else + stack ~width:44 ~height:44 + [ box ~width:24 ~height:24 ~corner_radius:12 ~border_width:2 + ~border_color:"foreground" []; + menu ~icon:(`app "more-horiz") ~style_class:"capsule" ~label:"More" + ~accessibility_identifier:"button.connection" entries ] + context parent let graph_picker_error_banner model_source : t = alert ~variant:`destructive ~accessibility_identifier:"error.banner" diff --git a/shared/src/logseq_chat/view_screens.ml b/shared/src/logseq_chat/view_screens.ml index a6647254..95783bbb 100644 --- a/shared/src/logseq_chat/view_screens.ml +++ b/shared/src/logseq_chat/view_screens.ml @@ -539,27 +539,63 @@ let main_header_sync (context : Lui_ui.ui_context) model_source send : t = let active_overflow_menu model_source send : t = fun context parent -> - let node = Lui_ui.extension context "native-overflow-menu" in - attach context parent node; let page_actions_source = Signal.map View_base.active_page_actions_visible_ model_source in - let favorite_label_source = - Signal.map View_base.active_page_favorite_label model_source - in let settings_source = Signal.map View_base.connection_settings_visible_ model_source in - Lui_ui.extension_property_signal context node "page-actions-visible" - (reactive View_base.bool_wire_value page_actions_source); - Lui_ui.extension_property_signal context node "favorite-label" - (reactive View_base.string_wire_value favorite_label_source); - Lui_ui.extension_property_signal context node "settings-visible" - (reactive View_base.bool_wire_value settings_source); - Lui_ui.on_event context node (fun input_event -> - ignore - (View_base.handle_native_overflow_menu_event input_event send)); - node + let flutter_host = Lui_ui.host context = FlutterHost in + (* iOS menu items carry no icons; the Flutter popup did. *) + let item_icon name = if flutter_host then Some (`app name) else None in + let entries = + [ if_ ~test:page_actions_source + (if flutter_host then + menu_item + ~text_signal: + (reactive View_base.active_page_favorite_label model_source) + ~icon_signal: + (Signal.map + (fun current -> + `app + (if View_base.active_page_favorite_label current + = "Unfavorite" + then "star-filled" + else "star")) + model_source) + ~on_press:(press send Model.ToggleActivePageFavorite) + [] + else + menu_item + ~text_signal: + (reactive View_base.active_page_favorite_label model_source) + ~on_press:(press send Model.ToggleActivePageFavorite) + []); + if_ ~test:page_actions_source + (menu_item ~text:"Share" ?icon:(item_icon "share") + ~on_press:(press send Model.ShareActivePage) + []); + if_ ~test:page_actions_source + (menu_item ~text:"Delete" ~variant:`destructive + ?icon:(item_icon "toolbar-delete") + ~on_press:(press send Model.RequestDeleteActivePage) + []); + if_ ~test:settings_source + (menu_item ~text:"Settings" ?icon:(item_icon "settings") + ~on_press:(press send Model.OpenSettings) + []); + ] + in + if flutter_host then + menu ~icon:(`app "more-vert") ~label:"More" + ~accessibility_identifier:"button.overflow-menu" entries context parent + else + stack ~width:44 ~height:44 + [ box ~width:24 ~height:24 ~corner_radius:12 ~border_width:2 + ~border_color:"foreground" []; + menu ~icon:(`app "more-horiz") ~style_class:"capsule" ~label:"More" + ~accessibility_identifier:"button.connection" entries ] + context parent let main_header_connection model_source send : t = stack @@ -696,6 +732,15 @@ let native_navigation_view (_context : Lui_ui.ui_context) model_source send ] context (Some node) in + ignore + ((if_ + ~test:(Signal.map View_base.asset_preview_ model_source) + (file_preview + ~path_signal: + (Signal.map View_base.asset_preview_path model_source) + ~on_dismiss:(press send Model.DismissAssetPreview) + [])) + context (Some node)); node else ( ignore @@ -729,6 +774,15 @@ let native_navigation_view (_context : Lui_ui.ui_context) model_source send ~mount:(fun route_source -> native_node_screen context model_source route_source send)) context (Some node)); + ignore + ((if_ + ~test:(Signal.map View_base.asset_preview_ model_source) + (file_preview + ~path_signal: + (Signal.map View_base.asset_preview_path model_source) + ~on_dismiss:(press send Model.DismissAssetPreview) + [])) + context (Some node)); node) let authentication_content model_source send : t = @@ -808,6 +862,9 @@ let application_main_content (context : Lui_ui.ui_context) model_source if_ ~test:(Signal.map View_base.page_deletion_pending_ model_source) (View_settings.page_delete_dialog context send); + if_ + ~test:(Signal.map View_base.sign_out_pending_ model_source) + (View_settings.sign_out_dialog context send); if_ ~test:(Signal.map View_base.model_sync_details_open_ model_source) @@ -846,6 +903,9 @@ let application_main_content (context : Lui_ui.ui_context) model_source if_ ~test:(Signal.map View_base.page_deletion_pending_ model_source) (View_settings.page_delete_dialog context send); + if_ + ~test:(Signal.map View_base.sign_out_pending_ model_source) + (View_settings.sign_out_dialog context send); if_ ~test:(Signal.map View_base.model_sync_details_open_ model_source) diff --git a/shared/src/logseq_chat/view_settings.ml b/shared/src/logseq_chat/view_settings.ml index 036764d0..cacac0b6 100644 --- a/shared/src/logseq_chat/view_settings.ml +++ b/shared/src/logseq_chat/view_settings.ml @@ -128,35 +128,44 @@ let settings_appearance_control (context : Lui_ui.ui_context) model_source ]); ] else - stack + (* Menu-style radio group renders as the native inline picker: + label on the left, current value + dropdown on the right. *) + radio_group ~label:"Theme" ~style_class:"menu" + ~accessibility_identifier:"picker.settings.appearance" [ - select - ~text_signal: - (Signal.map View_base.settings_appearance_title model_source) - ~label:"Theme" - ~on_press:(press send Model.OpenSettingsAppearanceMenu) + radio ~text:"System" + ~checked_signal: + (Signal.map + (fun (model : Model.chat_model) -> + model.appearance = "system") + model_source) + ~accessibility_identifier: + "picker.settings.appearance.option.system" + ~on_change:(fun _ -> + ignore (send (Model.ChangeAppearance "system"))) []; - if_ - ~test: - (Signal.map View_base.model_settings_appearance_menu_open_ + radio ~text:"Light" + ~checked_signal: + (Signal.map + (fun (model : Model.chat_model) -> + model.appearance = "light") model_source) - (dropdown_menu ~anchor:`below ~anchor_alignment:`end_ - ~min_width:160 - ~on_dismiss:(press send Model.CloseSettingsAppearanceMenu) - [ - menu_item ~text:"System" - ~on_press:(fun _ -> - ignore (send (Model.ChangeAppearance "system"))) - []; - menu_item ~text:"Light" - ~on_press:(fun _ -> - ignore (send (Model.ChangeAppearance "light"))) - []; - menu_item ~text:"Dark" - ~on_press:(fun _ -> - ignore (send (Model.ChangeAppearance "dark"))) - []; - ]); + ~accessibility_identifier: + "picker.settings.appearance.option.light" + ~on_change:(fun _ -> + ignore (send (Model.ChangeAppearance "light"))) + []; + radio ~text:"Dark" + ~checked_signal: + (Signal.map + (fun (model : Model.chat_model) -> + model.appearance = "dark") + model_source) + ~accessibility_identifier: + "picker.settings.appearance.option.dark" + ~on_change:(fun _ -> + ignore (send (Model.ChangeAppearance "dark"))) + []; ] let settings_community_link_row (context : Lui_ui.ui_context) model_source @@ -659,14 +668,69 @@ let settings_sign_out_row (context : Lui_ui.ui_context) send : t = if Lui_ui.host context = FlutterHost then list_item ~icon:(`app "sign-out") ~padding:0 ~accessibility_identifier:"button.sign-out" - ~on_press:(press send Model.SignOut) + ~on_press:(press send Model.RequestSignOut) ~text:"Sign Out" [] else list_item ~padding:0 ~accessibility_identifier:"button.sign-out" - ~on_press:(press send Model.SignOut) + ~on_press:(press send Model.RequestSignOut) ~text:"Sign Out" [] -let settings_screen (context : Lui_ui.ui_context) model_source send : t = +let sign_out_dialog (context : Lui_ui.ui_context) send : t = + if Lui_ui.host context = FlutterHost then + dialog ~text:"Sign out?" ~height:280 + ~accessibility_identifier:"dialog.sign-out" + ~on_dismiss:(press send Model.CancelSignOut) + [ + column ~gap:16 ~cross:`stretch + [ + text + ~value: + "You will be signed out of the sync server on this \ + device." + ~grow:1.0 []; + spacer ~grow:1.0 []; + row ~main:`end_ + [ + toolbar ~orientation:`horizontal ~toolbar_gap:12 + ~label:"Sign out actions" + ~accessibility_identifier:"toolbar.sign-out" + [ + button ~variant:`ghost + ~accessibility_identifier: + "button.sign-out.cancel" + ~on_press:(press send Model.CancelSignOut) + ~text:"Cancel" []; + button ~variant:`destructive + ~accessibility_identifier: + "button.sign-out.confirm" + ~on_press:(press send Model.SignOut) + ~text:"Sign Out" []; + ]; + ]; + ]; + ] + else + (* Text-and-buttons-only children keep the dialog on the native alert + path (rich children fall back to a custom sheet). *) + dialog ~text:"Sign out?" ~style_class:"alert" + ~accessibility_identifier:"dialog.sign-out" + ~on_dismiss:(press send Model.CancelSignOut) + [ + text + ~value: + "You will be signed out of the sync server on this device." + []; + button ~accessibility_identifier:"button.sign-out.cancel" + ~on_press:(press send Model.CancelSignOut) + ~text:"Cancel" []; + button ~variant:`destructive + ~accessibility_identifier:"button.sign-out.confirm" + ~on_press:(press send Model.SignOut) + ~text:"Sign Out" []; + ] + +let settings_flutter_screen (context : Lui_ui.ui_context) model_source + send : t = let card_bg = if Lui_ui.host context = FlutterHost then "surface-container-low" else "surface" @@ -778,6 +842,126 @@ let settings_screen (context : Lui_ui.ui_context) model_source send : t = [ settings_sign_out_row context send ]; ] +(* Native grouped-settings form: a `form`-styled content column whose + children are `heading` section delimiters and row nodes — the Apple + backend renders it as a grouped Form (white section cards, system + separators, green toggles). *) +let settings_form_screen (context : Lui_ui.ui_context) model_source send + : t = + let version_row label value_signal identifier : t = + row ~cross:`center ~accessibility_identifier:identifier + [ + text ~value:label []; + spacer []; + text ~value_signal ~foreground:"secondary" []; + ] + in + column ~style_class:"form" + ~accessibility_identifier:"screen.settings" + [ + heading ~value:"General" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.general" []; + settings_appearance_control context model_source send; + settings_language_control context model_source send; + list_item ~accessibility_identifier:"link.settings.tabs" + ~on_press:(press send Model.OpenSettingsTabs) + [ + row ~grow:1.0 ~cross:`center + [ + text ~value:"Tabs" []; + spacer []; + text + ~value_signal: + (Signal.map View_base.settings_tabs_summary + model_source) + ~style_class:"single-line" ~foreground:"secondary" + ~accessibility_identifier: + "text.settings.tabs.selection" []; + ]; + ]; + heading ~value:"Editor" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.editor" []; + toggle + ~checked_signal: + (Signal.map View_base.settings_spell_check model_source) + ~accessibility_identifier:"switch.settings.spell-check" + ~on_toggle:(settings_toggle_spell_check send) + ~text:"Spell check" []; + toggle + ~checked_signal: + (Signal.map View_base.settings_auto_correction model_source) + ~accessibility_identifier:"switch.settings.auto-correction" + ~on_toggle:(settings_toggle_auto_correction send) + ~text:"Auto-correction" []; + heading ~value:"Sync server" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.sync-server" []; + text_field + ~text_signal:(Signal.map View_base.settings_base_url model_source) + ~placeholder:"Server URL" ~label:"Server URL" + ~accessibility_identifier:"field.base-url" + ~on_input:(on_input send (fun text -> Model.ChangeBaseURL text)) + []; + if_ + ~test: + (Signal.map View_base.settings_base_url_invalid_ model_source) + (text ~foreground:"destructive" + ~value:"Enter a valid HTTP or HTTPS URL." []); + if_ + ~test:(Signal.map View_base.selected_graph_local_ model_source) + (heading ~value:"Advanced" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.advanced" []); + if_ + ~test:(Signal.map View_base.selected_graph_local_ model_source) + (list_item ~padding:0 + ~accessibility_identifier:"button.export-graph-database" + ~on_press:(press send Model.ExportGraphDatabase) + ~text:"Export Graph SQLite DB" []); + heading ~value:"About" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.about" []; + version_row "Version" + (Signal.map View_base.settings_version model_source) + "row.settings.version"; + version_row "Revision" + (Signal.map View_base.settings_revision model_source) + "row.settings.revision"; + list_item ~accessibility_identifier:"button.runtime-log" + ~on_press:(press send Model.OpenRuntimeLog) + ~text:"Check log" []; + heading ~value:"Community" ~foreground:"muted-foreground" + ~accessibility_identifier:"label.settings.community" []; + keyed + ~source:(Signal.map View_base.model_community_links model_source) + ~key:View_base.settings_community_link_identifier + ~cmp:compare + ~mount:(fun link_source -> + list_item + ~text_signal: + (Signal.map View_base.settings_community_link_title + link_source) + ~accessibility_identifier: + (View_base.settings_community_link_identifier + (Signal.sample link_source)) + ~on_press:(fun _ -> + ignore + (send + (Model.OpenExternalURL + (Signal.sample link_source + : Model.settings_community_link) + .url))) + []); + heading ~value:"Account" + ~accessibility_identifier:"label.settings.account" []; + list_item ~foreground:"destructive" + ~accessibility_identifier:"button.sign-out" + ~on_press:(press send Model.RequestSignOut) + ~text:"Sign Out" []; + ] + +let settings_screen (context : Lui_ui.ui_context) model_source send : t = + if Lui_ui.host context = FlutterHost then + settings_flutter_screen context model_source send + else settings_form_screen context model_source send + let settings_tabs_sheet (context : Lui_ui.ui_context) model_source send : t = if Lui_ui.host context = FlutterHost then @@ -899,7 +1083,7 @@ let settings_main_sheet (context : Lui_ui.ui_context) model_source send : ]; ] else - sheet ~text:"Settings" ~style_class:"navigation-scroll" + sheet ~text:"Settings" ~style_class:"navigation-form" ~accessibility_identifier:"sheet.settings" ~on_dismiss:(press send Model.DismissSettings) [ @@ -975,20 +1159,20 @@ let page_delete_dialog (context : Lui_ui.ui_context) send : t = ]; ] else + (* Text-and-buttons-only children keep the dialog on the native alert + path (rich children fall back to a custom sheet). *) dialog ~text:"Delete page?" ~style_class:"alert" ~accessibility_identifier:"dialog.page-delete" ~on_dismiss:(press send Model.CancelDeleteActivePage) [ - column - [ - text ~value:"The page will be moved to Recycle." []; - button ~accessibility_identifier:"button.page-delete.cancel" - ~on_press:(press send Model.CancelDeleteActivePage) - ~text:"Cancel" []; - button ~accessibility_identifier:"button.page-delete.confirm" - ~on_press:(press send Model.ConfirmDeleteActivePage) - ~text:"Delete" []; - ]; + text ~value:"The page will be moved to Recycle." []; + button ~accessibility_identifier:"button.page-delete.cancel" + ~on_press:(press send Model.CancelDeleteActivePage) + ~text:"Cancel" []; + button ~variant:`destructive + ~accessibility_identifier:"button.page-delete.confirm" + ~on_press:(press send Model.ConfirmDeleteActivePage) + ~text:"Delete" []; ] let sync_status_sheet (context : Lui_ui.ui_context) model_source send : t = diff --git a/shared/src/logseq_chat/view_settings.mli b/shared/src/logseq_chat/view_settings.mli index ded04c22..d1b330ad 100644 --- a/shared/src/logseq_chat/view_settings.mli +++ b/shared/src/logseq_chat/view_settings.mli @@ -78,6 +78,8 @@ val settings_sheet : (Model.chat_action -> bool) -> Lui_elements.t val page_delete_dialog : Lui_ui.ui_context -> (Model.chat_action -> bool) -> Lui_elements.t +val sign_out_dialog : + Lui_ui.ui_context -> (Model.chat_action -> bool) -> Lui_elements.t val sync_status_sheet : Lui_ui.ui_context -> Model.chat_model Signal.signal -> diff --git a/shared/test/logseq_chat/app_test.ml b/shared/test/logseq_chat/app_test.ml index 452220b6..a5f2f0c0 100644 --- a/shared/test/logseq_chat/app_test.ml +++ b/shared/test/logseq_chat/app_test.ml @@ -66,6 +66,24 @@ let rec descendant_with_node_kind runtime parent kind = in loop 0 +let menu_item_with_text runtime menu_trigger text = + let menu = + descendant_with_node_kind runtime menu_trigger + Lui_protocol.DropdownMenu + in + if menu = -1 then -1 + else + let items = children runtime menu in + let rec find items = + match items with + | [] -> -1 + | item :: rest -> + if property_string runtime item Lui_protocol.TextValue = text then + item + else find rest + in + find items + let rec descendant_count_with_node_kind runtime parent kind = let children = children runtime parent in let own = if node runtime parent = Some kind then 1 else 0 in @@ -1132,18 +1150,14 @@ let settings_render_the_main_branch_navigation_contract () = check_eq ~msg:"the settings root owns the native sheet title" (property_string renderer settings_sheet Lui_protocol.TextValue) "Settings"; - check_eq ~msg:"the settings root keeps its custom scrolling cards" + check_eq ~msg:"the settings root uses the native grouped form sheet" (property_string renderer settings_sheet Lui_protocol.StyleClass) - "navigation-scroll"; + "navigation-form"; check ~msg:"settings retain their baseline screen identifier" (settings_screen <> -1); - check_eq ~msg:"settings paint the app background inside the native sheet" - (property_string renderer settings_screen Lui_protocol.BackgroundValue) - "background"; - check_eq ~msg:"settings cards use the same themed surface as main" - (descendant_count_with_property_string renderer settings_screen - Lui_protocol.BackgroundValue "surface") - 7; + check_eq ~msg:"the settings content uses the native form layout" + (property_string renderer settings_screen Lui_protocol.StyleClass) + "form"; let tabs_link = descendant_with_identifier renderer root "link.settings.tabs" in @@ -1152,9 +1166,6 @@ let settings_render_the_main_branch_navigation_contract () = "text.settings.tabs.selection" in check ~msg:"settings expose tabs navigation" (tabs_link <> -1); - check_eq ~msg:"settings card rows do not duplicate card padding" - (property_int renderer tabs_link Lui_protocol.PaddingValue) - 0; check_eq ~msg:"tabs show the same selected-items summary as main" (property_string renderer tabs_selection Lui_protocol.TextValue) "Journals · Flashcards · Graphs"; @@ -1581,14 +1592,16 @@ let flutter_settings_use_full_width_material_controls () = send application Model.OpenSettingsLanguageMenu; flush application; let root = main_root renderer application in - let menu = - descendant_with_node_kind renderer root Lui_protocol.DropdownMenu - in let system = descendant_with_identifier renderer root "button.settings.language.system" in - check ~msg:"opening the language selector presents a Material menu" - (menu <> -1); + let menu = + parent_with_child_identifier renderer root + "button.settings.language.system" + in + check_eq ~msg:"opening the language selector presents a Material menu" + (node renderer menu) + (Some Lui_protocol.DropdownMenu); check ~msg:"the Material menu retains every shared language choice" (descendant_count_with_property_string renderer menu Lui_protocol.TextValue "简体中文" @@ -1784,25 +1797,11 @@ let settings_community_links_use_a_typed_platform_boundary () = descendant_with_identifier renderer root "link.settings.community.report-bug" in - let report_row = - parent_with_child_identifier renderer root - "link.settings.community.report-bug" - in let github = descendant_with_identifier renderer root "link.settings.community.github" in - let github_row = - parent_with_child_identifier renderer root - "link.settings.community.github" - in check ~msg:"settings retain the issue tracker link" (report_bug <> -1); check ~msg:"settings retain the GitHub community link" (github <> -1); - check_eq ~msg:"community links preserve main's spacing before dividers" - (property_int renderer report_row Lui_protocol.Gap) - 12; - check_eq ~msg:"the final community link has no trailing divider" - (List.length (children renderer github_row)) - 1; dispatch application (Lui_protocol.Press github); flush application; check_eq ~msg:"community navigation stays on the typed platform boundary" @@ -2143,7 +2142,9 @@ let graph_picker_matches_main_layout_actions_errors_and_overflow_menu () = let refresh = child_with_identifier renderer picker "button.graphs.refresh" in - let overflow = extension_node application "native-overflow-menu" in + let overflow = + descendant_with_identifier renderer picker "button.connection" + in check_eq ~msg:"the picker preserves main's vertical spacing" (property_int renderer picker Lui_protocol.Gap) 20; @@ -2165,17 +2166,19 @@ let graph_picker_matches_main_layout_actions_errors_and_overflow_menu () = check_eq ~msg:"the refresh action is a plain text button" (property_string renderer refresh Lui_protocol.VariantValue) "ghost"; - check ~msg:"the picker renders the native overflow menu in its header" + check ~msg:"the picker renders the overflow menu in its header" (overflow <> -1); check_eq ~msg:"the picker does not render a second connection control" - (child_with_identifier renderer main "button.connection") - (-1); - dispatch application - (Lui_protocol.ExtensionEvent - (overflow, "native-overflow-menu", "settings", - Lui_protocol.String_map.empty)); + (descendant_count_with_identifier renderer main "button.connection") + 1; + let settings_item = + menu_item_with_text renderer overflow "Settings" + in + check ~msg:"the overflow menu exposes a Settings item" + (settings_item <> -1); + if settings_item <> -1 then dispatch application (Lui_protocol.Press settings_item); flush application; - check ~msg:"the native overflow menu routes settings through LG" + check ~msg:"the overflow menu routes settings through LG" (App.model application).settings_open; send application (Model.SyncFailed "graph_discovery_failed\nConnection refused"); @@ -2349,9 +2352,10 @@ let graph_and_sync_actions_update_retained_status_in_place () = (property_string renderer control Lui_protocol.VariantValue) "ghost") [ sidebar_control; sync_control ]; - check_eq ~msg:"the trailing action uses the platform-native menu" - (extension_kind renderer connection_control) - (Some "native-overflow-menu"); + check ~msg:"the trailing action uses the platform-native menu" + (descendant_with_node_kind renderer connection_control + Lui_protocol.MenuTrigger + <> -1); check_eq ~msg:"the journal root leaves its default navigation title empty" (property_string renderer title Lui_protocol.TextValue) ""; @@ -5048,7 +5052,7 @@ let native_bridge_selects_the_flutter_host_profile () = (not (contains "container-relative-frame" patch)); let patch = Native_bridge.initialize 3 3 3 in check ~msg:"signed-in Flutter renders the Material graph picker controls" - (contains "native-overflow-menu" patch); + (contains "menu-trigger" patch); check ~msg:"signed-in Flutter excludes every SwiftUI-only viewport property" (not (contains "container-relative-frame" patch)); @@ -5920,10 +5924,11 @@ let native_navigation_retains_the_journal_and_every_node_route () = "sync.disconnected") Lui_protocol.InlineIconName) "app:status-dot"; - check_eq ~msg:"LG supplies the native trailing menu" - (extension_kind renderer - (List.nth (children renderer (List.nth nav_children 4)) 0)) - (Some "native-overflow-menu"); + check ~msg:"LG supplies the native trailing menu" + (descendant_with_node_kind renderer + (List.nth (children renderer (List.nth nav_children 4)) 0) + Lui_protocol.MenuTrigger + <> -1); check_eq ~msg: "the internal bottom chrome slot does not override its active \ @@ -7205,22 +7210,28 @@ let connection_menu_matches_active_page_actions () = (apply_core_snapshot None sidebar [] true "" [] [] None None [] [] [] false []); flush application; - let connection = extension_node application "native-overflow-menu" in - check_eq ~msg:"active pages expose native page actions" - (extension_property application connection "page-actions-visible") - (Some (Lui_protocol.BoolValue true)); + let renderer = Lui_app.runtime application in + let navigation = extension_node application "native-navigation-stack" in + let connection = + descendant_with_identifier renderer navigation "button.connection" + in + check ~msg:"active pages expose the native overflow menu" + (connection <> -1); check_eq ~msg:"the native action label reflects favorite state" - (extension_property application connection "favorite-label") - (Some (Lui_protocol.StringValue "Unfavorite")); + (descendant_count_with_property_string renderer connection + Lui_protocol.TextValue "Unfavorite") + 1; + check_eq ~msg:"active pages expose the share action" + (descendant_count_with_property_string renderer connection + Lui_protocol.TextValue "Share") + 1; check_eq ~msg:"page overflow excludes graph settings" - (extension_property application connection "settings-visible") - (Some (Lui_protocol.BoolValue false)); + (descendant_count_with_property_string renderer connection + Lui_protocol.TextValue "Settings") + 0; dispatch application - (Lui_protocol.ExtensionEvent - ( connection, - "native-overflow-menu", - "favorite", - Lui_protocol.String_map.empty )); + (Lui_protocol.Press + (menu_item_with_text renderer connection "Unfavorite")); flush application; check_eq ~msg:"the native favorite action keeps its typed LG event" (App.model application).pending_effects @@ -8334,11 +8345,6 @@ let composer_asset_failure_retains_draft_and_success_removes_it () = check_eq ~msg:"successful assets leave the draft" completed.composer_assets [] -let composer_asset_schema_matches_native_thumbnail_renderer () = - check_eq ~msg:"native attachment thumbnails share the LG schema" - (Lui_extension.fingerprint (View.composer_asset_schema ())) - "lui-extension-v1|14:composer-asset|profiles:android/flutter,ios/swiftui|standard-children:0|children:|properties:10:local-path:string:required:none,5:title:string:required:none|events:" - let block_node_breadcrumbs_preserve_navigation_context () = List.iter (fun search_ -> @@ -8696,7 +8702,6 @@ let cases = case "native-outliner-controls-match-first-line-and-main-status-shapes" native_outliner_controls_match_first_line_and_main_status_shapes; case "composer-stages-multiple-assets-until-confirmed" composer_stages_multiple_assets_until_confirmed; case "composer-asset-failure-retains-draft-and-success-removes-it" composer_asset_failure_retains_draft_and_success_removes_it; - case "composer-asset-schema-matches-native-thumbnail-renderer" composer_asset_schema_matches_native_thumbnail_renderer; case "block-node-breadcrumbs-preserve-navigation-context" block_node_breadcrumbs_preserve_navigation_context; case "composer-attachment-previews-remove-only-the-selected-draft" composer_attachment_previews_remove_only_the_selected_draft; case "ui-session-restores-after-process-relaunch" ui_session_restores_after_process_relaunch; diff --git a/shared/test/logseq_chat/drive/graph-switch-delete.drive b/shared/test/logseq_chat/drive/graph-switch-delete.drive index c7cf0c64..5837bf64 100644 --- a/shared/test/logseq_chat/drive/graph-switch-delete.drive +++ b/shared/test/logseq_chat/drive/graph-switch-delete.drive @@ -25,6 +25,6 @@ press prop:accessibility-identifier=link.sidebar.graphs wait prop:accessibility-identifier=graph.Beta press kind:menu-item&text:"Delete local graph" wait kind:dialog&text:"Delete local graph" -press kind:button&prop:text=Confirm +press prop:accessibility-identifier=button.graph-delete.confirm expect-absent prop:accessibility-identifier=graph.Alpha expect prop:accessibility-identifier=graph.Beta diff --git a/shared/test/logseq_chat/drive/settings-appearance.drive b/shared/test/logseq_chat/drive/settings-appearance.drive index 744869b9..ff8fb7a3 100644 --- a/shared/test/logseq_chat/drive/settings-appearance.drive +++ b/shared/test/logseq_chat/drive/settings-appearance.drive @@ -6,13 +6,11 @@ press prop:accessibility-identifier=button.graph-add type prop:accessibility-identifier=field.graph-name "Drive Graph" press prop:accessibility-identifier=button.graph-add.confirm wait prop:accessibility-identifier=application.shell -ext ext:native-overflow-menu native-overflow-menu settings '{}' +press kind:menu-item&text:"Settings" wait prop:accessibility-identifier=sheet.settings -press kind:select -wait kind:menu-item&text:"Dark" -press kind:menu-item&text:"Dark" -expect kind:select&text:"Dark" -press kind:select&text:"Dark" -wait kind:menu-item&text:"Light" -press kind:menu-item&text:"Light" -expect kind:select&text:"Light" +expect prop:accessibility-identifier=picker.settings.appearance +change prop:accessibility-identifier=picker.settings.appearance.option.dark +expect-prop prop:accessibility-identifier=picker.settings.appearance.option.dark checked true +expect-prop prop:accessibility-identifier=picker.settings.appearance.option.light checked false +change prop:accessibility-identifier=picker.settings.appearance.option.light +expect-prop prop:accessibility-identifier=picker.settings.appearance.option.light checked true diff --git a/shared/test/logseq_chat/drive/settings-language-tabs.drive b/shared/test/logseq_chat/drive/settings-language-tabs.drive index 9040d6a2..c08c130d 100644 --- a/shared/test/logseq_chat/drive/settings-language-tabs.drive +++ b/shared/test/logseq_chat/drive/settings-language-tabs.drive @@ -5,9 +5,8 @@ press prop:accessibility-identifier=button.graph-add type prop:accessibility-identifier=field.graph-name "Drive Graph" press prop:accessibility-identifier=button.graph-add.confirm wait prop:accessibility-identifier=application.shell -ext ext:native-overflow-menu native-overflow-menu settings '{}' +press kind:menu-item&text:"Settings" wait prop:accessibility-identifier=sheet.settings -expect prop:accessibility-identifier=layout.settings.general-card expect prop:accessibility-identifier=picker.settings.language change prop:accessibility-identifier=button.settings.language.fr expect-prop prop:accessibility-identifier=button.settings.language.fr checked true diff --git a/shared/test/logseq_chat/markup_test.ml b/shared/test/logseq_chat/markup_test.ml index db8b67d1..2893121d 100644 --- a/shared/test/logseq_chat/markup_test.ml +++ b/shared/test/logseq_chat/markup_test.ml @@ -31,13 +31,16 @@ let rich_node_semantics () = let reference_resolution_and_raw_source () = List.iter (fun source -> check_eq [ Markup_text source ] (parse_bare source)) - [ - "Legacy ((block-uuid)) stays text"; - "Unknown [[missing]]"; - "Unknown #[[missing]]"; - "\xF0\x9F\x98\x80 Unknown [[missing]] and #[[missing]]"; - "{{unknown value}}"; - ]; + [ "Legacy ((block-uuid)) stays text"; "{{unknown value}}" ]; + check_eq + [ Markup_text "Unknown missing" ] + (parse_bare "Unknown [[missing]]"); + check_eq + [ Markup_text "Unknown #missing" ] + (parse_bare "Unknown #[[missing]]"); + check_eq + [ Markup_text "\xF0\x9F\x98\x80 Unknown missing and #missing" ] + (parse_bare "\xF0\x9F\x98\x80 Unknown [[missing]] and #[[missing]]"); check_eq [ Markup_text "Inline ";