Unified · Split
| @@ -1,2 +1,4 @@ | ||
| 1 | 1 | reviews: |
| 2 | 2 | review_status: false |
| 3 | + auto_review: | |
| 4 | + enabled: false | |
| @@ -206,6 +206,7 @@ const config: ExpoConfig = { · 205 unmodified lines | ||
| 206 | 206 | }, |
| 207 | 207 | NSLocalNetworkUsageDescription: |
| 208 | 208 | "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", |
| 209 | + NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", | |
| 209 | 210 | ITSAppUsesNonExemptEncryption: false, |
| 210 | 211 | // The App Store screenshot harness rotates the iPad interface from |
| 211 | 212 | // inside the app (CI denies osascript the Accessibility access that |
| @@ -5,6 +5,7 @@ import UIKit · 4 unmodified lines | ||
| 5 | 5 | public final class T3NativeControlsModule: Module { |
| 6 | 6 | private let presentationSources = T3PresentationSources() |
| 7 | 7 | private var videoPresentation: T3NativeVideoPresentation? |
| 8 | + private var filePresentation: T3NativeFilePresentation? | |
| 8 | 9 | |
| 9 | 10 | public func definition() -> ModuleDefinition { |
| 10 | 11 | Name("T3NativeControls") |
| @@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { · 12 unmodified lines | ||
| 23 | 24 | self.dismissVideo(identifier: identifier) |
| 24 | 25 | }.runOnQueue(.main) |
| 25 | 26 | |
| 27 | + AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in | |
| 28 | + try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, | |
| 29 | + identifier: identifier, promise: promise) | |
| 30 | + }.runOnQueue(.main) | |
| 31 | + | |
| 32 | + AsyncFunction("dismissFile") { (identifier: String) in | |
| 33 | + self.dismissFile(identifier: identifier) | |
| 34 | + }.runOnQueue(.main) | |
| 35 | + | |
| 26 | 36 | OnDestroy { |
| 27 | 37 | let presentation = self.videoPresentation |
| 28 | − DispatchQueue.main.async { presentation?.dismiss() } | |
| 38 | + let file = self.filePresentation | |
| 39 | + DispatchQueue.main.async { | |
| 40 | + presentation?.dismiss() | |
| 41 | + file?.dismiss() | |
| 42 | + } | |
| 29 | 43 | } |
| 30 | 44 | |
| 31 | 45 | View(T3PresentationSourceView.self) { |
| @@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { · 108 unmodified lines | ||
| 140 | 154 | let isPlayableURL = url.isFileURL |
| 141 | 155 | ? FileManager.default.isReadableFile(atPath: url.path) |
| 142 | 156 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) |
| 143 | − guard videoPresentation == nil, | |
| 157 | + guard videoPresentation == nil, filePresentation == nil, | |
| 144 | 158 | let presenter = appContext?.utilities?.currentViewController(), |
| 145 | 159 | isPlayableURL |
| 146 | 160 | else { |
| @@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { · 15 unmodified lines | ||
| 162 | 176 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } |
| 163 | 177 | } |
| 164 | 178 | |
| 179 | + private func presentFile(url: URL, title: String, sourceIdentifier: String, | |
| 180 | + identifier: String, promise: Promise) throws { | |
| 181 | + guard filePresentation == nil, videoPresentation == nil, | |
| 182 | + let presenter = appContext?.utilities?.currentViewController() | |
| 183 | + else { throw URLError(.cannotLoadFromNetwork) } | |
| 184 | + let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, | |
| 185 | + sourceIdentifier: sourceIdentifier) { [weak self] error in | |
| 186 | + self?.filePresentation = nil | |
| 187 | + if let error { promise.reject(error) } else { promise.resolve(nil) } | |
| 188 | + } | |
| 189 | + filePresentation = file | |
| 190 | + file.present(url: url, title: title, from: presenter) | |
| 191 | + } | |
| 192 | + | |
| 193 | + private func dismissFile(identifier: String) { | |
| 194 | + if filePresentation?.identifier == identifier { filePresentation?.dismiss() } | |
| 195 | + } | |
| 196 | + | |
| 165 | 197 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { |
| 166 | 198 | guard let presenter = appContext?.utilities?.currentViewController() else { |
| 167 | 199 | throw NSError( |
| @@ -0,0 +1,165 @@ | ||
| 1 | +import ImageIO | |
| 2 | +import QuickLook | |
| 3 | +import UIKit | |
| 4 | +import UniformTypeIdentifiers | |
| 5 | + | |
| 6 | +private final class FilePreviewItem: NSObject, QLPreviewItem { | |
| 7 | + var previewItemURL: URL? | |
| 8 | + var previewItemTitle: String? | |
| 9 | +} | |
| 10 | + | |
| 11 | +private final class FilePreviewController: QLPreviewController { | |
| 12 | + var onAppear: (() -> Void)? | |
| 13 | + | |
| 14 | + override func viewDidAppear(_ animated: Bool) { | |
| 15 | + super.viewDidAppear(animated) | |
| 16 | + onAppear?() | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +/// Quick Look owns image and document controls, zooming, and source-view transitions. | |
| 21 | +final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, | |
| 22 | + QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { | |
| 23 | + let identifier: String | |
| 24 | + private var controller: UIViewController? | |
| 25 | + private let completion: (Error?) -> Void | |
| 26 | + private weak var sources: T3PresentationSources? | |
| 27 | + private let sourceIdentifier: String | |
| 28 | + private let item = FilePreviewItem() | |
| 29 | + private var loading: Task<Void, Never>? | |
| 30 | + private var dismissRequested = false | |
| 31 | + private var finished = false | |
| 32 | + | |
| 33 | + init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { | |
| 34 | + self.identifier = identifier | |
| 35 | + self.sources = sources | |
| 36 | + self.sourceIdentifier = sourceIdentifier | |
| 37 | + self.completion = completion | |
| 38 | + super.init() | |
| 39 | + } | |
| 40 | + | |
| 41 | + func present(url: URL, title: String, from presenter: UIViewController) { | |
| 42 | + loading = Task { @MainActor [self] in | |
| 43 | + do { | |
| 44 | + let file = try await Self.prepareFile(url: url, title: title) | |
| 45 | + guard !finished, !Task.isCancelled else { | |
| 46 | + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) | |
| 47 | + return | |
| 48 | + } | |
| 49 | + item.previewItemURL = file | |
| 50 | + item.previewItemTitle = title | |
| 51 | + let preview = FilePreviewController() | |
| 52 | + preview.delegate = self | |
| 53 | + preview.dataSource = self | |
| 54 | + preview.onAppear = { [weak self] in self?.resumePendingDismissal() } | |
| 55 | + controller = preview | |
| 56 | + presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in | |
| 57 | + resumePendingDismissal() | |
| 58 | + } | |
| 59 | + preview.presentationController?.delegate = self | |
| 60 | + } catch { | |
| 61 | + finish(error: error) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + func dismiss() { | |
| 67 | + dismissRequested = true | |
| 68 | + loading?.cancel() | |
| 69 | + guard !finished else { return } | |
| 70 | + guard let controller else { finish(); return } | |
| 71 | + // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. | |
| 72 | + // Starting a second modal transition while UIKit is settling the first can strand it. | |
| 73 | + guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } | |
| 74 | + controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } | |
| 75 | + } | |
| 76 | + | |
| 77 | + private func resumePendingDismissal() { | |
| 78 | + // Appearance callbacks run before UIKit has cleared the current transition. | |
| 79 | + DispatchQueue.main.async { [weak self] in | |
| 80 | + if self?.dismissRequested == true { self?.dismiss() } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } | |
| 85 | + | |
| 86 | + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { | |
| 87 | + item | |
| 88 | + } | |
| 89 | + | |
| 90 | + func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { | |
| 91 | + guard !UIAccessibility.isReduceMotionEnabled else { return nil } | |
| 92 | + return sources?.view(for: sourceIdentifier) | |
| 93 | + } | |
| 94 | + | |
| 95 | + func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, | |
| 96 | + inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect { | |
| 97 | + guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } | |
| 98 | + view.pointee = source | |
| 99 | + return source.bounds | |
| 100 | + } | |
| 101 | + | |
| 102 | + func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } | |
| 103 | + | |
| 104 | + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } | |
| 105 | + | |
| 106 | + private func finish(error: Error? = nil) { | |
| 107 | + guard !finished else { return } | |
| 108 | + finished = true | |
| 109 | + loading?.cancel() | |
| 110 | + loading = nil | |
| 111 | + if let file = item.previewItemURL { | |
| 112 | + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) | |
| 113 | + } | |
| 114 | + item.previewItemURL = nil | |
| 115 | + DispatchQueue.main.async { [completion] in completion(error) } | |
| 116 | + } | |
| 117 | + | |
| 118 | + /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. | |
| 119 | + nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { | |
| 120 | + try Task.checkCancellation() | |
| 121 | + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") | |
| 122 | + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| 123 | + do { | |
| 124 | + let download = directory.appendingPathComponent("original") | |
| 125 | + if url.isFileURL { | |
| 126 | + try FileManager.default.copyItem(at: url, to: download) | |
| 127 | + } else if url.scheme == "data" { | |
| 128 | + try Data(contentsOf: url).write(to: download, options: .atomic) | |
| 129 | + } else { | |
| 130 | + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { | |
| 131 | + throw URLError(.unsupportedURL) | |
| 132 | + } | |
| 133 | + let (temporaryFile, response) = try await URLSession.shared.download(from: url) | |
| 134 | + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { | |
| 135 | + throw URLError(.badServerResponse) | |
| 136 | + } | |
| 137 | + try FileManager.default.moveItem(at: temporaryFile, to: download) | |
| 138 | + } | |
| 139 | + try Task.checkCancellation() | |
| 140 | + let type: UTType | |
| 141 | + if let image = CGImageSourceCreateWithURL(download as CFURL, nil), | |
| 142 | + CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), | |
| 143 | + let detectedType = UTType(imageType as String) { | |
| 144 | + type = detectedType | |
| 145 | + } else if CGPDFDocument(download as CFURL) != nil { | |
| 146 | + type = .pdf | |
| 147 | + } else { | |
| 148 | + throw URLError(.cannotDecodeContentData) | |
| 149 | + } | |
| 150 | + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString | |
| 151 | + let originalExtension = filename.pathExtension | |
| 152 | + let fileExtension = UTType(filenameExtension: originalExtension) == type | |
| 153 | + ? originalExtension : type.preferredFilenameExtension ?? "png" | |
| 154 | + let stem = filename.deletingPathExtension | |
| 155 | + var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") | |
| 156 | + while name.utf8.count > 200 { name.removeLast() } | |
| 157 | + let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") | |
| 158 | + try FileManager.default.moveItem(at: download, to: file) | |
| 159 | + return file | |
| 160 | + } catch { | |
| 161 | + try? FileManager.default.removeItem(at: directory) | |
| 162 | + throw error | |
| 163 | + } | |
| 164 | + } | |
| 165 | +} | |
| @@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native"; · 5 unmodified lines | ||
| 6 | 6 | import { AppText as Text } from "./AppText"; |
| 7 | 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | −import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 9 | +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 10 | +import { PresentationSource } from "./NativePresentation"; | |
| 11 | +import type { FilePreviewSource } from "./FilePreviewModal"; | |
| 12 | +import { isPdfFile } from "../lib/filePreview"; | |
| 10 | 13 | |
| 11 | 14 | export interface ComposerAttachmentStripProps { |
| 12 | 15 | /** Attachments to display. */ |
| 13 | 16 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 14 | 17 | /** Called when the user removes an attachment. */ |
| 15 | 18 | readonly onRemove: (imageId: string) => void; |
| 16 | − /** Called when the user taps on an image thumbnail to preview it. */ | |
| 17 | − readonly onPressImage?: (previewUri: string) => void; | |
| 19 | + /** Called when the user taps an image or PDF to preview it. */ | |
| 20 | + readonly onPressPreview?: (source: FilePreviewSource) => void; | |
| 18 | 21 | readonly onPressVideo?: ( |
| 19 | 22 | attachment: DraftComposerFileAttachment, |
| 20 | 23 | sourceIdentifier: string, |
| @@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { · 11 unmodified lines | ||
| 32 | 35 | readonly size: number; |
| 33 | 36 | readonly borderRadius: number; |
| 34 | 37 | readonly compact?: boolean; |
| 35 | − readonly onPressImage?: (previewUri: string) => void; | |
| 38 | + readonly onPressPreview?: (source: FilePreviewSource) => void; | |
| 36 | 39 | readonly onPressVideo?: ( |
| 37 | 40 | attachment: DraftComposerFileAttachment, |
| 38 | 41 | sourceIdentifier: string, |
| @@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines | ||
| 41 | 44 | const { attachment } = props; |
| 42 | 45 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 43 | 46 | if (attachment.type === "image") { |
| 47 | + const sourceIdentifier = `draft-image:${attachment.id}`; | |
| 44 | 48 | return ( |
| 45 | − <Pressable | |
| 46 | − onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined} | |
| 47 | − > | |
| 48 | − <Image | |
| 49 | − source={{ uri: attachment.previewUri }} | |
| 50 | − style={style} | |
| 51 | − className="bg-subtle" | |
| 52 | − resizeMode="cover" | |
| 53 | − /> | |
| 54 | − </Pressable> | |
| 49 | + <PresentationSource identifier={sourceIdentifier}> | |
| 50 | + <Pressable | |
| 51 | + accessibilityRole="imagebutton" | |
| 52 | + accessibilityLabel={`Open ${attachment.name}`} | |
| 53 | + disabled={!props.onPressPreview} | |
| 54 | + onPress={() => | |
| 55 | + props.onPressPreview?.({ | |
| 56 | + kind: "image", | |
| 57 | + uri: attachment.dataUrl, | |
| 58 | + name: attachment.name, | |
| 59 | + sourceIdentifier, | |
| 60 | + }) | |
| 61 | + } | |
| 62 | + > | |
| 63 | + <Image | |
| 64 | + source={{ uri: attachment.previewUri }} | |
| 65 | + style={style} | |
| 66 | + className="bg-subtle" | |
| 67 | + resizeMode="cover" | |
| 68 | + /> | |
| 69 | + </Pressable> | |
| 70 | + </PresentationSource> | |
| 55 | 71 | ); |
| 56 | 72 | } |
| 57 | 73 | const onPressVideo = props.onPressVideo; |
| @@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines | ||
| 60 | 76 | <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} /> |
| 61 | 77 | ); |
| 62 | 78 | } |
| 79 | + const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; | |
| 80 | + const sourceIdentifier = `draft-file:${attachment.id}`; | |
| 63 | 81 | return ( |
| 64 | − <View | |
| 65 | − className={ | |
| 66 | − props.compact | |
| 67 | − ? "items-center justify-center bg-subtle" | |
| 68 | − : "items-center justify-center gap-1 bg-subtle px-2" | |
| 69 | − } | |
| 70 | − style={style} | |
| 71 | − > | |
| 72 | − <SymbolView | |
| 73 | − name="doc.text" | |
| 74 | − size={props.compact ? 15 : 22} | |
| 75 | − tintColor="#a3a3a3" | |
| 76 | − type="monochrome" | |
| 77 | − /> | |
| 78 | − {!props.compact ? ( | |
| 79 | − <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 80 | − {attachment.name} | |
| 81 | − </Text> | |
| 82 | − ) : null} | |
| 83 | − </View> | |
| 82 | + <PresentationSource identifier={sourceIdentifier}> | |
| 83 | + <Pressable | |
| 84 | + accessibilityRole="button" | |
| 85 | + accessibilityLabel={`Open ${attachment.name}`} | |
| 86 | + disabled={!canPreview} | |
| 87 | + onPress={() => | |
| 88 | + props.onPressPreview?.({ | |
| 89 | + kind: "pdf", | |
| 90 | + name: attachment.name, | |
| 91 | + attachment, | |
| 92 | + sourceIdentifier, | |
| 93 | + }) | |
| 94 | + } | |
| 95 | + className={ | |
| 96 | + props.compact | |
| 97 | + ? "items-center justify-center bg-subtle" | |
| 98 | + : "items-center justify-center gap-1 bg-subtle px-2" | |
| 99 | + } | |
| 100 | + style={style} | |
| 101 | + > | |
| 102 | + <SymbolView | |
| 103 | + name="doc.text" | |
| 104 | + size={props.compact ? 15 : 22} | |
| 105 | + tintColor="#a3a3a3" | |
| 106 | + type="monochrome" | |
| 107 | + /> | |
| 108 | + {!props.compact ? ( | |
| 109 | + <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | |
| 110 | + {attachment.name} | |
| 111 | + </Text> | |
| 112 | + ) : null} | |
| 113 | + </Pressable> | |
| 114 | + </PresentationSource> | |
| 84 | 115 | ); |
| 85 | 116 | } |
| 86 | 117 | |
| @@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { · 26 unmodified lines | ||
| 113 | 144 | shareRef.current = controller; |
| 114 | 145 | setSharing(true); |
| 115 | 146 | void (async () => { |
| 116 | − const preview = await loadLocalVideoPreview(attachment, controller.signal); | |
| 147 | + const preview = await loadLocalAttachmentPreview(attachment, controller.signal); | |
| 117 | 148 | if (!preview) return; |
| 118 | 149 | try { |
| 119 | 150 | await preview.share(controller.signal, sourceIdentifier); |
| @@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 65 unmodified lines | ||
| 185 | 216 | attachment={attachment} |
| 186 | 217 | size={size} |
| 187 | 218 | borderRadius={radius} |
| 188 | − onPressImage={props.onPressImage} | |
| 219 | + onPressPreview={props.onPressPreview} | |
| 189 | 220 | onPressVideo={props.onPressVideo} |
| 190 | 221 | /> |
| 191 | 222 | <Pressable |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { requireNativeModule } from "expo"; | |
| 2 | +import { useEffect, useEffectEvent, useId } from "react"; | |
| 3 | +import { Alert } from "react-native"; | |
| 4 | + | |
| 5 | +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | |
| 6 | + | |
| 7 | +const NativeControls = requireNativeModule<{ | |
| 8 | + presentFile( | |
| 9 | + uri: string, | |
| 10 | + name: string, | |
| 11 | + sourceIdentifier: string, | |
| 12 | + identifier: string, | |
| 13 | + ): Promise<void>; | |
| 14 | + dismissFile(identifier: string): Promise<void>; | |
| 15 | +}>("T3NativeControls"); | |
| 16 | + | |
| 17 | +export function FilePreview(props: { | |
| 18 | + readonly source: ResolvedFilePreviewSource; | |
| 19 | + readonly onRequestClose: () => void; | |
| 20 | +}) { | |
| 21 | + const { uri, name, sourceIdentifier } = props.source; | |
| 22 | + const identifier = useId(); | |
| 23 | + const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 24 | + | |
| 25 | + useEffect(() => { | |
| 26 | + let canceled = false; | |
| 27 | + void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) | |
| 28 | + .catch(() => { | |
| 29 | + if (!canceled) { | |
| 30 | + Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); | |
| 31 | + } | |
| 32 | + }) | |
| 33 | + .finally(() => { | |
| 34 | + if (!canceled) onRequestClose(); | |
| 35 | + }); | |
| 36 | + return () => { | |
| 37 | + canceled = true; | |
| 38 | + void NativeControls.dismissFile(identifier).catch(() => undefined); | |
| 39 | + }; | |
| 40 | + }, [uri, name, sourceIdentifier, identifier]); | |
| 41 | + | |
| 42 | + return null; | |
| 43 | +} | |
| @@ -0,0 +1,52 @@ | ||
| 1 | +import { useEffect, useEffectEvent } from "react"; | |
| 2 | +import { Alert } from "react-native"; | |
| 3 | +import ImageViewing from "react-native-image-viewing"; | |
| 4 | + | |
| 5 | +import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; | |
| 6 | +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | |
| 7 | + | |
| 8 | +function PdfPreview(props: { | |
| 9 | + readonly source: ResolvedFilePreviewSource; | |
| 10 | + readonly onRequestClose: () => void; | |
| 11 | +}) { | |
| 12 | + const { uri, name } = props.source; | |
| 13 | + const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 14 | + useEffect(() => { | |
| 15 | + const controller = new AbortController(); | |
| 16 | + const input = { | |
| 17 | + attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, | |
| 18 | + signal: controller.signal, | |
| 19 | + }; | |
| 20 | + // Android's system chooser supplies the installed PDF apps. | |
| 21 | + const opened = | |
| 22 | + uri.startsWith("file:") || uri.startsWith("content:") | |
| 23 | + ? shareLocalAttachment({ ...input, uri }) | |
| 24 | + : downloadAndShareAttachment({ ...input, url: uri }); | |
| 25 | + void opened | |
| 26 | + .catch(() => { | |
| 27 | + if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); | |
| 28 | + }) | |
| 29 | + .finally(() => { | |
| 30 | + if (!controller.signal.aborted) onRequestClose(); | |
| 31 | + }); | |
| 32 | + return () => controller.abort(); | |
| 33 | + }, [uri, name]); | |
| 34 | + return null; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export function FilePreview(props: { | |
| 38 | + readonly source: ResolvedFilePreviewSource; | |
| 39 | + readonly onRequestClose: () => void; | |
| 40 | +}) { | |
| 41 | + if (props.source.kind === "pdf") return <PdfPreview {...props} />; | |
| 42 | + return ( | |
| 43 | + <ImageViewing | |
| 44 | + images={[{ uri: props.source.uri }]} | |
| 45 | + imageIndex={0} | |
| 46 | + visible | |
| 47 | + onRequestClose={props.onRequestClose} | |
| 48 | + swipeToCloseEnabled | |
| 49 | + doubleTapToZoomEnabled | |
| 50 | + /> | |
| 51 | + ); | |
| 52 | +} | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +import { useIsFocused } from "@react-navigation/native"; | |
| 2 | +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | +import { useEffect, useEffectEvent, useState } from "react"; | |
| 4 | +import { Alert, Keyboard } from "react-native"; | |
| 5 | + | |
| 6 | +import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 7 | +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 8 | +import { useAssetUrlState } from "../state/assets"; | |
| 9 | +import { usePreparedConnection } from "../state/session"; | |
| 10 | +import { FilePreview } from "./FilePreview"; | |
| 11 | + | |
| 12 | +export interface ResolvedFilePreviewSource { | |
| 13 | + readonly kind: "image" | "pdf"; | |
| 14 | + readonly uri: string; | |
| 15 | + readonly name?: string; | |
| 16 | + readonly sourceIdentifier?: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & | |
| 20 | + ( | |
| 21 | + | { readonly uri: string } | |
| 22 | + | { readonly attachment: DraftComposerFileAttachment } | |
| 23 | + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } | |
| 24 | + ); | |
| 25 | + | |
| 26 | +function ResolvedFilePreview(props: { | |
| 27 | + readonly source: FilePreviewSource; | |
| 28 | + readonly onRequestClose: () => void; | |
| 29 | +}) { | |
| 30 | + const { source } = props; | |
| 31 | + const environmentId = "environmentId" in source ? source.environmentId : null; | |
| 32 | + const connection = usePreparedConnection(environmentId); | |
| 33 | + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); | |
| 34 | + // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. | |
| 35 | + const [uri, setUri] = useState<string | null>("uri" in source ? source.uri : null); | |
| 36 | + const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 37 | + const failed = | |
| 38 | + environmentId !== null && | |
| 39 | + uri === null && | |
| 40 | + (connection._tag === "None" || asset._tag === "Failure"); | |
| 41 | + useEffect(() => Keyboard.dismiss(), []); | |
| 42 | + useEffect(() => { | |
| 43 | + if (uri === null && asset._tag === "Success") setUri(asset.url); | |
| 44 | + }, [uri, asset]); | |
| 45 | + useEffect(() => { | |
| 46 | + if (!failed) return; | |
| 47 | + Alert.alert("Could not open preview", "Reconnect to this environment and try again."); | |
| 48 | + onRequestClose(); | |
| 49 | + }, [failed]); | |
| 50 | + useEffect(() => { | |
| 51 | + if (!("attachment" in source)) return; | |
| 52 | + const controller = new AbortController(); | |
| 53 | + let release: (() => void) | undefined; | |
| 54 | + void loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 55 | + .then((file) => { | |
| 56 | + if (!file) return; | |
| 57 | + if (controller.signal.aborted) { | |
| 58 | + file.dispose(); | |
| 59 | + return; | |
| 60 | + } | |
| 61 | + release = file.dispose; | |
| 62 | + setUri(file.uri); | |
| 63 | + }) | |
| 64 | + .catch(() => { | |
| 65 | + if (controller.signal.aborted) return; | |
| 66 | + Alert.alert("Could not open preview", "Attach the file again and retry."); | |
| 67 | + onRequestClose(); | |
| 68 | + }); | |
| 69 | + return () => { | |
| 70 | + controller.abort(); | |
| 71 | + release?.(); | |
| 72 | + }; | |
| 73 | + }, [source]); | |
| 74 | + | |
| 75 | + return uri === null ? null : ( | |
| 76 | + <FilePreview source={{ ...source, uri }} onRequestClose={props.onRequestClose} /> | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +export function FilePreviewModal(props: { | |
| 81 | + readonly source: FilePreviewSource | null; | |
| 82 | + readonly onRequestClose: () => void; | |
| 83 | +}) { | |
| 84 | + const isFocused = useIsFocused(); | |
| 85 | + const hasSource = props.source !== null; | |
| 86 | + const onRequestClose = useEffectEvent(props.onRequestClose); | |
| 87 | + useEffect(() => { | |
| 88 | + if (!isFocused && hasSource) onRequestClose(); | |
| 89 | + }, [isFocused, hasSource]); | |
| 90 | + | |
| 91 | + if (!props.source || !isFocused) return null; | |
| 92 | + return <ResolvedFilePreview source={props.source} onRequestClose={props.onRequestClose} />; | |
| 93 | +} | |
| @@ -4,7 +4,7 @@ import { requireNativeModule } from "expo"; · 3 unmodified lines | ||
| 4 | 4 | import { useEffect, useEffectEvent, useId, useState } from "react"; |
| 5 | 5 | import { Alert, Keyboard } from "react-native"; |
| 6 | 6 | |
| 7 | −import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 7 | +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 8 | 8 | import { useAssetUrlState } from "../state/assets"; |
| 9 | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; |
| @@ -67,7 +67,7 @@ function NativeVideoPreview(props: { · 56 unmodified lines | ||
| 67 | 67 | void (async () => { |
| 68 | 68 | const file = |
| 69 | 69 | source.type === "local" |
| 70 | − ? await loadLocalVideoPreview(source.attachment, controller.signal) | |
| 70 | + ? await loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 71 | 71 | : null; |
| 72 | 72 | if (source.type === "local" && !file) return; |
| 73 | 73 | try { |
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | type AttachmentPreviewFile, |
| 21 | 21 | } from "../lib/attachmentDownload"; |
| 22 | 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 23 | −import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 23 | +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 24 | 24 | import { useAssetUrlState } from "../state/assets"; |
| 25 | 25 | import { usePreparedConnection } from "../state/session"; |
| 26 | 26 | import { SymbolView } from "./AppSymbol"; |
| @@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { · 128 unmodified lines | ||
| 155 | 155 | setFailure(null); |
| 156 | 156 | const loading = |
| 157 | 157 | source.type === "local" |
| 158 | − ? loadLocalVideoPreview(source.attachment, controller.signal) | |
| 158 | + ? loadLocalAttachmentPreview(source.attachment, controller.signal) | |
| 159 | 159 | : downloadAttachmentForPreview({ |
| 160 | 160 | url: downloadUrl!, |
| 161 | 161 | attachment: { name: attachment.name, mimeType }, |
| @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; · 4 unmodified lines | ||
| 5 | 5 | import { StyleSheet } from "react-native"; |
| 6 | 6 | |
| 7 | 7 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | −import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | |
| 8 | +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 9 | 9 | import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; |
| 10 | 10 | |
| 11 | 11 | export function VideoThumbnailImage(props: { |
| @@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { · 13 unmodified lines | ||
| 25 | 25 | async (signal) => |
| 26 | 26 | typeof source === "string" |
| 27 | 27 | ? { uri: source, dispose: () => undefined } |
| 28 | − : loadLocalVideoPreview(source, signal), | |
| 28 | + : loadLocalAttachmentPreview(source, signal), | |
| 29 | 29 | controller.signal, |
| 30 | 30 | ).then((thumbnail) => { |
| 31 | 31 | if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); |
| @@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; · 16 unmodified lines | ||
| 17 | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 18 | 18 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 19 | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 20 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 20 | 21 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 21 | 22 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| 22 | 23 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 24 | +import { isPdfFile } from "../../lib/filePreview"; | |
| 23 | 25 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 24 | 26 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 25 | 27 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 461 unmodified lines | ||
| 487 | 489 | readonly mode: FileViewMode; |
| 488 | 490 | } | null>(null); |
| 489 | 491 | const [previewRevision, setPreviewRevision] = useState(0); |
| 492 | + const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null); | |
| 490 | 493 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); |
| 491 | 494 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); |
| 492 | 495 | const canPreview = |
| @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 93 unmodified lines | ||
| 586 | 589 | inline: false, |
| 587 | 590 | onPress: () => copyTextWithHaptic(relativePath), |
| 588 | 591 | } as const, |
| 592 | + isPdfFile({ name: relativePath }) && previewUri !== null | |
| 593 | + ? ({ | |
| 594 | + id: "open-pdf", | |
| 595 | + title: "Open PDF", | |
| 596 | + icon: "arrow.up.left.and.arrow.down.right", | |
| 597 | + inline: false, | |
| 598 | + onPress: () => | |
| 599 | + setFullScreenPreview({ | |
| 600 | + kind: "pdf", | |
| 601 | + uri: previewUri, | |
| 602 | + name: basename(relativePath), | |
| 603 | + }), | |
| 604 | + } as const) | |
| 605 | + : null, | |
| 589 | 606 | isBrowserFile && typeof assetPreviewUri === "string" |
| 590 | 607 | ? ({ |
| 591 | 608 | id: "open-browser", |
| @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 13 unmodified lines | ||
| 605 | 622 | } as const) |
| 606 | 623 | : null, |
| 607 | 624 | ].filter((action) => action !== null); |
| 608 | − }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); | |
| 625 | + }, [ | |
| 626 | + assetPreviewUri, | |
| 627 | + previewUri, | |
| 628 | + canPreview, | |
| 629 | + isBrowserFile, | |
| 630 | + isImageFile, | |
| 631 | + relativePath, | |
| 632 | + resolvedActiveMode, | |
| 633 | + ]); | |
| 609 | 634 | |
| 610 | 635 | const androidFileMenuActions = useMemo<MenuAction[]>( |
| 611 | 636 | () => |
| @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 154 unmodified lines | ||
| 766 | 791 | truncated={fileData?.truncated ?? false} |
| 767 | 792 | onRefresh={() => fileQuery.refresh()} |
| 768 | 793 | /> |
| 794 | + <FilePreviewModal | |
| 795 | + source={fullScreenPreview} | |
| 796 | + onRequestClose={() => setFullScreenPreview(null)} | |
| 797 | + /> | |
| 769 | 798 | </View> |
| 770 | 799 | </ReviewHighlighterProvider> |
| 771 | 800 | ); |
| @@ -1,24 +1,25 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | −import { useMemo, useState } from "react"; | |
| 2 | +import { useId, useMemo, useState } from "react"; | |
| 3 | 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; |
| 4 | −import ImageViewing from "react-native-image-viewing"; | |
| 5 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 6 | 5 | |
| 7 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 7 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | 8 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; |
| 9 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 10 | +import { PresentationSource } from "../../components/NativePresentation"; | |
| 10 | 11 | |
| 11 | 12 | function ResolvedWorkspaceFileImagePreview(props: { |
| 12 | 13 | readonly accessibilityLabel: string; |
| 13 | 14 | readonly uri: string; |
| 14 | 15 | }) { |
| 15 | 16 | const [loadError, setLoadError] = useState<string | null>(null); |
| 16 | − const [fullScreenVisible, setFullScreenVisible] = useState(false); | |
| 17 | + const [preview, setPreview] = useState<FilePreviewSource | null>(null); | |
| 18 | + const sourceIdentifier = useId(); | |
| 17 | 19 | const imageSource = useMemo( |
| 18 | 20 | () => ({ uri: props.uri, cache: "force-cache" as const }), |
| 19 | 21 | [props.uri], |
| 20 | 22 | ); |
| 21 | − const fullScreenImages = useMemo(() => [imageSource], [imageSource]); | |
| 22 | 23 | |
| 23 | 24 | return ( |
| 24 | 25 | <View className="relative flex-1 bg-subtle"> |
| @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines | ||
| 27 | 28 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} |
| 28 | 29 | disabled={loadError !== null} |
| 29 | 30 | className="flex-1 p-4 active:bg-subtle-strong" |
| 30 | − onPress={() => setFullScreenVisible(true)} | |
| 31 | + onPress={() => | |
| 32 | + setPreview({ | |
| 33 | + kind: "image", | |
| 34 | + uri: props.uri, | |
| 35 | + name: props.accessibilityLabel, | |
| 36 | + sourceIdentifier, | |
| 37 | + }) | |
| 38 | + } | |
| 31 | 39 | > |
| 32 | − <Image | |
| 33 | − accessible={false} | |
| 34 | − source={imageSource} | |
| 35 | − className="h-full w-full" | |
| 36 | − resizeMode="contain" | |
| 37 | − onLoadStart={() => setLoadError(null)} | |
| 38 | − onError={(event) => { | |
| 39 | − setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 40 | − }} | |
| 41 | − /> | |
| 40 | + <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> | |
| 41 | + <Image | |
| 42 | + accessible={false} | |
| 43 | + source={imageSource} | |
| 44 | + className="h-full w-full" | |
| 45 | + resizeMode="contain" | |
| 46 | + onLoadStart={() => setLoadError(null)} | |
| 47 | + onError={(event) => { | |
| 48 | + setLoadError(event.nativeEvent.error || "The image could not be rendered."); | |
| 49 | + }} | |
| 50 | + /> | |
| 51 | + </PresentationSource> | |
| 42 | 52 | </Pressable> |
| 43 | 53 | |
| 44 | 54 | {loadError !== null ? ( |
| @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines | ||
| 47 | 57 | </View> |
| 48 | 58 | ) : null} |
| 49 | 59 | |
| 50 | − <ImageViewing | |
| 51 | − images={fullScreenImages} | |
| 52 | − imageIndex={0} | |
| 53 | − visible={fullScreenVisible} | |
| 54 | − onRequestClose={() => setFullScreenVisible(false)} | |
| 55 | − swipeToCloseEnabled | |
| 56 | − doubleTapToZoomEnabled | |
| 57 | − /> | |
| 60 | + <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | |
| 58 | 61 | </View> |
| 59 | 62 | ); |
| 60 | 63 | } |
| @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 4 unmodified lines | ||
| 5 | 5 | import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; |
| 6 | 6 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | −import ImageViewing from "react-native-image-viewing"; | |
| 8 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 9 | 9 | |
| 10 | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 11 | 11 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 41 unmodified lines | ||
| 53 | 53 | Record<string, ReadonlyArray<ReviewHighlightedToken>> |
| 54 | 54 | >({}); |
| 55 | 55 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); |
| 56 | − const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | |
| 56 | + const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | |
| 57 | 57 | |
| 58 | 58 | const selectedLines = useMemo( |
| 59 | 59 | () => (target ? getSelectedReviewCommentLines(target) : []), |
| @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 212 unmodified lines | ||
| 272 | 272 | attachments={attachments} |
| 273 | 273 | imageBorderRadius={16} |
| 274 | 274 | imageSize={60} |
| 275 | − onPressImage={setPreviewImageUri} | |
| 275 | + onPressPreview={setPreviewFile} | |
| 276 | 276 | removeButtonPlacement="gutter" |
| 277 | 277 | onRemove={(imageId) => { |
| 278 | 278 | setAttachments((current) => |
| @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 53 unmodified lines | ||
| 332 | 332 | </View> |
| 333 | 333 | </KeyboardStickyView> |
| 334 | 334 | ) : null} |
| 335 | − <ImageViewing | |
| 336 | − images={previewImageUri ? [{ uri: previewImageUri }] : []} | |
| 337 | − imageIndex={0} | |
| 338 | − visible={previewImageUri !== null} | |
| 339 | − onRequestClose={() => setPreviewImageUri(null)} | |
| 340 | − swipeToCloseEnabled | |
| 341 | − doubleTapToZoomEnabled | |
| 342 | − /> | |
| 335 | + <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} /> | |
| 343 | 336 | </View> |
| 344 | 337 | ); |
| 345 | 338 | } |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | ||
| 35 | 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 38 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 38 | 39 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 39 | 40 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 40 | 41 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { · 126 unmodified lines | ||
| 167 | 168 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 168 | 169 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 169 | 170 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 170 | − const wasFocusedBeforeVideoRef = useRef(false); | |
| 171 | + const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | |
| 172 | + const wasFocusedBeforePreviewRef = useRef(false); | |
| 171 | 173 | const openVideoPreview = useCallback( |
| 172 | 174 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 173 | − wasFocusedBeforeVideoRef.current = isComposerFocused; | |
| 175 | + wasFocusedBeforePreviewRef.current = isComposerFocused; | |
| 176 | + setPreviewFile(null); | |
| 174 | 177 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 175 | 178 | }, |
| 176 | 179 | [isComposerFocused], |
| 177 | 180 | ); |
| 178 | − const closeVideoPreview = useCallback(() => { | |
| 181 | + const openFilePreview = useCallback( | |
| 182 | + (source: FilePreviewSource) => { | |
| 183 | + wasFocusedBeforePreviewRef.current = isComposerFocused; | |
| 184 | + setPreviewVideo(null); | |
| 185 | + setPreviewFile((current) => current ?? source); | |
| 186 | + }, | |
| 187 | + [isComposerFocused], | |
| 188 | + ); | |
| 189 | + const closeMediaPreview = useCallback(() => { | |
| 179 | 190 | setPreviewVideo(null); |
| 180 | − if (wasFocusedBeforeVideoRef.current) { | |
| 191 | + setPreviewFile(null); | |
| 192 | + if (wasFocusedBeforePreviewRef.current) { | |
| 181 | 193 | setTimeout(() => { |
| 182 | 194 | if (navigation.isFocused()) promptInputRef.current?.focus(); |
| 183 | 195 | }, 100); |
| @@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { · 1028 unmodified lines | ||
| 1212 | 1224 | ? () => undefined |
| 1213 | 1225 | : flow.removeAttachment |
| 1214 | 1226 | } |
| 1227 | + onPressPreview={ | |
| 1228 | + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview | |
| 1229 | + } | |
| 1215 | 1230 | onPressVideo={ |
| 1216 | 1231 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview |
| 1217 | 1232 | } |
| @@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { · 100 unmodified lines | ||
| 1318 | 1333 | </ComposerDictationToolbar> |
| 1319 | 1334 | </Animated.View> |
| 1320 | 1335 | </ComposerSurface> |
| 1321 | − <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} /> | |
| 1336 | + <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} /> | |
| 1337 | + <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} /> | |
| 1322 | 1338 | </View> |
| 1323 | 1339 | ); |
| 1324 | 1340 | |
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | type RefObject, |
| 21 | 21 | } from "react"; |
| 22 | 22 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 23 | −import ImageViewing from "react-native-image-viewing"; | |
| 23 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 24 | 24 | import Animated, { |
| 25 | 25 | FadeIn, |
| 26 | 26 | FadeInDown, |
| @@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 284 unmodified lines | ||
| 311 | 311 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 312 | 312 | const { onExpandedChange } = props; |
| 313 | 313 | |
| 314 | − const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | |
| 314 | + const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | |
| 315 | 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 316 | 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 317 | 317 | const showStopAction = |
| @@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 54 unmodified lines | ||
| 372 | 372 | onExpandedChange?.(isExpanded); |
| 373 | 373 | }, [isExpanded, onExpandedChange]); |
| 374 | 374 | |
| 375 | − const onPressImage = useCallback( | |
| 376 | − (uri: string) => { | |
| 375 | + const onPressPreview = useCallback( | |
| 376 | + (source: FilePreviewSource) => { | |
| 377 | 377 | wasExpandedBeforePreviewRef.current = isFocused; |
| 378 | 378 | setPreviewVideo(null); |
| 379 | − setPreviewImageUri(uri); | |
| 379 | + setPreviewFile((current) => current ?? source); | |
| 380 | 380 | }, |
| 381 | 381 | [isFocused], |
| 382 | 382 | ); |
| 383 | 383 | |
| 384 | 384 | const closePreview = useCallback(() => { |
| 385 | − setPreviewImageUri(null); | |
| 385 | + setPreviewFile(null); | |
| 386 | 386 | setPreviewVideo(null); |
| 387 | 387 | if (wasExpandedBeforePreviewRef.current) { |
| 388 | 388 | setTimeout(() => { |
| @@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 5 unmodified lines | ||
| 394 | 394 | const onPressVideo = useCallback( |
| 395 | 395 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 396 | 396 | wasExpandedBeforePreviewRef.current = isFocused; |
| 397 | − setPreviewImageUri(null); | |
| 397 | + setPreviewFile(null); | |
| 398 | 398 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 399 | 399 | }, |
| 400 | 400 | [isFocused], |
| @@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 218 unmodified lines | ||
| 619 | 619 | <ComposerAttachmentStrip |
| 620 | 620 | attachments={props.draftAttachments} |
| 621 | 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 622 | − onPressImage={voiceInput.isBusy ? undefined : onPressImage} | |
| 622 | + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} | |
| 623 | 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} |
| 624 | 624 | /> |
| 625 | 625 | </Animated.View> |
| @@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 47 unmodified lines | ||
| 673 | 673 | size={30} |
| 674 | 674 | borderRadius={8} |
| 675 | 675 | compact |
| 676 | − onPressImage={onPressImage} | |
| 676 | + onPressPreview={onPressPreview} | |
| 677 | 677 | onPressVideo={onPressVideo} |
| 678 | 678 | /> |
| 679 | 679 | ))} |
| @@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 139 unmodified lines | ||
| 819 | 819 | </Animated.View> |
| 820 | 820 | |
| 821 | 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> |
| 822 | − <ImageViewing | |
| 823 | − images={previewImageUri ? [{ uri: previewImageUri }] : []} | |
| 824 | − imageIndex={0} | |
| 825 | − visible={previewImageUri !== null} | |
| 826 | − onRequestClose={closePreview} | |
| 827 | − swipeToCloseEnabled | |
| 828 | − doubleTapToZoomEnabled | |
| 829 | − /> | |
| 822 | + <FilePreviewModal source={previewFile} onRequestClose={closePreview} /> | |
| 830 | 823 | </Animated.View> |
| 831 | 824 | ); |
| 832 | 825 | }); |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | useMemo, |
| 37 | 37 | useRef, |
| 38 | 38 | useState, |
| 39 | + useId, | |
| 39 | 40 | type ReactNode, |
| 40 | 41 | type RefObject, |
| 41 | 42 | } from "react"; |
| @@ -62,8 +63,9 @@ import { · 20 unmodified lines | ||
| 62 | 63 | View, |
| 63 | 64 | type ViewStyle, |
| 64 | 65 | } from "react-native"; |
| 65 | −import { TouchableOpacity } from "react-native-gesture-handler"; | |
| 66 | −import ImageViewing from "react-native-image-viewing"; | |
| 66 | +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 67 | +import { isPdfFile } from "../../lib/filePreview"; | |
| 68 | +import { PresentationSource } from "../../components/NativePresentation"; | |
| 67 | 69 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 68 | 70 | import Animated, { |
| 69 | 71 | FadeIn, |
| @@ -212,9 +214,11 @@ export interface ThreadFeedProps { · 142 unmodified lines | ||
| 212 | 214 | function MessageAttachmentImage(props: { |
| 213 | 215 | readonly environmentId: EnvironmentId; |
| 214 | 216 | readonly attachmentId: string; |
| 217 | + readonly name: string; | |
| 215 | 218 | readonly className: string; |
| 216 | − readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | |
| 219 | + readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 217 | 220 | }) { |
| 221 | + const sourceIdentifier = useId(); | |
| 218 | 222 | const uri = useAssetUrl(props.environmentId, { |
| 219 | 223 | _tag: "attachment", |
| 220 | 224 | attachmentId: props.attachmentId, |
| @@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { · 8 unmodified lines | ||
| 229 | 233 | } |
| 230 | 234 | |
| 231 | 235 | return ( |
| 232 | − <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}> | |
| 233 | − <Image source={{ uri }} className={props.className} resizeMode="cover" /> | |
| 234 | − </TouchableOpacity> | |
| 236 | + <PresentationSource identifier={sourceIdentifier}> | |
| 237 | + <Pressable | |
| 238 | + accessibilityRole="imagebutton" | |
| 239 | + accessibilityLabel={`Open ${props.name}`} | |
| 240 | + onPress={() => | |
| 241 | + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) | |
| 242 | + } | |
| 243 | + > | |
| 244 | + <Image source={{ uri }} className={props.className} resizeMode="cover" /> | |
| 245 | + </Pressable> | |
| 246 | + </PresentationSource> | |
| 235 | 247 | ); |
| 236 | 248 | } |
| 237 | 249 | |
| @@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt · 11 unmodified lines | ||
| 249 | 261 | function MessageAttachmentFile(props: { |
| 250 | 262 | readonly environmentId: EnvironmentId; |
| 251 | 263 | readonly attachment: ChatFileAttachment; |
| 264 | + readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 252 | 265 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 253 | 266 | }) { |
| 267 | + const sourceIdentifier = useId(); | |
| 254 | 268 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 255 | 269 | reportFailure: false, |
| 256 | 270 | }); |
| 257 | 271 | const preparedConnection = usePreparedConnection(props.environmentId); |
| 258 | 272 | const { attachment } = props; |
| 259 | 273 | const videoType = videoMimeType(attachment); |
| 274 | + const isPdf = isPdfFile(attachment); | |
| 275 | + const fileTypeLabel = isPdf | |
| 276 | + ? "PDF" | |
| 277 | + : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); | |
| 278 | + const sizeLabel = formatAttachmentSize(attachment.sizeBytes); | |
| 260 | 279 | const thumbnailUrl = useAssetUrl( |
| 261 | 280 | props.environmentId, |
| 262 | 281 | videoType === null |
| @@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { · 85 unmodified lines | ||
| 348 | 367 | } |
| 349 | 368 | |
| 350 | 369 | return ( |
| 351 | − <Pressable | |
| 352 | − accessibilityRole="button" | |
| 353 | − accessibilityLabel={`Open ${attachment.name}`} | |
| 354 | − accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | |
| 355 | − disabled={opening || httpBaseUrl === null} | |
| 356 | − className="flex-row items-center gap-2 py-1" | |
| 357 | − onPress={() => shareFile()} | |
| 370 | + <PresentationSource | |
| 371 | + identifier={sourceIdentifier} | |
| 372 | + className="my-1" | |
| 373 | + style={{ width: 280, maxWidth: "100%" }} | |
| 358 | 374 | > |
| 359 | − {opening ? ( | |
| 360 | − <ActivityIndicator size="small" /> | |
| 361 | − ) : ( | |
| 362 | − <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" /> | |
| 363 | − )} | |
| 364 | − <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}> | |
| 365 | − {attachment.name} | |
| 366 | − </Text> | |
| 367 | − <Text className="text-xs text-foreground-muted"> | |
| 368 | − {formatAttachmentSize(attachment.sizeBytes)} | |
| 369 | − </Text> | |
| 370 | − </Pressable> | |
| 375 | + <Pressable | |
| 376 | + accessibilityRole="button" | |
| 377 | + accessibilityLabel={`Open ${attachment.name}`} | |
| 378 | + accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }} | |
| 379 | + accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | |
| 380 | + disabled={opening || httpBaseUrl === null} | |
| 381 | + className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle" | |
| 382 | + onPress={() => | |
| 383 | + isPdf | |
| 384 | + ? props.onPressPreview({ | |
| 385 | + kind: "pdf", | |
| 386 | + name: attachment.name, | |
| 387 | + environmentId: props.environmentId, | |
| 388 | + resource: { | |
| 389 | + _tag: "attachment", | |
| 390 | + attachmentId: attachment.id, | |
| 391 | + fileName: attachment.name, | |
| 392 | + mimeType: "application/pdf", | |
| 393 | + }, | |
| 394 | + sourceIdentifier, | |
| 395 | + }) | |
| 396 | + : shareFile(sourceIdentifier) | |
| 397 | + } | |
| 398 | + > | |
| 399 | + <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle"> | |
| 400 | + {opening ? ( | |
| 401 | + <ActivityIndicator size="small" /> | |
| 402 | + ) : ( | |
| 403 | + <SymbolView | |
| 404 | + name="doc.text" | |
| 405 | + size={26} | |
| 406 | + tintColorClassName={isPdf ? "accent-red-500" : "accent-foreground-muted"} | |
| 407 | + type="monochrome" | |
| 408 | + /> | |
| 409 | + )} | |
| 410 | + </View> | |
| 411 | + <View className="min-w-0 flex-1 gap-1"> | |
| 412 | + <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}> | |
| 413 | + {attachment.name} | |
| 414 | + </Text> | |
| 415 | + <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 416 | + {fileTypeLabel} · {sizeLabel} | |
| 417 | + </Text> | |
| 418 | + </View> | |
| 419 | + <SymbolView | |
| 420 | + name="chevron.right" | |
| 421 | + size={12} | |
| 422 | + tintColorClassName="accent-foreground-muted" | |
| 423 | + type="monochrome" | |
| 424 | + /> | |
| 425 | + </Pressable> | |
| 426 | + </PresentationSource> | |
| 371 | 427 | ); |
| 372 | 428 | } |
| 373 | 429 | |
| @@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { · 17 unmodified lines | ||
| 391 | 447 | readonly sourceKey: string; |
| 392 | 448 | readonly unavailable: boolean; |
| 393 | 449 | readonly alt: string | null; |
| 394 | − readonly onPressImage: (uri: string) => void; | |
| 450 | + readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 395 | 451 | }) { |
| 452 | + const sourceIdentifier = useId(); | |
| 396 | 453 | const [availableWidth, setAvailableWidth] = useState(0); |
| 397 | 454 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 398 | 455 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| @@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { · 38 unmodified lines | ||
| 437 | 494 | )} |
| 438 | 495 | </View> |
| 439 | 496 | ) : ( |
| 440 | − <TouchableOpacity | |
| 441 | − accessibilityRole="imagebutton" | |
| 442 | − accessibilityLabel={props.alt ?? "Markdown image"} | |
| 443 | − activeOpacity={0.7} | |
| 444 | − onPress={() => props.onPressImage(props.uri!)} | |
| 445 | − style={{ alignSelf: "flex-start" }} | |
| 446 | − > | |
| 447 | − <View | |
| 448 | − className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 449 | − style={{ | |
| 450 | − ...frameStyle, | |
| 451 | − }} | |
| 497 | + <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> | |
| 498 | + <Pressable | |
| 499 | + accessibilityRole="imagebutton" | |
| 500 | + accessibilityLabel={props.alt ?? "Markdown image"} | |
| 501 | + onPress={() => | |
| 502 | + props.onPressPreview({ | |
| 503 | + kind: "image", | |
| 504 | + uri: props.uri!, | |
| 505 | + name: props.alt ?? "Image", | |
| 506 | + sourceIdentifier, | |
| 507 | + }) | |
| 508 | + } | |
| 509 | + style={{ alignSelf: "flex-start" }} | |
| 452 | 510 | > |
| 453 | − <ThreadMarkdownImageRequest | |
| 454 | − key={props.uri} | |
| 455 | − uri={props.uri} | |
| 456 | − onLoad={setSourceSize} | |
| 457 | − onError={() => setFailedUri(props.uri)} | |
| 458 | − /> | |
| 459 | − </View> | |
| 460 | − </TouchableOpacity> | |
| 511 | + <View | |
| 512 | + className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 513 | + style={{ | |
| 514 | + ...frameStyle, | |
| 515 | + }} | |
| 516 | + > | |
| 517 | + <ThreadMarkdownImageRequest | |
| 518 | + key={props.uri} | |
| 519 | + uri={props.uri} | |
| 520 | + onLoad={setSourceSize} | |
| 521 | + onError={() => setFailedUri(props.uri)} | |
| 522 | + /> | |
| 523 | + </View> | |
| 524 | + </Pressable> | |
| 525 | + </PresentationSource> | |
| 461 | 526 | )} |
| 462 | 527 | {props.alt ? ( |
| 463 | 528 | <Text selectable className="text-xs text-foreground-muted"> |
| @@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { · 42 unmodified lines | ||
| 506 | 571 | readonly threadId: ThreadId; |
| 507 | 572 | readonly path: string; |
| 508 | 573 | readonly alt: string | null; |
| 509 | − readonly onPressImage: (uri: string) => void; | |
| 574 | + readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 510 | 575 | }) { |
| 511 | 576 | const assetUrl = useAssetUrlState(props.environmentId, { |
| 512 | 577 | _tag: "workspace-file", |
| @@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { · 7 unmodified lines | ||
| 520 | 585 | sourceKey={props.path} |
| 521 | 586 | unavailable={assetUrl._tag === "Failure"} |
| 522 | 587 | alt={props.alt} |
| 523 | − onPressImage={props.onPressImage} | |
| 588 | + onPressPreview={props.onPressPreview} | |
| 524 | 589 | /> |
| 525 | 590 | ); |
| 526 | 591 | } |
| @@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) · 5 unmodified lines | ||
| 532 | 597 | sourceKey="unavailable" |
| 533 | 598 | unavailable |
| 534 | 599 | alt={props.alt} |
| 535 | − onPressImage={() => undefined} | |
| 600 | + onPressPreview={() => undefined} | |
| 536 | 601 | /> |
| 537 | 602 | ); |
| 538 | 603 | } |
| @@ -1264,7 +1329,7 @@ function renderFeedEntry( · 725 unmodified lines | ||
| 1264 | 1329 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 1265 | 1330 | readonly onToggleWorkRow: (rowId: string) => void; |
| 1266 | 1331 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1267 | − readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | |
| 1332 | + readonly onPressPreview: (source: FilePreviewSource) => void; | |
| 1268 | 1333 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 1269 | 1334 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1270 | 1335 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| @@ -1375,14 +1440,16 @@ function renderFeedEntry( · 104 unmodified lines | ||
| 1375 | 1440 | key={attachment.id} |
| 1376 | 1441 | environmentId={props.environmentId} |
| 1377 | 1442 | attachmentId={attachment.id} |
| 1443 | + name={attachment.name} | |
| 1378 | 1444 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1379 | − onPressImage={props.onPressImage} | |
| 1445 | + onPressPreview={props.onPressPreview} | |
| 1380 | 1446 | /> |
| 1381 | 1447 | ) : isFileAttachment(attachment) ? ( |
| 1382 | 1448 | <MessageAttachmentFile |
| 1383 | 1449 | key={attachment.id} |
| 1384 | 1450 | environmentId={props.environmentId} |
| 1385 | 1451 | attachment={attachment} |
| 1452 | + onPressPreview={props.onPressPreview} | |
| 1386 | 1453 | onPressVideo={props.onPressVideo} |
| 1387 | 1454 | /> |
| 1388 | 1455 | ) : ( |
| @@ -1436,14 +1503,16 @@ function renderFeedEntry( · 47 unmodified lines | ||
| 1436 | 1503 | key={attachment.id} |
| 1437 | 1504 | environmentId={props.environmentId} |
| 1438 | 1505 | attachmentId={attachment.id} |
| 1506 | + name={attachment.name} | |
| 1439 | 1507 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" |
| 1440 | − onPressImage={props.onPressImage} | |
| 1508 | + onPressPreview={props.onPressPreview} | |
| 1441 | 1509 | /> |
| 1442 | 1510 | ) : isFileAttachment(attachment) ? ( |
| 1443 | 1511 | <MessageAttachmentFile |
| 1444 | 1512 | key={attachment.id} |
| 1445 | 1513 | environmentId={props.environmentId} |
| 1446 | 1514 | attachment={attachment} |
| 1515 | + onPressPreview={props.onPressPreview} | |
| 1447 | 1516 | onPressVideo={props.onPressVideo} |
| 1448 | 1517 | /> |
| 1449 | 1518 | ) : ( |
| @@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 356 unmodified lines | ||
| 1806 | 1875 | expandedTurnIds: new Set(), |
| 1807 | 1876 | }); |
| 1808 | 1877 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; |
| 1809 | − const [expandedImage, setExpandedImage] = useState<{ | |
| 1810 | − uri: string; | |
| 1811 | − headers?: Record<string, string>; | |
| 1812 | − } | null>(null); | |
| 1878 | + const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); | |
| 1813 | 1879 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); |
| 1814 | 1880 | useEffect(() => { |
| 1815 | 1881 | setExpandedVideo(null); |
| 1882 | + setExpandedFile(null); | |
| 1816 | 1883 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); |
| 1817 | 1884 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1818 | 1885 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| @@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 39 unmodified lines | ||
| 1858 | 1925 | ); |
| 1859 | 1926 | if (relativePath) { |
| 1860 | 1927 | void Haptics.selectionAsync(); |
| 1928 | + if (isPdfFile({ name: relativePath })) { | |
| 1929 | + setExpandedFile( | |
| 1930 | + (current) => | |
| 1931 | + current ?? { | |
| 1932 | + kind: "pdf", | |
| 1933 | + name: relativePath.split("/").at(-1), | |
| 1934 | + environmentId: props.environmentId, | |
| 1935 | + resource: { | |
| 1936 | + _tag: "workspace-file", | |
| 1937 | + threadId: props.threadId, | |
| 1938 | + path: relativePath, | |
| 1939 | + }, | |
| 1940 | + }, | |
| 1941 | + ); | |
| 1942 | + return; | |
| 1943 | + } | |
| 1861 | 1944 | navigation.navigate("ThreadFile", { |
| 1862 | 1945 | environmentId: String(props.environmentId), |
| 1863 | 1946 | threadId: String(props.threadId), |
| @@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines | ||
| 1869 | 1952 | } |
| 1870 | 1953 | |
| 1871 | 1954 | if (presentation.href) { |
| 1955 | + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { | |
| 1956 | + setExpandedFile( | |
| 1957 | + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, | |
| 1958 | + ); | |
| 1959 | + return; | |
| 1960 | + } | |
| 1872 | 1961 | void tryOpenExternalUrl(presentation.href, "markdown-link"); |
| 1873 | 1962 | } |
| 1874 | 1963 | }, |
| @@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines | ||
| 1884 | 1973 | sourceKey={imageSource.uri} |
| 1885 | 1974 | unavailable={false} |
| 1886 | 1975 | alt={image.alt} |
| 1887 | − onPressImage={(uri) => setExpandedImage({ uri })} | |
| 1976 | + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} | |
| 1888 | 1977 | /> |
| 1889 | 1978 | ); |
| 1890 | 1979 | } |
| @@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines | ||
| 1897 | 1986 | threadId={props.threadId} |
| 1898 | 1987 | path={imageSource.path} |
| 1899 | 1988 | alt={image.alt} |
| 1900 | − onPressImage={(uri) => setExpandedImage({ uri })} | |
| 1989 | + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} | |
| 1901 | 1990 | /> |
| 1902 | 1991 | ); |
| 1903 | 1992 | }, |
| @@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 381 unmodified lines | ||
| 2285 | 2374 | [suspendEndScrollMaintenanceForDisclosure], |
| 2286 | 2375 | ); |
| 2287 | 2376 | |
| 2288 | − const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | |
| 2289 | − setExpandedImage({ uri, headers }); | |
| 2377 | + const onPressPreview = useCallback((source: FilePreviewSource) => { | |
| 2378 | + setExpandedFile((current) => current ?? source); | |
| 2290 | 2379 | }, []); |
| 2291 | 2380 | const onPressVideo = useCallback( |
| 2292 | 2381 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { |
| @@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 57 unmodified lines | ||
| 2350 | 2439 | onToggleWorkGroup, |
| 2351 | 2440 | onToggleWorkRow, |
| 2352 | 2441 | onToggleTurnFold, |
| 2353 | − onPressImage, | |
| 2442 | + onPressPreview, | |
| 2354 | 2443 | onPressVideo, |
| 2355 | 2444 | onMarkdownLinkPress, |
| 2356 | 2445 | renderMarkdownImage, |
| @@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 22 unmodified lines | ||
| 2379 | 2468 | userBubbleMaxWidth, |
| 2380 | 2469 | onCopyWorkRow, |
| 2381 | 2470 | onMarkdownLinkPress, |
| 2382 | − onPressImage, | |
| 2471 | + onPressPreview, | |
| 2383 | 2472 | onPressVideo, |
| 2384 | 2473 | onToggleTurnFold, |
| 2385 | 2474 | onToggleWorkGroup, |
| @@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 179 unmodified lines | ||
| 2565 | 2654 | </View> |
| 2566 | 2655 | |
| 2567 | 2656 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> |
| 2568 | − <ImageViewing | |
| 2569 | − images={ | |
| 2570 | − expandedImage | |
| 2571 | − ? [ | |
| 2572 | − { | |
| 2573 | − uri: expandedImage.uri, | |
| 2574 | − headers: expandedImage.headers, | |
| 2575 | − }, | |
| 2576 | − ] | |
| 2577 | − : [] | |
| 2578 | − } | |
| 2579 | − imageIndex={0} | |
| 2580 | − visible={expandedImage !== null} | |
| 2581 | − onRequestClose={() => setExpandedImage(null)} | |
| 2582 | − swipeToCloseEnabled | |
| 2583 | − doubleTapToZoomEnabled | |
| 2584 | − /> | |
| 2657 | + <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> | |
| 2585 | 2658 | </> |
| 2586 | 2659 | ); |
| 2587 | 2660 | }); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; | |
| 2 | 3 | import type { ImagePickerAsset } from "expo-image-picker"; |
| 3 | 4 | |
| 4 | 5 | const mocks = vi.hoisted(() => ({ |
| @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ · 4 unmodified lines | ||
| 9 | 10 | delete: vi.fn(), |
| 10 | 11 | open: vi.fn(), |
| 11 | 12 | size: vi.fn(), |
| 13 | + readBase64: vi.fn(), | |
| 12 | 14 | })); |
| 13 | 15 | |
| 14 | 16 | vi.mock("expo-file-system", () => { |
| @@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { · 40 unmodified lines | ||
| 55 | 57 | mocks.copy(this.uri, destination.uri); |
| 56 | 58 | } |
| 57 | 59 | |
| 60 | + async base64(): Promise<string> { | |
| 61 | + return mocks.readBase64(this.uri); | |
| 62 | + } | |
| 63 | + | |
| 58 | 64 | delete(): void { |
| 59 | 65 | mocks.delete(this.uri); |
| 60 | 66 | } |
| @@ -95,9 +101,115 @@ describe("composer file attachments", () => { · 34 unmodified lines | ||
| 95 | 101 | mocks.delete.mockReset(); |
| 96 | 102 | mocks.open.mockReset(); |
| 97 | 103 | mocks.size.mockReset(); |
| 104 | + mocks.readBase64.mockReset(); | |
| 98 | 105 | mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); |
| 99 | 106 | }); |
| 100 | 107 | |
| 108 | + describe("photo library image conversion", () => { | |
| 109 | + const jpeg = "/9j/2Q=="; | |
| 110 | + const photo: ImagePickerAsset = { | |
| 111 | + uri: "file:///picker/photo.heic", | |
| 112 | + type: "image", | |
| 113 | + fileName: "photo.HEIC", | |
| 114 | + mimeType: "image/heic", | |
| 115 | + fileSize: 20 * 1024 * 1024, | |
| 116 | + base64: jpeg, | |
| 117 | + width: 1, | |
| 118 | + height: 1, | |
| 119 | + }; | |
| 120 | + | |
| 121 | + it.each(["image/heic", "image/heif", undefined])( | |
| 122 | + "attaches the native JPEG conversion with matching metadata when the source MIME is %s", | |
| 123 | + async (mimeType) => { | |
| 124 | + mocks.pickMedia.mockResolvedValue({ | |
| 125 | + canceled: false, | |
| 126 | + assets: [{ ...photo, mimeType }], | |
| 127 | + }); | |
| 128 | + | |
| 129 | + const result = await pickComposerImages({ existingCount: 0 }); | |
| 130 | + | |
| 131 | + expect(result).toEqual({ | |
| 132 | + images: [ | |
| 133 | + { | |
| 134 | + id: "attachment-id", | |
| 135 | + type: "image", | |
| 136 | + name: "photo.jpg", | |
| 137 | + mimeType: "image/jpeg", | |
| 138 | + sizeBytes: 4, | |
| 139 | + dataUrl: `data:image/jpeg;base64,${jpeg}`, | |
| 140 | + previewUri: `data:image/jpeg;base64,${jpeg}`, | |
| 141 | + }, | |
| 142 | + ], | |
| 143 | + error: null, | |
| 144 | + }); | |
| 145 | + }, | |
| 146 | + ); | |
| 147 | + | |
| 148 | + it.each([ | |
| 149 | + { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, | |
| 150 | + { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, | |
| 151 | + { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, | |
| 152 | + ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { | |
| 153 | + const name = `photo.${original.extension}`; | |
| 154 | + mocks.pickMedia.mockResolvedValue({ | |
| 155 | + canceled: false, | |
| 156 | + assets: [{ ...photo, fileName: name, mimeType: original.mimeType }], | |
| 157 | + }); | |
| 158 | + mocks.readBase64.mockResolvedValue(original.base64); | |
| 159 | + | |
| 160 | + const result = await pickComposerImages({ existingCount: 0 }); | |
| 161 | + | |
| 162 | + expect(result.error).toBeNull(); | |
| 163 | + expect(result.images).toEqual([ | |
| 164 | + expect.objectContaining({ | |
| 165 | + name, | |
| 166 | + mimeType: original.mimeType, | |
| 167 | + dataUrl: `data:${original.mimeType};base64,${original.base64}`, | |
| 168 | + sizeBytes: Buffer.from(original.base64, "base64").byteLength, | |
| 169 | + }), | |
| 170 | + ]); | |
| 171 | + }); | |
| 172 | + | |
| 173 | + it("checks the converted JPEG size even when the HEIC source was smaller", async () => { | |
| 174 | + const oversized = | |
| 175 | + jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); | |
| 176 | + mocks.pickMedia.mockResolvedValue({ | |
| 177 | + canceled: false, | |
| 178 | + assets: [{ ...photo, fileSize: 42, base64: oversized }], | |
| 179 | + }); | |
| 180 | + | |
| 181 | + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ | |
| 182 | + images: [], | |
| 183 | + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", | |
| 184 | + }); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + it("does not relabel unconverted HEIC bytes as JPEG", async () => { | |
| 188 | + mocks.pickMedia.mockResolvedValue({ | |
| 189 | + canceled: false, | |
| 190 | + assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], | |
| 191 | + }); | |
| 192 | + | |
| 193 | + const result = await pickComposerImages({ existingCount: 0 }); | |
| 194 | + | |
| 195 | + expect(result.images).toEqual([]); | |
| 196 | + expect(result.error).toContain("not a supported image type"); | |
| 197 | + }); | |
| 198 | + | |
| 199 | + it("retains a converted photo when another original cannot be read", async () => { | |
| 200 | + mocks.pickMedia.mockResolvedValue({ | |
| 201 | + canceled: false, | |
| 202 | + assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], | |
| 203 | + }); | |
| 204 | + mocks.readBase64.mockRejectedValue(new Error("missing file")); | |
| 205 | + | |
| 206 | + const result = await pickComposerImages({ existingCount: 0 }); | |
| 207 | + | |
| 208 | + expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); | |
| 209 | + expect(result.error).toBe("Failed to read 'missing.gif'."); | |
| 210 | + }); | |
| 211 | + }); | |
| 212 | + | |
| 101 | 213 | describe("photo library videos", () => { |
| 102 | 214 | const image: ImagePickerAsset = { |
| 103 | 215 | uri: "file:///picker/photo.png", |
| @@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { · 350 unmodified lines | ||
| 351 | 351 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; |
| 352 | 352 | break; |
| 353 | 353 | } |
| 354 | − const mimeType = asset.mimeType?.toLowerCase(); | |
| 354 | + let mimeType = asset.mimeType?.toLowerCase(); | |
| 355 | 355 | if (asset.type === "video" || mimeType?.startsWith("video/")) { |
| 356 | 356 | if (input.maxVideoBytes === undefined) { |
| 357 | 357 | error = "Video attachments are unavailable here."; |
| @@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { · 17 unmodified lines | ||
| 375 | 375 | } |
| 376 | 376 | continue; |
| 377 | 377 | } |
| 378 | − if (!mimeType?.startsWith("image/")) { | |
| 378 | + if (asset.type !== "image" && !mimeType?.startsWith("image/")) { | |
| 379 | 379 | error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; |
| 380 | 380 | continue; |
| 381 | 381 | } |
| 382 | − if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { | |
| 383 | − error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; | |
| 384 | − continue; | |
| 385 | − } | |
| 386 | 382 | |
| 387 | − const base64 = asset.base64; | |
| 383 | + let base64 = asset.base64; | |
| 388 | 384 | if (!base64) { |
| 389 | 385 | error = `Failed to read '${asset.fileName ?? "image"}'.`; |
| 390 | 386 | continue; |
| 391 | 387 | } |
| 392 | 388 | |
| 393 | − const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); | |
| 389 | + let name = asset.fileName?.trim() || "image"; | |
| 390 | + // The iOS picker returns JPEG base64 even when its metadata describes HEIC, | |
| 391 | + // PNG, or GIF. Keep supported originals so transparency and animation survive; | |
| 392 | + // use the native JPEG conversion for formats providers cannot accept. | |
| 393 | + if (base64.startsWith("/9j/")) { | |
| 394 | + if ( | |
| 395 | + mimeType && | |
| 396 | + mimeType !== "image/jpeg" && | |
| 397 | + isProviderSendTurnSupportedImageMimeType(mimeType) | |
| 398 | + ) { | |
| 399 | + try { | |
| 400 | + const { File } = await import("expo-file-system"); | |
| 401 | + base64 = await new File(asset.uri).base64(); | |
| 402 | + } catch { | |
| 403 | + error = `Failed to read '${name}'.`; | |
| 404 | + continue; | |
| 405 | + } | |
| 406 | + } else { | |
| 407 | + mimeType = "image/jpeg"; | |
| 408 | + if (!/\.jpe?g$/i.test(name)) { | |
| 409 | + name = `${name.replace(/\.[^.]+$/, "")}.jpg`; | |
| 410 | + } | |
| 411 | + } | |
| 412 | + } | |
| 413 | + if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { | |
| 414 | + error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; | |
| 415 | + continue; | |
| 416 | + } | |
| 417 | + | |
| 418 | + const sizeBytes = estimateBase64ByteSize(base64); | |
| 394 | 419 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { |
| 395 | 420 | error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; |
| 396 | 421 | continue; |
| 397 | 422 | } |
| 398 | 423 | |
| 424 | + const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 399 | 425 | attachments.push({ |
| 400 | 426 | id: uuidv4(), |
| 401 | 427 | type: "image", |
| 402 | − name: asset.fileName ?? "image", | |
| 428 | + name, | |
| 403 | 429 | mimeType, |
| 404 | 430 | sizeBytes, |
| 405 | − dataUrl: `data:${mimeType};base64,${base64}`, | |
| 406 | − previewUri: asset.uri, | |
| 431 | + dataUrl, | |
| 432 | + previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, | |
| 407 | 433 | }); |
| 408 | 434 | } |
| 409 | 435 | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { isPdfFile } from "./filePreview"; | |
| 4 | + | |
| 5 | +describe("PDF preview detection", () => { | |
| 6 | + it.each([ | |
| 7 | + [{ name: "download", mimeType: "application/pdf" }, true], | |
| 8 | + [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], | |
| 9 | + [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], | |
| 10 | + [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], | |
| 11 | + [{ name: "report.pdf", mimeType: "text/plain" }, false], | |
| 12 | + [{ name: "report.pdf.exe" }, false], | |
| 13 | + [{ name: "https://example.com/page?download=report.pdf" }, false], | |
| 14 | + ])("classifies %j as %s", (file, expected) => { | |
| 15 | + expect(isPdfFile(file)).toBe(expected); | |
| 16 | + }); | |
| 17 | +}); | |
| @@ -0,0 +1,6 @@ | ||
| 1 | +/** MIME metadata wins; use the extension for files reported without a specific type. */ | |
| 2 | +export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean { | |
| 3 | + const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); | |
| 4 | + if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf"; | |
| 5 | + return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? ""); | |
| 6 | +} | |
| @@ -24,7 +24,7 @@ vi.mock("expo-file-system", () => ({ · 23 unmodified lines | ||
| 24 | 24 | }, |
| 25 | 25 | })); |
| 26 | 26 | |
| 27 | −import { loadLocalVideoPreview } from "./localVideoPreview"; | |
| 27 | +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | |
| 28 | 28 | |
| 29 | 29 | const attachment = { |
| 30 | 30 | type: "file" as const, |
| @@ -45,9 +45,22 @@ beforeEach(() => { · 14 unmodified lines | ||
| 45 | 45 | mocks.share.mockResolvedValue(undefined); |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | −describe("loadLocalVideoPreview", () => { | |
| 48 | +describe("loadLocalAttachmentPreview", () => { | |
| 49 | + it("retains and shares a PDF with its original filename and type", async () => { | |
| 50 | + const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; | |
| 51 | + const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); | |
| 52 | + await preview!.share(new AbortController().signal); | |
| 53 | + expect(mocks.share).toHaveBeenCalledWith( | |
| 54 | + expect.objectContaining({ | |
| 55 | + attachment: { name: "report.pdf", mimeType: "application/pdf" }, | |
| 56 | + }), | |
| 57 | + ); | |
| 58 | + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); | |
| 59 | + preview!.dispose(); | |
| 60 | + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); | |
| 61 | + }); | |
| 49 | 62 | it("resolves the current iOS container and releases its playback lease once", async () => { |
| 50 | − const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); | |
| 63 | + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); | |
| 51 | 64 | expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); |
| 52 | 65 | expect(mocks.retain).toHaveBeenCalledWith(attachment); |
| 53 | 66 | const release = mocks.retain.mock.results[0]!.value; |
| @@ -62,7 +75,7 @@ describe("loadLocalVideoPreview", () => { · 8 unmodified lines | ||
| 62 | 75 | async (sourceIdentifier) => { |
| 63 | 76 | const shared = Promise.withResolvers<void>(); |
| 64 | 77 | mocks.share.mockReturnValue(shared.promise); |
| 65 | − const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); | |
| 78 | + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); | |
| 66 | 79 | const share = preview!.share(new AbortController().signal, sourceIdentifier); |
| 67 | 80 | expect(mocks.retain).toHaveBeenCalledTimes(2); |
| 68 | 81 | const releasePlayback = mocks.retain.mock.results[0]!.value; |
| @@ -78,7 +91,7 @@ describe("loadLocalVideoPreview", () => { · 9 unmodified lines | ||
| 78 | 91 | |
| 79 | 92 | it("releases a failed share while keeping playback retained", async () => { |
| 80 | 93 | mocks.share.mockRejectedValue(new Error("Sharing unavailable")); |
| 81 | − const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); | |
| 94 | + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); | |
| 82 | 95 | await expect(preview!.share(new AbortController().signal)).rejects.toThrow( |
| 83 | 96 | "Sharing unavailable", |
| 84 | 97 | ); |
| @@ -89,7 +102,7 @@ describe("loadLocalVideoPreview", () => { · 4 unmodified lines | ||
| 89 | 102 | |
| 90 | 103 | it("releases a load canceled during native module loading", async () => { |
| 91 | 104 | const controller = new AbortController(); |
| 92 | − const loading = loadLocalVideoPreview(attachment, controller.signal); | |
| 105 | + const loading = loadLocalAttachmentPreview(attachment, controller.signal); | |
| 93 | 106 | controller.abort(); |
| 94 | 107 | await expect(loading).resolves.toBeNull(); |
| 95 | 108 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); |
| @@ -98,14 +111,14 @@ describe("loadLocalVideoPreview", () => { · 2 unmodified lines | ||
| 98 | 111 | |
| 99 | 112 | it("reports missing files and releases their lease", async () => { |
| 100 | 113 | mocks.exists.mockReturnValue(false); |
| 101 | − await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow( | |
| 102 | − "This video is no longer available. Attach the file again.", | |
| 103 | − ); | |
| 114 | + await expect( | |
| 115 | + loadLocalAttachmentPreview(attachment, new AbortController().signal), | |
| 116 | + ).rejects.toThrow("This attachment is no longer available. Attach the file again."); | |
| 104 | 117 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); |
| 105 | 118 | }); |
| 106 | 119 | |
| 107 | 120 | it("does not start sharing a disposed preview", async () => { |
| 108 | − const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); | |
| 121 | + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); | |
| 109 | 122 | preview!.dispose(); |
| 110 | 123 | await preview!.share(new AbortController().signal); |
| 111 | 124 | expect(mocks.share).not.toHaveBeenCalled(); |
| @@ -5,8 +5,8 @@ import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles · 4 unmodified lines | ||
| 5 | 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; |
| 6 | 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; |
| 7 | 7 | |
| 8 | −/** Retains the draft original for playback and gives each outgoing share its own lease. */ | |
| 9 | −export async function loadLocalVideoPreview( | |
| 8 | +/** Retains the draft original for preview and gives each outgoing share its own lease. */ | |
| 9 | +export async function loadLocalAttachmentPreview( | |
| 10 | 10 | attachment: DraftComposerFileAttachment, |
| 11 | 11 | signal: AbortSignal, |
| 12 | 12 | ): Promise<AttachmentPreviewFile | null> { |
| @@ -54,6 +54,6 @@ export async function loadLocalVideoPreview( · 41 unmodified lines | ||
| 54 | 54 | } catch (cause) { |
| 55 | 55 | release(); |
| 56 | 56 | if (signal.aborted) return null; |
| 57 | − throw new Error("This video is no longer available. Attach the file again.", { cause }); | |
| 57 | + throw new Error("This attachment is no longer available. Attach the file again.", { cause }); | |
| 58 | 58 | } |
| 59 | 59 | } |
| @@ -103,7 +103,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function · 102 unmodified lines | ||
| 103 | 103 | |
| 104 | 104 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 105 | 105 | import * as ServerConfig from "./config.ts"; |
| 106 | −import { makeRoutesLayer } from "./server.ts"; | |
| 106 | +import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; | |
| 107 | 107 | import { |
| 108 | 108 | isThreadDetailEvent, |
| 109 | 109 | resolveAvailableEditorsForConfig, |
| @@ -629,6 +629,7 @@ const buildAppUnderTest = (options?: { · 519 unmodified lines | ||
| 629 | 629 | { |
| 630 | 630 | disableListenLog: true, |
| 631 | 631 | disableLogger: true, |
| 632 | + routerConfig: HTTP_ROUTER_CONFIG, | |
| 632 | 633 | }, |
| 633 | 634 | ).pipe( |
| 634 | 635 | Layer.provide( |
| @@ -1527,6 +1528,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 892 unmodified lines | ||
| 1527 | 1528 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1528 | 1529 | ); |
| 1529 | 1530 | |
| 1531 | + it.effect("serves snapshots for MCP handoff thread IDs above the router default", () => | |
| 1532 | + Effect.gen(function* () { | |
| 1533 | + const threadId = ThreadId.make( | |
| 1534 | + "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0", | |
| 1535 | + ); | |
| 1536 | + const thread = { | |
| 1537 | + ...makeDefaultOrchestrationReadModel().threads[0]!, | |
| 1538 | + id: threadId, | |
| 1539 | + }; | |
| 1540 | + yield* buildAppUnderTest({ | |
| 1541 | + layers: { | |
| 1542 | + projectionSnapshotQuery: { | |
| 1543 | + getThreadDetailSnapshot: (requestedThreadId) => | |
| 1544 | + Effect.succeed( | |
| 1545 | + requestedThreadId === threadId | |
| 1546 | + ? Option.some({ snapshotSequence: 1, thread }) | |
| 1547 | + : Option.none(), | |
| 1548 | + ), | |
| 1549 | + }, | |
| 1550 | + }, | |
| 1551 | + }); | |
| 1552 | + | |
| 1553 | + const response = yield* fetchEffect( | |
| 1554 | + yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`), | |
| 1555 | + { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } }, | |
| 1556 | + ); | |
| 1557 | + const snapshot = yield* responseJsonEffect<{ | |
| 1558 | + readonly thread: { readonly id: ThreadId }; | |
| 1559 | + }>(response); | |
| 1560 | + | |
| 1561 | + assert.equal(response.status, 200); | |
| 1562 | + assert.equal(snapshot.thread.id, threadId); | |
| 1563 | + }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1564 | + ); | |
| 1565 | + | |
| 1530 | 1566 | it.effect("compresses large JSON responses through the composed routes", () => |
| 1531 | 1567 | Effect.gen(function* () { |
| 1532 | 1568 | const descriptor = { |
| @@ -123,6 +123,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; · 122 unmodified lines | ||
| 123 | 123 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 124 | 124 | import { forkParked, ServerActivation } from "./serverActivation.ts"; |
| 125 | 125 | |
| 126 | +// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's | |
| 127 | +// 100-character default for one path segment. | |
| 128 | +export const HTTP_ROUTER_CONFIG = { | |
| 129 | + maxParamLength: 512, | |
| 130 | +} as const; | |
| 131 | + | |
| 126 | 132 | // Effect's default preemptive shutdown waits 20s before finalizing request scopes. |
| 127 | 133 | // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer |
| 128 | 134 | // already closes the websocket gracefully. Do not add an artificial drain before |
| @@ -675,6 +681,7 @@ export const makeServerLayer = Layer.unwrap( · 546 unmodified lines | ||
| 675 | 681 | |
| 676 | 682 | const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { |
| 677 | 683 | disableLogger: !config.logWebSocketEvents, |
| 684 | + routerConfig: HTTP_ROUTER_CONFIG, | |
| 678 | 685 | }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); |
| 679 | 686 | const serverApplicationLayer = Layer.mergeAll( |
| 680 | 687 | routesLayer, |
| @@ -193,6 +193,20 @@ describe("pruneScanCache with an unwalked root", () => { · 192 unmodified lines | ||
| 193 | 193 | expect(removed).toBe(0); |
| 194 | 194 | expect(cache.size).toBe(1); |
| 195 | 195 | }); |
| 196 | + | |
| 197 | + it("keeps entries under a sibling path that only shares the walked root prefix", () => { | |
| 198 | + const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); | |
| 199 | + | |
| 200 | + const removed = pruneScanCache(cache, { | |
| 201 | + livePaths: new Set(), | |
| 202 | + walkedRoots: ["/claude/projects"], | |
| 203 | + windowStartMs: 4000, | |
| 204 | + retentionCutoffMs: 1000, | |
| 205 | + }); | |
| 206 | + | |
| 207 | + expect(removed).toBe(0); | |
| 208 | + expect(cache.size).toBe(1); | |
| 209 | + }); | |
| 196 | 210 | }); |
| 197 | 211 | |
| 198 | 212 | describe("dedupeWithinFile", () => { |
| @@ -14,6 +14,9 @@ · 13 unmodified lines | ||
| 14 | 14 | * |
| 15 | 15 | * @module usageScanCache |
| 16 | 16 | */ |
| 17 | +// @effect-diagnostics nodeBuiltinImport:off | |
| 18 | +import * as NodePath from "node:path"; | |
| 19 | + | |
| 17 | 20 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 18 | 21 | |
| 19 | 22 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| @@ -229,7 +232,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number · 209 unmodified lines | ||
| 229 | 232 | let removed = 0; |
| 230 | 233 | for (const [path, entry] of cache) { |
| 231 | 234 | const agedOut = entry.mtimeMs < options.retentionCutoffMs; |
| 232 | − const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); | |
| 235 | + const underWalkedRoot = options.walkedRoots.some((root) => { | |
| 236 | + const relative = NodePath.relative(root, path); | |
| 237 | + return ( | |
| 238 | + relative === "" || | |
| 239 | + (relative !== ".." && | |
| 240 | + !relative.startsWith(`..${NodePath.sep}`) && | |
| 241 | + !NodePath.isAbsolute(relative)) | |
| 242 | + ); | |
| 243 | + }); | |
| 233 | 244 | const deleted = |
| 234 | 245 | underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); |
| 235 | 246 | if (agedOut || deleted) { |
| @@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 264 unmodified lines | ||
| 265 | 265 | let needed = 0; |
| 266 | 266 | let groups = 0; |
| 267 | 267 | for (const child of current.children) { |
| 268 | − if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; | |
| 269 | − needed += contentWidth(child); | |
| 268 | + if (!(child instanceof HTMLElement)) continue; | |
| 269 | + const width = contentWidth(child); | |
| 270 | + if (width <= 1) continue; | |
| 271 | + needed += width; | |
| 270 | 272 | groups += 1; |
| 271 | 273 | } |
| 272 | 274 | needed += stripGap * Math.max(0, groups - 1); |
| @@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 83 unmodified lines | ||
| 356 | 358 | // Label widths can change without the strip box moving (font family or |
| 357 | 359 | // size preferences), so re-measure on every render as well as on resize |
| 358 | 360 | // and font loads. |
| 359 | − useEffect(() => { | |
| 361 | + useLayoutEffect(() => { | |
| 360 | 362 | measure(); |
| 361 | 363 | }); |
| 362 | 364 | |
| @@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 124 unmodified lines | ||
| 487 | 489 | onUsePreviousWorktree={onUsePreviousWorktree} |
| 488 | 490 | /> |
| 489 | 491 | ) : ( |
| 490 | − <div className="flex min-w-0 flex-1 items-center gap-1"> | |
| 492 | + <div className="flex min-w-10 flex-1 items-center gap-1"> | |
| 491 | 493 | {showEnvironmentIndicator && availableEnvironments && ( |
| 492 | 494 | <> |
| 493 | 495 | <BranchToolbarEnvironmentSelector |
| @@ -520,7 +522,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 26 unmodified lines | ||
| 520 | 522 | |
| 521 | 523 | {showGitControls ? ( |
| 522 | 524 | <BranchToolbarBranchSelector |
| 523 | − className="min-w-0 flex-1 justify-end md:ml-auto md:flex-none" | |
| 525 | + className="min-w-0 flex-1 justify-end md:ml-auto md:flex-initial" | |
| 524 | 526 | environmentId={environmentId} |
| 525 | 527 | threadId={threadId} |
| 526 | 528 | {...(draftId ? { draftId } : {})} |
| @@ -51,20 +51,25 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe · 50 unmodified lines | ||
| 51 | 51 | if (envLocked) { |
| 52 | 52 | return ( |
| 53 | 53 | <span |
| 54 | − className="inline-flex h-7 shrink-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs" | |
| 54 | + className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs" | |
| 55 | 55 | data-composer-context-control |
| 56 | 56 | > |
| 57 | 57 | {activeWorktreePath ? ( |
| 58 | − <> | |
| 59 | − <FolderGitIcon className="size-3" /> | |
| 60 | − {resolveLockedWorkspaceLabel(activeWorktreePath)} | |
| 61 | − </> | |
| 58 | + <FolderGitIcon className="size-3 shrink-0" /> | |
| 62 | 59 | ) : ( |
| 63 | − <> | |
| 64 | − <FolderIcon className="size-3" /> | |
| 65 | − {resolveLockedWorkspaceLabel(activeWorktreePath)} | |
| 66 | − </> | |
| 60 | + <FolderIcon className="size-3 shrink-0" /> | |
| 67 | 61 | )} |
| 62 | + <span | |
| 63 | + data-composer-label | |
| 64 | + className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" | |
| 65 | + > | |
| 66 | + <span | |
| 67 | + data-composer-label-motion | |
| 68 | + className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" | |
| 69 | + > | |
| 70 | + {resolveLockedWorkspaceLabel(activeWorktreePath)} | |
| 71 | + </span> | |
| 72 | + </span> | |
| 68 | 73 | </span> |
| 69 | 74 | ); |
| 70 | 75 | } |
| @@ -288,13 +288,16 @@ export const ChatHeader = memo(function ChatHeader({ · 287 unmodified lines | ||
| 288 | 288 | className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" |
| 289 | 289 | onContextMenu={handleHeaderContextMenu} |
| 290 | 290 | > |
| 291 | − <WorkspaceBreadcrumb ariaLabel="Thread breadcrumb" className="flex-1"> | |
| 291 | + <WorkspaceBreadcrumb | |
| 292 | + ariaLabel="Thread breadcrumb" | |
| 293 | + className="flex-1 overflow-clip [overflow-clip-margin:2px]" | |
| 294 | + > | |
| 292 | 295 | {/* The project always leads the header: knowing which project a |
| 293 | 296 | thread lives in is priority zero, and the thread title alone |
| 294 | 297 | doesn't answer it. */} |
| 295 | 298 | {activeProjectName ? ( |
| 296 | 299 | <> |
| 297 | − <WorkspaceBreadcrumbItem> | |
| 300 | + <WorkspaceBreadcrumbItem className="shrink"> | |
| 298 | 301 | <Tooltip> |
| 299 | 302 | <TooltipTrigger |
| 300 | 303 | render={ |
| @@ -302,7 +305,7 @@ export const ChatHeader = memo(function ChatHeader({ · 1 unmodified line | ||
| 302 | 305 | type="button" |
| 303 | 306 | aria-label={`New thread in ${activeProjectName}`} |
| 304 | 307 | onClick={onNewThreadInProject} |
| 305 | − className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" | |
| 308 | + className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" | |
| 306 | 309 | /> |
| 307 | 310 | } |
| 308 | 311 | > |
| @@ -320,7 +323,7 @@ export const ChatHeader = memo(function ChatHeader({ · 11 unmodified lines | ||
| 320 | 323 | <WorkspaceBreadcrumbSeparator /> |
| 321 | 324 | </> |
| 322 | 325 | ) : null} |
| 323 | − <WorkspaceBreadcrumbItem current className="flex-1"> | |
| 326 | + <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> | |
| 324 | 327 | {renamingTitle !== null ? ( |
| 325 | 328 | <input |
| 326 | 329 | autoFocus |
| @@ -3,9 +3,9 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, |
| 4 | 4 | } from "@pierre/trees"; |
| 5 | 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 6 | −import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; | |
| 6 | +import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; | |
| 7 | 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 8 | −import { RotateCw } from "lucide-react"; | |
| 8 | +import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; | |
| 9 | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 10 | 10 | |
| 11 | 11 | import { Button } from "~/components/ui/button"; |
| @@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; · 9 unmodified lines | ||
| 21 | 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 22 | 22 | |
| 23 | 23 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; |
| 24 | +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | |
| 24 | 25 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| 25 | 26 | |
| 26 | 27 | interface FileBrowserPanelProps { |
| @@ -121,6 +122,10 @@ export default function FileBrowserPanel({ · 94 unmodified lines | ||
| 121 | 122 | ); |
| 122 | 123 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); |
| 123 | 124 | const treePaths = useMemo(() => entries.map(treePath), [entries]); |
| 125 | + const directoryPaths = useMemo( | |
| 126 | + () => entries.filter((entry) => entry.kind === "directory").map(treePath), | |
| 127 | + [entries], | |
| 128 | + ); | |
| 124 | 129 | const previousTreePathsRef = useRef<readonly string[]>([]); |
| 125 | 130 | const syncingSelectionRef = useRef(false); |
| 126 | 131 | const treeSelectionPathRef = useRef<string | null>(null); |
| @@ -252,6 +257,12 @@ export default function FileBrowserPanel({ · 125 unmodified lines | ||
| 252 | 257 | unsafeCSS: TREE_UNSAFE_CSS, |
| 253 | 258 | }); |
| 254 | 259 | const search = useFileTreeSearch(model); |
| 260 | + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => | |
| 261 | + areAllDirectoriesExpanded(currentModel, directoryPaths), | |
| 262 | + ); | |
| 263 | + const toggleAllDirectories = () => { | |
| 264 | + setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); | |
| 265 | + }; | |
| 255 | 266 | const handleSearchValueChange = (value: string) => { |
| 256 | 267 | if (value.trim().length === 0) { |
| 257 | 268 | search.close(); |
| @@ -376,6 +387,32 @@ export default function FileBrowserPanel({ · 118 unmodified lines | ||
| 376 | 387 | onValueChange={handleSearchValueChange} |
| 377 | 388 | onClose={search.close} |
| 378 | 389 | /> |
| 390 | + {directoryPaths.length > 0 ? ( | |
| 391 | + <Tooltip> | |
| 392 | + <TooltipTrigger | |
| 393 | + render={ | |
| 394 | + <Button | |
| 395 | + type="button" | |
| 396 | + size="icon-xs" | |
| 397 | + variant="ghost" | |
| 398 | + aria-label={ | |
| 399 | + allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | |
| 400 | + } | |
| 401 | + onClick={toggleAllDirectories} | |
| 402 | + /> | |
| 403 | + } | |
| 404 | + > | |
| 405 | + {allDirectoriesExpanded ? ( | |
| 406 | + <ChevronsDownUpIcon className="size-3.5" /> | |
| 407 | + ) : ( | |
| 408 | + <ChevronsUpDownIcon className="size-3.5" /> | |
| 409 | + )} | |
| 410 | + </TooltipTrigger> | |
| 411 | + <TooltipPopup> | |
| 412 | + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | |
| 413 | + </TooltipPopup> | |
| 414 | + </Tooltip> | |
| 415 | + ) : null} | |
| 379 | 416 | </div> |
| 380 | 417 | {entriesQuery.error && entriesQuery.data === null ? ( |
| 381 | 418 | <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div> |
| @@ -0,0 +1,60 @@ | ||
| 1 | +import { describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | |
| 4 | + | |
| 5 | +type FakeDirectoryItem = { | |
| 6 | + isDirectory: () => true; | |
| 7 | + isExpanded: () => boolean; | |
| 8 | + expand: () => void; | |
| 9 | + collapse: () => void; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +function makeModel(expanded: Record<string, boolean>) { | |
| 13 | + const items = new Map<string, FakeDirectoryItem>(); | |
| 14 | + return { | |
| 15 | + getItem: (path: string) => { | |
| 16 | + const existing = items.get(path); | |
| 17 | + if (existing !== undefined) return existing; | |
| 18 | + const item: FakeDirectoryItem = { | |
| 19 | + isDirectory: () => true, | |
| 20 | + isExpanded: () => expanded[path] ?? false, | |
| 21 | + expand: () => { | |
| 22 | + expanded[path] = true; | |
| 23 | + }, | |
| 24 | + collapse: () => { | |
| 25 | + expanded[path] = false; | |
| 26 | + }, | |
| 27 | + }; | |
| 28 | + items.set(path, item); | |
| 29 | + return item; | |
| 30 | + }, | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +describe("file tree expansion", () => { | |
| 35 | + it("requires at least one directory and detects whether all are expanded", () => { | |
| 36 | + const model = makeModel({ "src/": true, "test/": true }); | |
| 37 | + expect(areAllDirectoriesExpanded(model, [])).toBe(false); | |
| 38 | + expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); | |
| 39 | + expect( | |
| 40 | + areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), | |
| 41 | + ).toBe(false); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("expands and collapses every directory", () => { | |
| 45 | + const expanded = { "src/": true, "test/": false }; | |
| 46 | + const model = makeModel(expanded); | |
| 47 | + setAllDirectoriesExpanded(model, ["src/", "test/"], true); | |
| 48 | + expect(expanded).toEqual({ "src/": true, "test/": true }); | |
| 49 | + setAllDirectoriesExpanded(model, ["src/", "test/"], false); | |
| 50 | + expect(expanded).toEqual({ "src/": false, "test/": false }); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it("skips directories already at the requested state", () => { | |
| 54 | + const model = makeModel({ "src/": true }); | |
| 55 | + const item = model.getItem("src/"); | |
| 56 | + const collapse = vi.spyOn(item, "collapse"); | |
| 57 | + setAllDirectoriesExpanded(model, ["src/"], true); | |
| 58 | + expect(collapse).not.toHaveBeenCalled(); | |
| 59 | + }); | |
| 60 | +}); | |
| @@ -0,0 +1,55 @@ | ||
| 1 | +export interface FileTreeExpansionModel { | |
| 2 | + getItem(path: string): unknown; | |
| 3 | +} | |
| 4 | + | |
| 5 | +type DirectoryHandle = { | |
| 6 | + isDirectory(): boolean; | |
| 7 | + isExpanded(): boolean; | |
| 8 | + expand(): void; | |
| 9 | + collapse(): void; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +function asDirectoryHandle(item: unknown): DirectoryHandle | null { | |
| 13 | + if ( | |
| 14 | + typeof item !== "object" || | |
| 15 | + item === null || | |
| 16 | + !("isDirectory" in item) || | |
| 17 | + typeof item.isDirectory !== "function" || | |
| 18 | + !item.isDirectory() || | |
| 19 | + !("isExpanded" in item) || | |
| 20 | + typeof item.isExpanded !== "function" || | |
| 21 | + !("expand" in item) || | |
| 22 | + typeof item.expand !== "function" || | |
| 23 | + !("collapse" in item) || | |
| 24 | + typeof item.collapse !== "function" | |
| 25 | + ) { | |
| 26 | + return null; | |
| 27 | + } | |
| 28 | + return item as DirectoryHandle; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function areAllDirectoriesExpanded( | |
| 32 | + model: FileTreeExpansionModel, | |
| 33 | + directoryPaths: readonly string[], | |
| 34 | +): boolean { | |
| 35 | + return ( | |
| 36 | + directoryPaths.length > 0 && | |
| 37 | + directoryPaths.every((path) => { | |
| 38 | + const item = asDirectoryHandle(model.getItem(path)); | |
| 39 | + return item !== null && item.isExpanded(); | |
| 40 | + }) | |
| 41 | + ); | |
| 42 | +} | |
| 43 | + | |
| 44 | +export function setAllDirectoriesExpanded( | |
| 45 | + model: FileTreeExpansionModel, | |
| 46 | + directoryPaths: readonly string[], | |
| 47 | + expanded: boolean, | |
| 48 | +): void { | |
| 49 | + for (const path of directoryPaths) { | |
| 50 | + const item = asDirectoryHandle(model.getItem(path)); | |
| 51 | + if (item === null || item.isExpanded() === expanded) continue; | |
| 52 | + if (expanded) item.expand(); | |
| 53 | + else item.collapse(); | |
| 54 | + } | |
| 55 | +} | |
| @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | applyWslEnableSelection, |
| 5 | 5 | isQrShareableEndpoint, |
| 6 | + isWslSettingsRowVisible, | |
| 6 | 7 | selectQrEndpointOption, |
| 7 | 8 | } from "./ConnectionsSettings.logic"; |
| 8 | 9 | |
| @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { · 6 unmodified lines | ||
| 15 | 16 | preflightError: null, |
| 16 | 17 | }; |
| 17 | 18 | |
| 19 | +describe("isWslSettingsRowVisible", () => { | |
| 20 | + it("shows the retry row when the WSL state failed to load", () => { | |
| 21 | + expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("hides an unavailable and unused WSL snapshot", () => { | |
| 25 | + expect( | |
| 26 | + isWslSettingsRowVisible({ | |
| 27 | + state: { ...baseWslState, available: false, wslOnly: false }, | |
| 28 | + error: null, | |
| 29 | + }), | |
| 30 | + ).toBe(false); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("shows an available WSL snapshot", () => { | |
| 34 | + expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); | |
| 35 | + }); | |
| 36 | +}); | |
| 37 | + | |
| 18 | 38 | describe("applyWslEnableSelection", () => { |
| 19 | 39 | it("clears WSL-only and updates the distro before enabling both backends", async () => { |
| 20 | 40 | const calls: Array<string> = []; |
| @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { · 10 unmodified lines | ||
| 11 | 11 | return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | +export function isWslSettingsRowVisible(input: { | |
| 15 | + readonly state: DesktopWslState | null; | |
| 16 | + readonly error: string | null; | |
| 17 | +}): boolean { | |
| 18 | + const { state, error } = input; | |
| 19 | + return state ? state.available || state.enabled || state.wslOnly : error !== null; | |
| 20 | +} | |
| 21 | + | |
| 14 | 22 | export type QrEndpointOption = { |
| 15 | 23 | /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ |
| 16 | 24 | readonly id: string; |
| @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls · 42 unmodified lines | ||
| 43 | 43 | import { |
| 44 | 44 | applyWslEnableSelection, |
| 45 | 45 | isQrShareableEndpoint, |
| 46 | + isWslSettingsRowVisible, | |
| 46 | 47 | selectQrEndpointOption, |
| 47 | 48 | } from "./ConnectionsSettings.logic"; |
| 48 | 49 | import { |
| @@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { · 2717 unmodified lines | ||
| 2766 | 2767 | // retry so the row doesn't flicker away, and the button reflects the |
| 2767 | 2768 | // loading state. With no error we simply haven't loaded yet (or WSL |
| 2768 | 2769 | // management isn't available), so render nothing. |
| 2769 | − if (desktopWslError && canManageLocalBackend) { | |
| 2770 | + if ( | |
| 2771 | + isWslSettingsRowVisible({ state: null, error: desktopWslError }) && | |
| 2772 | + canManageLocalBackend | |
| 2773 | + ) { | |
| 2770 | 2774 | return ( |
| 2771 | 2775 | <SettingsRow |
| 2772 | − title="WSL backend" | |
| 2776 | + {...searchableSetting("wsl-backend")} | |
| 2773 | 2777 | description="Couldn't load the WSL backend state." |
| 2774 | 2778 | status={<span className="block text-destructive">{desktopWslError}</span>} |
| 2775 | 2779 | control={ |
| @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { · 18 unmodified lines | ||
| 2794 | 2798 | // be stranded on a WSL preference they can't clear, so render a recovery |
| 2795 | 2799 | // row that switches back to Windows. When WSL is unavailable AND unused, |
| 2796 | 2800 | // there's nothing to recover — keep the section hidden as before. |
| 2801 | + if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { | |
| 2802 | + return null; | |
| 2803 | + } | |
| 2797 | 2804 | if (!desktopWslState.available) { |
| 2798 | − if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; | |
| 2799 | 2805 | return ( |
| 2800 | 2806 | <SettingsRow |
| 2801 | − title="WSL backend" | |
| 2807 | + {...searchableSetting("wsl-backend")} | |
| 2802 | 2808 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." |
| 2803 | 2809 | status={ |
| 2804 | 2810 | desktopWslError ? ( |
| @@ -2835,7 +2841,7 @@ export function ConnectionsSettings() { · 30 unmodified lines | ||
| 2835 | 2841 | return ( |
| 2836 | 2842 | <> |
| 2837 | 2843 | <SettingsRow |
| 2838 | − title="WSL backend" | |
| 2844 | + {...searchableSetting("wsl-backend")} | |
| 2839 | 2845 | description="Run a second backend inside a WSL distro alongside the Windows one. Pick a distro to start it; pick Off to stop it. Projects opened against the WSL backend live on the Linux side; Windows projects stay where they are." |
| 2840 | 2846 | status={ |
| 2841 | 2847 | desktopWslError ? ( |
| @@ -7,6 +7,8 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type ComponentType, |
| 8 | 8 | type KeyboardEvent, |
| 9 | 9 | } from "react"; |
| 10 | +import { useEnvironmentQuery } from "~/state/query"; | |
| 11 | +import { desktopWslStateAtom } from "~/state/desktopWslState"; | |
| 10 | 12 | import { |
| 11 | 13 | ArchiveIcon, |
| 12 | 14 | BlocksIcon, |
| @@ -21,6 +23,8 @@ import { · 8 unmodified lines | ||
| 21 | 23 | } from "lucide-react"; |
| 22 | 24 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 23 | 25 | |
| 26 | +import { isElectron } from "~/env"; | |
| 27 | +import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | |
| 24 | 28 | import { Button } from "../ui/button"; |
| 25 | 29 | import { Input } from "../ui/input"; |
| 26 | 30 | import { Kbd } from "../ui/kbd"; |
| @@ -38,6 +42,7 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines | ||
| 38 | 42 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 39 | 43 | import { |
| 40 | 44 | searchSettings, |
| 45 | + SETTINGS_SEARCH_ITEMS, | |
| 41 | 46 | SETTINGS_SECTION_LABELS, |
| 42 | 47 | type SettingsPath, |
| 43 | 48 | type SettingsSearchItem, |
| @@ -78,10 +83,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 34 unmodified lines | ||
| 78 | 83 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 79 | 84 | const [query, setQuery] = useState(""); |
| 80 | 85 | const [activeResultIndex, setActiveResultIndex] = useState(0); |
| 81 | − const results = useMemo(() => searchSettings(query), [query]); | |
| 86 | + const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); | |
| 87 | + const searchableItems = useMemo(() => { | |
| 88 | + const wslState = desktopWsl.data; | |
| 89 | + const rowRenders = isWslSettingsRowVisible({ | |
| 90 | + state: wslState, | |
| 91 | + error: desktopWsl.error, | |
| 92 | + }); | |
| 93 | + if (rowRenders) { | |
| 94 | + return SETTINGS_SEARCH_ITEMS; | |
| 95 | + } | |
| 96 | + return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); | |
| 97 | + }, [desktopWsl.data, desktopWsl.error]); | |
| 98 | + const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); | |
| 82 | 99 | const isSearching = query.trim().length > 0; |
| 83 | 100 | const hasResults = results.length > 0; |
| 84 | 101 | |
| 102 | + useEffect(() => { | |
| 103 | + setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); | |
| 104 | + }, [results.length]); | |
| 105 | + | |
| 85 | 106 | useEffect(() => { |
| 86 | 107 | const result = results[activeResultIndex]; |
| 87 | 108 | if (!result) return; |
| @@ -71,6 +71,17 @@ describe("searchSettings", () => { · 70 unmodified lines | ||
| 71 | 71 | it("hides desktop-only settings from browser search", () => { |
| 72 | 72 | expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); |
| 73 | 73 | expect(searchSettings("quit confirmation")).toEqual([]); |
| 74 | + expect(searchSettings("wsl")).toEqual([]); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + it("registers the WSL backend as a desktop-only setting", () => { | |
| 78 | + expect(SETTINGS_SEARCH_ITEMS).toContainEqual({ | |
| 79 | + id: "wsl-backend", | |
| 80 | + title: "WSL backend", | |
| 81 | + to: "/settings/connections", | |
| 82 | + desktopOnly: true, | |
| 83 | + windowsOnly: true, | |
| 84 | + }); | |
| 74 | 85 | }); |
| 75 | 86 | |
| 76 | 87 | it("keeps catalog result ids unique", () => { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { isElectron } from "~/env"; |
| 2 | +import { isWindowsPlatform } from "~/lib/utils"; | |
| 2 | 3 | |
| 3 | 4 | export type SettingsPath = |
| 4 | 5 | | "/settings/general" |
| @@ -18,6 +19,9 @@ export interface SettingsSearchItem { · 13 unmodified lines | ||
| 18 | 19 | // Its row only renders in the desktop app, so a browser result would land on |
| 19 | 20 | // an anchor that isn't there. |
| 20 | 21 | readonly desktopOnly?: boolean; |
| 22 | + // Its row only renders on Windows desktop, so other desktop platforms must | |
| 23 | + // not expose a result that points to a missing anchor. | |
| 24 | + readonly windowsOnly?: boolean; | |
| 21 | 25 | } |
| 22 | 26 | |
| 23 | 27 | /** |
| @@ -264,6 +268,13 @@ export const SETTINGS_SEARCH_ITEMS = [ · 240 unmodified lines | ||
| 264 | 268 | title: "Remote environments", |
| 265 | 269 | to: "/settings/connections", |
| 266 | 270 | }, |
| 271 | + { | |
| 272 | + id: "wsl-backend", | |
| 273 | + title: "WSL backend", | |
| 274 | + to: "/settings/connections", | |
| 275 | + desktopOnly: true, | |
| 276 | + windowsOnly: true, | |
| 277 | + }, | |
| 267 | 278 | { |
| 268 | 279 | id: "archive", |
| 269 | 280 | title: "Archived threads", |
| @@ -309,6 +320,8 @@ export function searchSettings( · 39 unmodified lines | ||
| 309 | 320 | return items.filter( |
| 310 | 321 | (item) => |
| 311 | 322 | (isElectron || item.desktopOnly !== true) && |
| 323 | + (!item.windowsOnly || | |
| 324 | + isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) && | |
| 312 | 325 | normalizeSearchText(item.title).includes(normalizedQuery), |
| 313 | 326 | ); |
| 314 | 327 | } |
| @@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround. · 50 unmodified lines | ||
| 51 | 51 | |
| 52 | 52 | ## Native media presentations |
| 53 | 53 | |
| 54 | −`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's | |
| 55 | −full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child | |
| 54 | +`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, | |
| 55 | +image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child | |
| 56 | 56 | and pass the stable identifier to the presentation. The registry keeps weak |
| 57 | 57 | references to source views; recycled or compact composer thumbnails can register |
| 58 | 58 | the same identifier. Identifiers must distinguish simultaneously visible attachments. |
| 59 | −This source registration does not own playback and can be reused by a future image | |
| 60 | −viewer. Android uses a regular view. | |
| 59 | +The source registration does not own the preview. Android uses a regular view. | |
| 61 | 60 | |
| 62 | 61 | On iOS, video previews mount `AVPlayerViewController` temporarily inside the |
| 63 | 62 | registered source and enter full screen through AVKit. AVKit |
| @@ -67,7 +66,25 @@ simulator, that leaves native Close unable to exit full screen. When the source · 3 unmodified lines | ||
| 67 | 66 | the player uses a standard modal presentation. Programmatic entry uses the same |
| 68 | 67 | guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; |
| 69 | 68 | if that selector is unavailable, the player also falls back to a standard modal. |
| 70 | −Images retain their existing viewer. | |
| 69 | + | |
| 70 | +`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, | |
| 71 | +or a retained composer file. On iOS, Quick Look owns image and document layout, controls, | |
| 72 | +zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail | |
| 73 | +and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or | |
| 74 | +another image scroll view over that presentation: Quick Look coordinates its image gestures | |
| 75 | +with the return to the thumbnail. Missing sources use the standard transition, and Reduce | |
| 76 | +Motion disables animation. A pending programmatic Close waits until the current presentation | |
| 77 | +or cancelled dismissal has settled before starting another transition. | |
| 78 | + | |
| 79 | +The shared native presenter copies original bytes into its own temporary directory and | |
| 80 | +removes that copy after dismissal. Network downloads write to disk, and sharing never edits | |
| 81 | +the source attachment. Draft images use their stored upload data rather than a potentially | |
| 82 | +expired picker URI. No React Navigation route or custom transition animator is needed. | |
| 83 | + | |
| 84 | +The same viewer handles message images, markdown images, PDF attachments and links, | |
| 85 | +composer thumbnails, and workspace image previews. The workspace PDF web preview has an | |
| 86 | +Open PDF action for the native viewer. Android retains its image viewer and uses the | |
| 87 | +system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. | |
| 71 | 88 | |
| 72 | 89 | Received videos open directly from their signed asset URL. AVKit handles buffering; |
| 73 | 90 | the client does not download the entire file or show a separate opening overlay before |
| @@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and ** · 19 unmodified lines | ||
| 20 | 20 | Videos use the server's file upload limit. You can also share photos, videos, and files into |
| 21 | 21 | T3 Code from other apps through the system share sheet. Mobile uploads happen when the message |
| 22 | 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile |
| 23 | −to save it or open it in another app through the system share sheet. | |
| 23 | +to preview it or open the system share options. | |
| 24 | + | |
| 25 | +Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail | |
| 26 | +into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. | |
| 27 | +Use Share to save a copy or send it to another app. PDFs support page navigation and search. | |
| 28 | +PDF links in assistant responses open the same preview. On Android, images open in the image | |
| 29 | +viewer and PDFs open the system chooser. | |
| 24 | 30 | |
| 25 | 31 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 26 | 32 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. |
| @@ -36,7 +42,8 @@ On web and desktop, if you reload before a file finishes uploading, the draft ke · 9 unmodified lines | ||
| 36 | 42 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
| 37 | 43 | |
| 38 | 44 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into |
| 39 | −the composer or paste them into a message. | |
| 45 | +the composer or paste them into a message. On iOS, selecting them from **Photo Library** also | |
| 46 | +converts them to JPEG. The 10 MB image limit applies to the converted photo. | |
| 40 | 47 | |
| 41 | 48 | On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, |
| 42 | 49 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| @@ -460,7 +460,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { · 459 unmodified lines | ||
| 460 | 460 | }); |
| 461 | 461 | |
| 462 | 462 | effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { |
| 463 | − it.effect("returns the baseline no-profile PATH patch when node is already available", () => | |
| 463 | + it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () => | |
| 464 | 464 | Effect.gen(function* () { |
| 465 | 465 | const readEnvironment = vi.fn( |
| 466 | 466 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => |
| @@ -483,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 16 unmodified lines | ||
| 483 | 483 | ), |
| 484 | 484 | ).toEqual({ |
| 485 | 485 | PATH: [ |
| 486 | + "C:\\Shell\\Bin", | |
| 487 | + "C:\\Windows\\System32", | |
| 486 | 488 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 487 | 489 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 488 | 490 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| @@ -490,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line | ||
| 490 | 492 | "C:\\Users\\testuser\\.local\\bin", |
| 491 | 493 | "C:\\Users\\testuser\\.bun\\bin", |
| 492 | 494 | "C:\\Users\\testuser\\scoop\\shims", |
| 493 | − "C:\\Shell\\Bin", | |
| 494 | − "C:\\Windows\\System32", | |
| 495 | 495 | ].join(";"), |
| 496 | 496 | }); |
| 497 | 497 | expect(readEnvironment).toHaveBeenCalledTimes(1); |
| @@ -532,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 34 unmodified lines | ||
| 532 | 532 | PATH: [ |
| 533 | 533 | "C:\\Profile\\Node", |
| 534 | 534 | "C:\\Windows\\System32", |
| 535 | + "C:\\Shell\\Bin", | |
| 535 | 536 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 536 | 537 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 537 | 538 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| @@ -539,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line | ||
| 539 | 540 | "C:\\Users\\testuser\\.local\\bin", |
| 540 | 541 | "C:\\Users\\testuser\\.bun\\bin", |
| 541 | 542 | "C:\\Users\\testuser\\scoop\\shims", |
| 542 | − "C:\\Shell\\Bin", | |
| 543 | 543 | ].join(";"), |
| 544 | 544 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 545 | 545 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", |
| @@ -576,11 +576,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 30 unmodified lines | ||
| 576 | 576 | ), |
| 577 | 577 | ).toEqual({ |
| 578 | 578 | PATH: [ |
| 579 | + "C:\\Windows\\System32", | |
| 579 | 580 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 580 | 581 | "C:\\Users\\testuser\\.local\\bin", |
| 581 | 582 | "C:\\Users\\testuser\\.bun\\bin", |
| 582 | 583 | "C:\\Users\\testuser\\scoop\\shims", |
| 583 | − "C:\\Windows\\System32", | |
| 584 | 584 | ].join(";"), |
| 585 | 585 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 586 | 586 | }); |
| @@ -728,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm · 727 unmodified lines | ||
| 728 | 728 | }).PATH; |
| 729 | 729 | const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); |
| 730 | 730 | const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); |
| 731 | − const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); | |
| 731 | + // Preserve the order a user's shell uses. These directories fill gaps when | |
| 732 | + // desktop apps launch without the full interactive-shell PATH. | |
| 733 | + const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); | |
| 732 | 734 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; |
| 733 | 735 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); |
| 734 | 736 | |