Skip to content

Add file-picker element (files/photos/camera) for the Apple backend - #73

Merged
RCmerci merged 11 commits into
mainfrom
devin/1790579431-file-picker
Sep 29, 2026
Merged

RCmerci merged 11 commits into
mainfrom
devin/1790579431-file-picker

Conversation

@RCmerci

@RCmerci RCmerci commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a general-purpose non-visual file-picker element to lui, covering the same contract as the journal's journal-asset-import extension (file dialog pick + security-scope retention + completion handshake) plus photo-library and camera sources — all with generic naming so any app can drive it. The token/payload contents stay app-side; no journal semantics are baked into the schema or backends.

Wire surface (from schema/components.json, regenerated):

  • Kind: file-picker (container, mounted as a child like sheet/dialog; non-visual)
  • Properties: request (string|int token — set/bump to present), types (comma-separated UTIs), multiple (bool), source ("files" default / "photos" / "camera"), completion (string|int token), plus enabled and appear-enabled (restrictive kind)
  • Event: picked(node, payload: string); cancelled presentations reuse dismiss

Handshake: when request changes to a new token and enabled holds, the platform picker presents. On success, picked fires with {"request": <token>, "files": [{"path", "name", "content-type"}]} (token type preserved; files supports multi-select). On cancel, dismiss fires — including the iOS quirk where .fileImporter never invokes its completion on cancel, detected by watching the presentation flip while unhandled. Picked URLs retain their security scope keyed by the request token until completion echoes it or the node drops; photo/camera results are temp copies owned by the picker and deleted on release.

Apple backend sources:

  • files → .fileImporter with allowedContentTypes parsed from types
  • photos → SwiftUI photosPicker (PHPickerFilter narrowed by types)
  • camera → UIImagePickerController(.camera) via UIViewControllerRepresentable, iOS only; on macOS a camera request answers with dismiss without presenting

Lui_elements.file_picker exposes ?source ?request ?types ?multiple ?disabled ?completion ?on_picked ?on_dismiss (with signal twins) over Lui_protocol.PickerRequest/…/Picked. Flutter gets the prop/event schema + picked bridge + a Stack stub; Qt a stub QML + schema arms; WinUI LUIEvent.Picked/PerformPicked + schema parity. The example OCaml bridges and Swift hosts gain picked plumbing.

Test plan

  • dune build, dune runtest — 30 tests incl. new file-picker rules (kind/property/event matrix, element wiring, picked/dismiss dispatch)
  • swift test — 138 tests incl. new LUIFilePickerTests (prop validation, performPicked/performDismiss kind gating)
  • dart analyze + flutter test — clean; Qt lib + test target compile under CMake
  • WinUI changes are compile-equivalent mirrors of existing patterns (no .NET toolchain on this machine)

Link to Devin session: https://app.devin.ai/sessions/ae0d876b6fc4405487695d8ebd90712b
Open in Devin Desktop: https://app.devin.ai/desktop/session/ae0d876b6fc4405487695d8ebd90712b?variant=devin
Requested by: @RCmerci

Introduces a generic non-visual 'file-picker' node kind driven by the
schema: a 'request' token presents a picker for 'source'
(files/photos/camera), 'picked' emits a JSON payload echoing the token
plus per-file {path, name, content-type}, 'completion' releases retained
security-scoped resources, and a cancelled presentation reports through
'dismiss'. 'types' takes a comma-separated UTI list and 'multiple'
enables multi-select.

Apple backend mounts fileImporter, SwiftUI PhotosPicker, or an iOS-only
UIImagePickerController camera wrapper; macOS camera requests answer
with dismiss. Picked URLs keep their security scope (or temp copies for
photo/camera results) until completion echoes the token or the node is
dropped, and the iOS fileImporter never-calls-on-completion-on-cancel
quirk is covered by presentation-state cancel detection.

Flutter, Qt, and WinUI get schema parity and stub rendering only.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T07:21:08.830461Z ef52d08 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…visible-range tracking

Schema gains five node kinds (list-section, list-section-header,
list-section-footer, swipe-actions, swipe-action), nine properties
(key, separator, style, scroll-target, scroll-anchor, scroll-token,
scroll-animated, track-visible-range, edge), and two list events
(scroll-completed, visible-range).

OCaml: list_item gains ?expanded/?on_toggle disclosure support plus
?separator/?swipe_actions; list gains ?style, scroll request props and
?on_scroll_completed/?on_visible_range; new list_section, swipe_actions
and swipe_action constructors.

Apple backend: list-section children render as grouped sections with
arbitrary content headers/footers (existing heading/footnote inference
still applies when no explicit sections are present); a list-item with
children renders a DisclosureGroup; explicit swipe-actions render on
both edges while the context-menu trailing-swipe derivation is
preserved; scroll-target/scroll-token drive ScrollViewReader requests
reporting scroll-completed outcomes; track-visible-range emits
debounced flat-position visible-range events.

Flutter/Qt/WinUI gain schema-parity validation; rendering stays a
container fallback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef52d08770

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +386 to +389
func makeUIViewController(context: Context) -> UIImagePickerController {
let controller = UIImagePickerController()
controller.sourceType = .camera
controller.mediaTypes = mediaTypes.map(\.identifier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check camera availability before assigning the source

When source == "camera" is used on an iOS Simulator, a device without a camera, or a device where camera capture is unavailable, assigning .camera to sourceType raises an Objective-C exception instead of producing the documented dismiss event. Check UIImagePickerController.isSourceTypeAvailable(.camera) before presenting and cancel the request when it is unavailable.

Useful? React with 👍 / 👎.

Comment on lines +281 to +289
private static func importPhotoItem(_ item: PhotosPickerItem) async -> RetainedFile? {
guard let data = try? await item.loadTransferable(type: Data.self) else {
return nil
}
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "bin"
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("lui-picked-\(UUID().uuidString)")
.appendingPathExtension(ext)
guard (try? data.write(to: url)) != nil else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Transfer photo-library movies without loading them into memory

With the default photo filter, users can select movies, and loadTransferable(type: Data.self) materializes the entire asset in process memory before writing another copy. A normal multi-gigabyte phone video can therefore terminate the app for memory pressure; use a file-based Transferable/FileRepresentation or otherwise stream/copy from a supplied URL.

Useful? React with 👍 / 👎.

Comment on lines +224 to +228
private func finishCancelled() {
handled = true
releaseRetained()
operation = nil
try? backend.performDismiss(node: model.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear presentation state when camera capture is cancelled

When the camera controller calls imagePickerControllerDidCancel, the callback reaches finishCancelled(), but this path never sets presented to false. UIImagePickerController delegates are responsible for dismissing the picker, so the camera sheet can remain visible after the dismiss event has already been sent, with operation cleared and no way to complete that presentation.

Useful? React with 👍 / 👎.

Comment on lines +84 to +87
.onAppear {
evaluate()
evaluateCompletion()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor an existing completion token before presenting

If a picker mounts with request and completion already equal—for example when application state retaining the last handshake recreates the node—evaluate() first sets presented = true, and evaluateCompletion() only clears operation. The acknowledged request consequently opens another picker whose result is ignored; completion must be evaluated first or presentation must explicitly exclude already-completed tokens.

Useful? React with 👍 / 👎.

#endif
finishCancelled()
}
.onDisappear { releaseRetained() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain picked files across temporary view disappearance

If the picker view temporarily disappears while its backend node remains mounted, such as when switching tabs or navigation destinations before asynchronous processing acknowledges completion, this hook immediately closes security scope and deletes photo/camera temp files. That invalidates the paths despite the API promise to retain them until matching completion or node removal; cleanup needs to follow the node/request lifecycle rather than SwiftUI visibility.

Useful? React with 👍 / 👎.

Comment on lines +264 to +267
private func handlePhotoSelection(_ items: [PhotosPickerItem]) {
guard operation != nil, source == "photos", !items.isEmpty else { return }
photoSelection = []
Task {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark photo selections handled before clearing the binding

When a photo selection closes the picker, SwiftUI updates photoSelection and presented in the same render cycle. This handler clears photoSelection while handled is still false, so the outer onChange(of: presented) can observe an empty selection and call finishCancelled() before the asynchronous import completes; the eventual finishPicked() then sees no operation and drops the selected files. Mark the operation as handling a selection before clearing the binding.

Useful? React with 👍 / 👎.

Comment on lines +2920 to +2923
// Non-visual node: presentation is driven by its properties on
// platforms that implement the file-picker backend; children render
// inline.
_NodeKind.filePicker => Stack(children: children),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dismiss unsupported picker requests on non-Apple backends

When an app sends a camera request on Flutter, this new branch only renders a Stack and never emits Dismiss, leaving the request pending forever despite the public API explicitly promising that non-iOS platforms answer camera requests with a dismiss event. The WinUI and QML placeholders added by this commit are likewise inert, so unsupported hosts need to observe new request tokens and dispatch the documented fallback event.

Useful? React with 👍 / 👎.

Comment thread src/lui_elements.mli Outdated
Comment on lines +2265 to +2266
?request:string ->
?request_signal:string Signal.signal ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose integer request tokens through the element API

The wire schema and every backend explicitly accept int as well as string values for request, and the picked payload promises to preserve that token type, but the public file_picker constructor restricts both its static and signal request arguments to strings. Apps whose operation IDs are integers therefore cannot use the advertised token form through the typed element API and must bypass it with raw property calls; expose a token type that covers both supported wire values, with the same treatment for completion.

Useful? React with 👍 / 👎.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

End-to-end verified on macOS with a scratch SwiftUI harness mounting a real file-picker node through backend.apply(json:) (no OCaml runtime).

Scenario Result
request:"op-1" (files) NSOpenPanel presented; picked payload {"request":"op-1","files":[{path,name,"content-type":"text/plain"}]}
new request while retained ignored — no dialog, no event
completion:"op-1" released retained file; re-request works
cancel dialog dismiss(node) emitted, no picked
source:"camera" on macOS immediate dismiss, no presentation
multiple:true multi-select; picked payload lists both files
types:"public.image" non-images greyed out; picked reports image/png

Recording: /Users/devin/screencasts/rec-5039b89a-2e62-495e-be0d-ce7e34230d4f/rec-5039b89a-2e62-495e-be0d-ce7e34230d4f-edited.mp4

picked payload with request token

More screenshots

NSOpenPanel presented
dismiss on cancel
camera dismiss on macOS
2 items multi-selected
multi-pick payload
types greying — txt dimmed, png selectable
picked image/png

Not covered: source:"photos" (needs Photos library access), iOS camera sheet, and the enabled:false gate.

- Hold file-picker operations on the backend keyed by node id so view
  teardown can't release retained files; drop-node releases them.
- Stream PhotosPicker items through FileRepresentation instead of
  loading whole assets into memory as Data.
- Guard UIImagePickerController.isSourceTypeAvailable(.camera) — a
  camera-less device now answers dismiss instead of throwing.
- Mark photo selections handled before clearing so async imports are
  not mistaken for cancels; finishCancelled also tears down the camera
  sheet; requests equal to completion no longer re-present.
- Flutter/WinUI backends answer unservable file-picker requests with
  dismiss after the batch commits; the Qt stub does the same in QML.
- Lui_elements.file_picker request/completion take a file_picker_token
  ([`String | `Int]) matching the wire's string|int property values.
…ction handling

- Apple: emit visible-range on flatRowOrder changes (insert/remove/reorder
  with same visible ids); wrap headerless explicit sections in Section{};
  apply heading/footnote inference to pending non-section runs; resolve
  scroll targets by section key; guard fallback cancelled emission with
  handledScrollToken; keep context-menu ellipsis when explicit
  swipe-actions exist.
- Flutter: exclude swipe-actions children from list-item row content.
- Web: treat swipe-actions as hidden metadata in list-item content
  validation and DOM placement (never mounted).
- Qt/WinUI: require text or icon on swipe-action for parity.
An empty ForEach mounts no view, so .fileImporter/.photosPicker/.sheet on
LUIFilePickerView never presented when the node had no children.
@RCmerci
RCmerci merged commit 12d95ad into main Sep 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant