Unified · Split
| @@ -1,2 +1,4 @@ | |||
| 1 | reviews: | 1 | reviews: |
| 2 | review_status: false | 2 | review_status: false |
| 3 | auto_review: | ||
| 4 | enabled: false | ||
| @@ -206,6 +206,7 @@ const config: ExpoConfig = { · 205 unmodified lines | |||
| 206 | }, | 206 | }, |
| 207 | NSLocalNetworkUsageDescription: | 207 | NSLocalNetworkUsageDescription: |
| 208 | "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", | 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 | ITSAppUsesNonExemptEncryption: false, | 210 | ITSAppUsesNonExemptEncryption: false, |
| 210 | // The App Store screenshot harness rotates the iPad interface from | 211 | // The App Store screenshot harness rotates the iPad interface from |
| 211 | // inside the app (CI denies osascript the Accessibility access that | 212 | // inside the app (CI denies osascript the Accessibility access that |
| @@ -5,6 +5,7 @@ import UIKit · 4 unmodified lines | |||
| 5 | public final class T3NativeControlsModule: Module { | 5 | public final class T3NativeControlsModule: Module { |
| 6 | private let presentationSources = T3PresentationSources() | 6 | private let presentationSources = T3PresentationSources() |
| 7 | private var videoPresentation: T3NativeVideoPresentation? | 7 | private var videoPresentation: T3NativeVideoPresentation? |
| 8 | private var filePresentation: T3NativeFilePresentation? | ||
| 8 | 9 | ||
| 9 | public func definition() -> ModuleDefinition { | 10 | public func definition() -> ModuleDefinition { |
| 10 | Name("T3NativeControls") | 11 | Name("T3NativeControls") |
| @@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { · 12 unmodified lines | |||
| 23 | self.dismissVideo(identifier: identifier) | 24 | self.dismissVideo(identifier: identifier) |
| 24 | }.runOnQueue(.main) | 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 | OnDestroy { | 36 | OnDestroy { |
| 27 | let presentation = self.videoPresentation | 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 | View(T3PresentationSourceView.self) { | 45 | View(T3PresentationSourceView.self) { |
| @@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { · 108 unmodified lines | |||
| 140 | let isPlayableURL = url.isFileURL | 154 | let isPlayableURL = url.isFileURL |
| 141 | ? FileManager.default.isReadableFile(atPath: url.path) | 155 | ? FileManager.default.isReadableFile(atPath: url.path) |
| 142 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) | 156 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) |
| 143 | guard videoPresentation == nil, | 157 | guard videoPresentation == nil, filePresentation == nil, |
| 144 | let presenter = appContext?.utilities?.currentViewController(), | 158 | let presenter = appContext?.utilities?.currentViewController(), |
| 145 | isPlayableURL | 159 | isPlayableURL |
| 146 | else { | 160 | else { |
| @@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { · 15 unmodified lines | |||
| 162 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } | 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 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { | 197 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { |
| 166 | guard let presenter = appContext?.utilities?.currentViewController() else { | 198 | guard let presenter = appContext?.utilities?.currentViewController() else { |
| 167 | throw NSError( | 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 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; | 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 | export interface ComposerAttachmentStripProps { | 14 | export interface ComposerAttachmentStripProps { |
| 12 | /** Attachments to display. */ | 15 | /** Attachments to display. */ |
| 13 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 16 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 14 | /** Called when the user removes an attachment. */ | 17 | /** Called when the user removes an attachment. */ |
| 15 | readonly onRemove: (imageId: string) => void; | 18 | readonly onRemove: (imageId: string) => void; |
| 16 | /** Called when the user taps on an image thumbnail to preview it. */ | 19 | /** Called when the user taps an image or PDF to preview it. */ |
| 17 | readonly onPressImage?: (previewUri: string) => void; | 20 | readonly onPressPreview?: (source: FilePreviewSource) => void; |
| 18 | readonly onPressVideo?: ( | 21 | readonly onPressVideo?: ( |
| 19 | attachment: DraftComposerFileAttachment, | 22 | attachment: DraftComposerFileAttachment, |
| 20 | sourceIdentifier: string, | 23 | sourceIdentifier: string, |
| @@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { · 11 unmodified lines | |||
| 32 | readonly size: number; | 35 | readonly size: number; |
| 33 | readonly borderRadius: number; | 36 | readonly borderRadius: number; |
| 34 | readonly compact?: boolean; | 37 | readonly compact?: boolean; |
| 35 | readonly onPressImage?: (previewUri: string) => void; | 38 | readonly onPressPreview?: (source: FilePreviewSource) => void; |
| 36 | readonly onPressVideo?: ( | 39 | readonly onPressVideo?: ( |
| 37 | attachment: DraftComposerFileAttachment, | 40 | attachment: DraftComposerFileAttachment, |
| 38 | sourceIdentifier: string, | 41 | sourceIdentifier: string, |
| @@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines | |||
| 41 | const { attachment } = props; | 44 | const { attachment } = props; |
| 42 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | 45 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 43 | if (attachment.type === "image") { | 46 | if (attachment.type === "image") { |
| 47 | const sourceIdentifier = `draft-image:${attachment.id}`; | ||
| 44 | return ( | 48 | return ( |
| 45 | <Pressable | 49 | <PresentationSource identifier={sourceIdentifier}> |
| 46 | onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined} | 50 | <Pressable |
| 47 | > | 51 | accessibilityRole="imagebutton" |
| 48 | <Image | 52 | accessibilityLabel={`Open ${attachment.name}`} |
| 49 | source={{ uri: attachment.previewUri }} | 53 | disabled={!props.onPressPreview} |
| 50 | style={style} | 54 | onPress={() => |
| 51 | className="bg-subtle" | 55 | props.onPressPreview?.({ |
| 52 | resizeMode="cover" | 56 | kind: "image", |
| 53 | /> | 57 | uri: attachment.dataUrl, |
| 54 | </Pressable> | 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 | const onPressVideo = props.onPressVideo; | 73 | const onPressVideo = props.onPressVideo; |
| @@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines | |||
| 60 | <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} /> | 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 | return ( | 81 | return ( |
| 64 | <View | 82 | <PresentationSource identifier={sourceIdentifier}> |
| 65 | className={ | 83 | <Pressable |
| 66 | props.compact | 84 | accessibilityRole="button" |
| 67 | ? "items-center justify-center bg-subtle" | 85 | accessibilityLabel={`Open ${attachment.name}`} |
| 68 | : "items-center justify-center gap-1 bg-subtle px-2" | 86 | disabled={!canPreview} |
| 69 | } | 87 | onPress={() => |
| 70 | style={style} | 88 | props.onPressPreview?.({ |
| 71 | > | 89 | kind: "pdf", |
| 72 | <SymbolView | 90 | name: attachment.name, |
| 73 | name="doc.text" | 91 | attachment, |
| 74 | size={props.compact ? 15 : 22} | 92 | sourceIdentifier, |
| 75 | tintColor="#a3a3a3" | 93 | }) |
| 76 | type="monochrome" | 94 | } |
| 77 | /> | 95 | className={ |
| 78 | {!props.compact ? ( | 96 | props.compact |
| 79 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> | 97 | ? "items-center justify-center bg-subtle" |
| 80 | {attachment.name} | 98 | : "items-center justify-center gap-1 bg-subtle px-2" |
| 81 | </Text> | 99 | } |
| 82 | ) : null} | 100 | style={style} |
| 83 | </View> | 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 | shareRef.current = controller; | 144 | shareRef.current = controller; |
| 114 | setSharing(true); | 145 | setSharing(true); |
| 115 | void (async () => { | 146 | void (async () => { |
| 116 | const preview = await loadLocalVideoPreview(attachment, controller.signal); | 147 | const preview = await loadLocalAttachmentPreview(attachment, controller.signal); |
| 117 | if (!preview) return; | 148 | if (!preview) return; |
| 118 | try { | 149 | try { |
| 119 | await preview.share(controller.signal, sourceIdentifier); | 150 | await preview.share(controller.signal, sourceIdentifier); |
| @@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 65 unmodified lines | |||
| 185 | attachment={attachment} | 216 | attachment={attachment} |
| 186 | size={size} | 217 | size={size} |
| 187 | borderRadius={radius} | 218 | borderRadius={radius} |
| 188 | onPressImage={props.onPressImage} | 219 | onPressPreview={props.onPressPreview} |
| 189 | onPressVideo={props.onPressVideo} | 220 | onPressVideo={props.onPressVideo} |
| 190 | /> | 221 | /> |
| 191 | <Pressable | 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 | import { useEffect, useEffectEvent, useId, useState } from "react"; | 4 | import { useEffect, useEffectEvent, useId, useState } from "react"; |
| 5 | import { Alert, Keyboard } from "react-native"; | 5 | import { Alert, Keyboard } from "react-native"; |
| 6 | 6 | ||
| 7 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import { useAssetUrlState } from "../state/assets"; | 8 | import { useAssetUrlState } from "../state/assets"; |
| 9 | import { usePreparedConnection } from "../state/session"; | 9 | import { usePreparedConnection } from "../state/session"; |
| 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; | 10 | import type { VideoPreviewSource } from "./VideoPreviewModal"; |
| @@ -67,7 +67,7 @@ function NativeVideoPreview(props: { · 56 unmodified lines | |||
| 67 | void (async () => { | 67 | void (async () => { |
| 68 | const file = | 68 | const file = |
| 69 | source.type === "local" | 69 | source.type === "local" |
| 70 | ? await loadLocalVideoPreview(source.attachment, controller.signal) | 70 | ? await loadLocalAttachmentPreview(source.attachment, controller.signal) |
| 71 | : null; | 71 | : null; |
| 72 | if (source.type === "local" && !file) return; | 72 | if (source.type === "local" && !file) return; |
| 73 | try { | 73 | try { |
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | |||
| 20 | type AttachmentPreviewFile, | 20 | type AttachmentPreviewFile, |
| 21 | } from "../lib/attachmentDownload"; | 21 | } from "../lib/attachmentDownload"; |
| 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | 22 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 23 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | 23 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 24 | import { useAssetUrlState } from "../state/assets"; | 24 | import { useAssetUrlState } from "../state/assets"; |
| 25 | import { usePreparedConnection } from "../state/session"; | 25 | import { usePreparedConnection } from "../state/session"; |
| 26 | import { SymbolView } from "./AppSymbol"; | 26 | import { SymbolView } from "./AppSymbol"; |
| @@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { · 128 unmodified lines | |||
| 155 | setFailure(null); | 155 | setFailure(null); |
| 156 | const loading = | 156 | const loading = |
| 157 | source.type === "local" | 157 | source.type === "local" |
| 158 | ? loadLocalVideoPreview(source.attachment, controller.signal) | 158 | ? loadLocalAttachmentPreview(source.attachment, controller.signal) |
| 159 | : downloadAttachmentForPreview({ | 159 | : downloadAttachmentForPreview({ |
| 160 | url: downloadUrl!, | 160 | url: downloadUrl!, |
| 161 | attachment: { name: attachment.name, mimeType }, | 161 | attachment: { name: attachment.name, mimeType }, |
| @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; · 4 unmodified lines | |||
| 5 | import { StyleSheet } from "react-native"; | 5 | import { StyleSheet } from "react-native"; |
| 6 | 6 | ||
| 7 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | 7 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 8 | import { loadLocalVideoPreview } from "../lib/localVideoPreview"; | 8 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 9 | import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; | 9 | import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; |
| 10 | 10 | ||
| 11 | export function VideoThumbnailImage(props: { | 11 | export function VideoThumbnailImage(props: { |
| @@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { · 13 unmodified lines | |||
| 25 | async (signal) => | 25 | async (signal) => |
| 26 | typeof source === "string" | 26 | typeof source === "string" |
| 27 | ? { uri: source, dispose: () => undefined } | 27 | ? { uri: source, dispose: () => undefined } |
| 28 | : loadLocalVideoPreview(source, signal), | 28 | : loadLocalAttachmentPreview(source, signal), |
| 29 | controller.signal, | 29 | controller.signal, |
| 30 | ).then((thumbnail) => { | 30 | ).then((thumbnail) => { |
| 31 | if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); | 31 | if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); |
| @@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; · 16 unmodified lines | |||
| 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 18 | import { ControlPillMenu } from "../../components/ControlPill"; | 18 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 19 | import { EmptyState } from "../../components/EmptyState"; | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 20 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | ||
| 20 | import { LoadingScreen } from "../../components/LoadingScreen"; | 21 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 21 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; | 22 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| 22 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 23 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 24 | import { isPdfFile } from "../../lib/filePreview"; | ||
| 23 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 25 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 24 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 26 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 25 | import { useThreadSelection } from "../../state/use-thread-selection"; | 27 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 461 unmodified lines | |||
| 487 | readonly mode: FileViewMode; | 489 | readonly mode: FileViewMode; |
| 488 | } | null>(null); | 490 | } | null>(null); |
| 489 | const [previewRevision, setPreviewRevision] = useState(0); | 491 | const [previewRevision, setPreviewRevision] = useState(0); |
| 492 | const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null); | ||
| 490 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); | 493 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); |
| 491 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); | 494 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); |
| 492 | const canPreview = | 495 | const canPreview = |
| @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 93 unmodified lines | |||
| 586 | inline: false, | 589 | inline: false, |
| 587 | onPress: () => copyTextWithHaptic(relativePath), | 590 | onPress: () => copyTextWithHaptic(relativePath), |
| 588 | } as const, | 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 | isBrowserFile && typeof assetPreviewUri === "string" | 606 | isBrowserFile && typeof assetPreviewUri === "string" |
| 590 | ? ({ | 607 | ? ({ |
| 591 | id: "open-browser", | 608 | id: "open-browser", |
| @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 13 unmodified lines | |||
| 605 | } as const) | 622 | } as const) |
| 606 | : null, | 623 | : null, |
| 607 | ].filter((action) => action !== null); | 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 | const androidFileMenuActions = useMemo<MenuAction[]>( | 635 | const androidFileMenuActions = useMemo<MenuAction[]>( |
| 611 | () => | 636 | () => |
| @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 154 unmodified lines | |||
| 766 | truncated={fileData?.truncated ?? false} | 791 | truncated={fileData?.truncated ?? false} |
| 767 | onRefresh={() => fileQuery.refresh()} | 792 | onRefresh={() => fileQuery.refresh()} |
| 768 | /> | 793 | /> |
| 794 | <FilePreviewModal | ||
| 795 | source={fullScreenPreview} | ||
| 796 | onRequestClose={() => setFullScreenPreview(null)} | ||
| 797 | /> | ||
| 769 | </View> | 798 | </View> |
| 770 | </ReviewHighlighterProvider> | 799 | </ReviewHighlighterProvider> |
| 771 | ); | 800 | ); |
| @@ -1,24 +1,25 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useMemo, useState } from "react"; | 2 | import { useId, useMemo, useState } from "react"; |
| 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; | 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; |
| 4 | import ImageViewing from "react-native-image-viewing"; | ||
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 6 | 5 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 6 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { EmptyState } from "../../components/EmptyState"; | 7 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; | 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 | function ResolvedWorkspaceFileImagePreview(props: { | 12 | function ResolvedWorkspaceFileImagePreview(props: { |
| 12 | readonly accessibilityLabel: string; | 13 | readonly accessibilityLabel: string; |
| 13 | readonly uri: string; | 14 | readonly uri: string; |
| 14 | }) { | 15 | }) { |
| 15 | const [loadError, setLoadError] = useState<string | null>(null); | 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 | const imageSource = useMemo( | 19 | const imageSource = useMemo( |
| 18 | () => ({ uri: props.uri, cache: "force-cache" as const }), | 20 | () => ({ uri: props.uri, cache: "force-cache" as const }), |
| 19 | [props.uri], | 21 | [props.uri], |
| 20 | ); | 22 | ); |
| 21 | const fullScreenImages = useMemo(() => [imageSource], [imageSource]); | ||
| 22 | 23 | ||
| 23 | return ( | 24 | return ( |
| 24 | <View className="relative flex-1 bg-subtle"> | 25 | <View className="relative flex-1 bg-subtle"> |
| @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines | |||
| 27 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} | 28 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} |
| 28 | disabled={loadError !== null} | 29 | disabled={loadError !== null} |
| 29 | className="flex-1 p-4 active:bg-subtle-strong" | 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 | 40 | <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}> |
| 33 | accessible={false} | 41 | <Image |
| 34 | source={imageSource} | 42 | accessible={false} |
| 35 | className="h-full w-full" | 43 | source={imageSource} |
| 36 | resizeMode="contain" | 44 | className="h-full w-full" |
| 37 | onLoadStart={() => setLoadError(null)} | 45 | resizeMode="contain" |
| 38 | onError={(event) => { | 46 | onLoadStart={() => setLoadError(null)} |
| 39 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | 47 | onError={(event) => { |
| 40 | }} | 48 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); |
| 41 | /> | 49 | }} |
| 50 | /> | ||
| 51 | </PresentationSource> | ||
| 42 | </Pressable> | 52 | </Pressable> |
| 43 | 53 | ||
| 44 | {loadError !== null ? ( | 54 | {loadError !== null ? ( |
| @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines | |||
| 47 | </View> | 57 | </View> |
| 48 | ) : null} | 58 | ) : null} |
| 49 | 59 | ||
| 50 | <ImageViewing | 60 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 51 | images={fullScreenImages} | ||
| 52 | imageIndex={0} | ||
| 53 | visible={fullScreenVisible} | ||
| 54 | onRequestClose={() => setFullScreenVisible(false)} | ||
| 55 | swipeToCloseEnabled | ||
| 56 | doubleTapToZoomEnabled | ||
| 57 | /> | ||
| 58 | </View> | 61 | </View> |
| 59 | ); | 62 | ); |
| 60 | } | 63 | } |
| @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 4 unmodified lines | |||
| 5 | import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; | 5 | import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; |
| 6 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; | 6 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 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 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 11 | import { SymbolView } from "../../components/AppSymbol"; | 11 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 41 unmodified lines | |||
| 53 | Record<string, ReadonlyArray<ReviewHighlightedToken>> | 53 | Record<string, ReadonlyArray<ReviewHighlightedToken>> |
| 54 | >({}); | 54 | >({}); |
| 55 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | 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 | const selectedLines = useMemo( | 58 | const selectedLines = useMemo( |
| 59 | () => (target ? getSelectedReviewCommentLines(target) : []), | 59 | () => (target ? getSelectedReviewCommentLines(target) : []), |
| @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 212 unmodified lines | |||
| 272 | attachments={attachments} | 272 | attachments={attachments} |
| 273 | imageBorderRadius={16} | 273 | imageBorderRadius={16} |
| 274 | imageSize={60} | 274 | imageSize={60} |
| 275 | onPressImage={setPreviewImageUri} | 275 | onPressPreview={setPreviewFile} |
| 276 | removeButtonPlacement="gutter" | 276 | removeButtonPlacement="gutter" |
| 277 | onRemove={(imageId) => { | 277 | onRemove={(imageId) => { |
| 278 | setAttachments((current) => | 278 | setAttachments((current) => |
| @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 53 unmodified lines | |||
| 332 | </View> | 332 | </View> |
| 333 | </KeyboardStickyView> | 333 | </KeyboardStickyView> |
| 334 | ) : null} | 334 | ) : null} |
| 335 | <ImageViewing | 335 | <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} /> |
| 336 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | ||
| 337 | imageIndex={0} | ||
| 338 | visible={previewImageUri !== null} | ||
| 339 | onRequestClose={() => setPreviewImageUri(null)} | ||
| 340 | swipeToCloseEnabled | ||
| 341 | doubleTapToZoomEnabled | ||
| 342 | /> | ||
| 343 | </View> | 336 | </View> |
| 344 | ); | 337 | ); |
| 345 | } | 338 | } |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | |||
| 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 38 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | ||
| 38 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | 39 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 39 | import { ProviderIcon } from "../../components/ProviderIcon"; | 40 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 40 | import { SymbolView } from "../../components/AppSymbol"; | 41 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { · 126 unmodified lines | |||
| 167 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 168 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 168 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 169 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 169 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | 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 | const openVideoPreview = useCallback( | 173 | const openVideoPreview = useCallback( |
| 172 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { | 174 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 173 | wasFocusedBeforeVideoRef.current = isComposerFocused; | 175 | wasFocusedBeforePreviewRef.current = isComposerFocused; |
| 176 | setPreviewFile(null); | ||
| 174 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); | 177 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 175 | }, | 178 | }, |
| 176 | [isComposerFocused], | 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 | setPreviewVideo(null); | 190 | setPreviewVideo(null); |
| 180 | if (wasFocusedBeforeVideoRef.current) { | 191 | setPreviewFile(null); |
| 192 | if (wasFocusedBeforePreviewRef.current) { | ||
| 181 | setTimeout(() => { | 193 | setTimeout(() => { |
| 182 | if (navigation.isFocused()) promptInputRef.current?.focus(); | 194 | if (navigation.isFocused()) promptInputRef.current?.focus(); |
| 183 | }, 100); | 195 | }, 100); |
| @@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { · 1028 unmodified lines | |||
| 1212 | ? () => undefined | 1224 | ? () => undefined |
| 1213 | : flow.removeAttachment | 1225 | : flow.removeAttachment |
| 1214 | } | 1226 | } |
| 1227 | onPressPreview={ | ||
| 1228 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview | ||
| 1229 | } | ||
| 1215 | onPressVideo={ | 1230 | onPressVideo={ |
| 1216 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview | 1231 | isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview |
| 1217 | } | 1232 | } |
| @@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { · 100 unmodified lines | |||
| 1318 | </ComposerDictationToolbar> | 1333 | </ComposerDictationToolbar> |
| 1319 | </Animated.View> | 1334 | </Animated.View> |
| 1320 | </ComposerSurface> | 1335 | </ComposerSurface> |
| 1321 | <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} /> | 1336 | <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} /> |
| 1337 | <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} /> | ||
| 1322 | </View> | 1338 | </View> |
| 1323 | ); | 1339 | ); |
| 1324 | 1340 | ||
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | |||
| 20 | type RefObject, | 20 | type RefObject, |
| 21 | } from "react"; | 21 | } from "react"; |
| 22 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; | 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 | import Animated, { | 24 | import Animated, { |
| 25 | FadeIn, | 25 | FadeIn, |
| 26 | FadeInDown, | 26 | FadeInDown, |
| @@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 284 unmodified lines | |||
| 311 | const inFlightThreadIdsRef = useRef(new Set<string>()); | 311 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 312 | const { onExpandedChange } = props; | 312 | const { onExpandedChange } = props; |
| 313 | 313 | ||
| 314 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 314 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); |
| 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | 315 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 316 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 317 | const showStopAction = | 317 | const showStopAction = |
| @@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 54 unmodified lines | |||
| 372 | onExpandedChange?.(isExpanded); | 372 | onExpandedChange?.(isExpanded); |
| 373 | }, [isExpanded, onExpandedChange]); | 373 | }, [isExpanded, onExpandedChange]); |
| 374 | 374 | ||
| 375 | const onPressImage = useCallback( | 375 | const onPressPreview = useCallback( |
| 376 | (uri: string) => { | 376 | (source: FilePreviewSource) => { |
| 377 | wasExpandedBeforePreviewRef.current = isFocused; | 377 | wasExpandedBeforePreviewRef.current = isFocused; |
| 378 | setPreviewVideo(null); | 378 | setPreviewVideo(null); |
| 379 | setPreviewImageUri(uri); | 379 | setPreviewFile((current) => current ?? source); |
| 380 | }, | 380 | }, |
| 381 | [isFocused], | 381 | [isFocused], |
| 382 | ); | 382 | ); |
| 383 | 383 | ||
| 384 | const closePreview = useCallback(() => { | 384 | const closePreview = useCallback(() => { |
| 385 | setPreviewImageUri(null); | 385 | setPreviewFile(null); |
| 386 | setPreviewVideo(null); | 386 | setPreviewVideo(null); |
| 387 | if (wasExpandedBeforePreviewRef.current) { | 387 | if (wasExpandedBeforePreviewRef.current) { |
| 388 | setTimeout(() => { | 388 | setTimeout(() => { |
| @@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 5 unmodified lines | |||
| 394 | const onPressVideo = useCallback( | 394 | const onPressVideo = useCallback( |
| 395 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { | 395 | (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { |
| 396 | wasExpandedBeforePreviewRef.current = isFocused; | 396 | wasExpandedBeforePreviewRef.current = isFocused; |
| 397 | setPreviewImageUri(null); | 397 | setPreviewFile(null); |
| 398 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); | 398 | setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); |
| 399 | }, | 399 | }, |
| 400 | [isFocused], | 400 | [isFocused], |
| @@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 218 unmodified lines | |||
| 619 | <ComposerAttachmentStrip | 619 | <ComposerAttachmentStrip |
| 620 | attachments={props.draftAttachments} | 620 | attachments={props.draftAttachments} |
| 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} | 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 622 | onPressImage={voiceInput.isBusy ? undefined : onPressImage} | 622 | onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} |
| 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} | 623 | onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} |
| 624 | /> | 624 | /> |
| 625 | </Animated.View> | 625 | </Animated.View> |
| @@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 47 unmodified lines | |||
| 673 | size={30} | 673 | size={30} |
| 674 | borderRadius={8} | 674 | borderRadius={8} |
| 675 | compact | 675 | compact |
| 676 | onPressImage={onPressImage} | 676 | onPressPreview={onPressPreview} |
| 677 | onPressVideo={onPressVideo} | 677 | onPressVideo={onPressVideo} |
| 678 | /> | 678 | /> |
| 679 | ))} | 679 | ))} |
| @@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 139 unmodified lines | |||
| 819 | </Animated.View> | 819 | </Animated.View> |
| 820 | 820 | ||
| 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> | 821 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> |
| 822 | <ImageViewing | 822 | <FilePreviewModal source={previewFile} onRequestClose={closePreview} /> |
| 823 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | ||
| 824 | imageIndex={0} | ||
| 825 | visible={previewImageUri !== null} | ||
| 826 | onRequestClose={closePreview} | ||
| 827 | swipeToCloseEnabled | ||
| 828 | doubleTapToZoomEnabled | ||
| 829 | /> | ||
| 830 | </Animated.View> | 823 | </Animated.View> |
| 831 | ); | 824 | ); |
| 832 | }); | 825 | }); |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | useMemo, | 36 | useMemo, |
| 37 | useRef, | 37 | useRef, |
| 38 | useState, | 38 | useState, |
| 39 | useId, | ||
| 39 | type ReactNode, | 40 | type ReactNode, |
| 40 | type RefObject, | 41 | type RefObject, |
| 41 | } from "react"; | 42 | } from "react"; |
| @@ -62,8 +63,9 @@ import { · 20 unmodified lines | |||
| 62 | View, | 63 | View, |
| 63 | type ViewStyle, | 64 | type ViewStyle, |
| 64 | } from "react-native"; | 65 | } from "react-native"; |
| 65 | import { TouchableOpacity } from "react-native-gesture-handler"; | 66 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 66 | import ImageViewing from "react-native-image-viewing"; | 67 | import { isPdfFile } from "../../lib/filePreview"; |
| 68 | import { PresentationSource } from "../../components/NativePresentation"; | ||
| 67 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 69 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 68 | import Animated, { | 70 | import Animated, { |
| 69 | FadeIn, | 71 | FadeIn, |
| @@ -212,9 +214,11 @@ export interface ThreadFeedProps { · 142 unmodified lines | |||
| 212 | function MessageAttachmentImage(props: { | 214 | function MessageAttachmentImage(props: { |
| 213 | readonly environmentId: EnvironmentId; | 215 | readonly environmentId: EnvironmentId; |
| 214 | readonly attachmentId: string; | 216 | readonly attachmentId: string; |
| 217 | readonly name: string; | ||
| 215 | readonly className: string; | 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 | const uri = useAssetUrl(props.environmentId, { | 222 | const uri = useAssetUrl(props.environmentId, { |
| 219 | _tag: "attachment", | 223 | _tag: "attachment", |
| 220 | attachmentId: props.attachmentId, | 224 | attachmentId: props.attachmentId, |
| @@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { · 8 unmodified lines | |||
| 229 | } | 233 | } |
| 230 | 234 | ||
| 231 | return ( | 235 | return ( |
| 232 | <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}> | 236 | <PresentationSource identifier={sourceIdentifier}> |
| 233 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> | 237 | <Pressable |
| 234 | </TouchableOpacity> | 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 | function MessageAttachmentFile(props: { | 261 | function MessageAttachmentFile(props: { |
| 250 | readonly environmentId: EnvironmentId; | 262 | readonly environmentId: EnvironmentId; |
| 251 | readonly attachment: ChatFileAttachment; | 263 | readonly attachment: ChatFileAttachment; |
| 264 | readonly onPressPreview: (source: FilePreviewSource) => void; | ||
| 252 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | 265 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 253 | }) { | 266 | }) { |
| 267 | const sourceIdentifier = useId(); | ||
| 254 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 268 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 255 | reportFailure: false, | 269 | reportFailure: false, |
| 256 | }); | 270 | }); |
| 257 | const preparedConnection = usePreparedConnection(props.environmentId); | 271 | const preparedConnection = usePreparedConnection(props.environmentId); |
| 258 | const { attachment } = props; | 272 | const { attachment } = props; |
| 259 | const videoType = videoMimeType(attachment); | 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 | const thumbnailUrl = useAssetUrl( | 279 | const thumbnailUrl = useAssetUrl( |
| 261 | props.environmentId, | 280 | props.environmentId, |
| 262 | videoType === null | 281 | videoType === null |
| @@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { · 85 unmodified lines | |||
| 348 | } | 367 | } |
| 349 | 368 | ||
| 350 | return ( | 369 | return ( |
| 351 | <Pressable | 370 | <PresentationSource |
| 352 | accessibilityRole="button" | 371 | identifier={sourceIdentifier} |
| 353 | accessibilityLabel={`Open ${attachment.name}`} | 372 | className="my-1" |
| 354 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} | 373 | style={{ width: 280, maxWidth: "100%" }} |
| 355 | disabled={opening || httpBaseUrl === null} | ||
| 356 | className="flex-row items-center gap-2 py-1" | ||
| 357 | onPress={() => shareFile()} | ||
| 358 | > | 374 | > |
| 359 | {opening ? ( | 375 | <Pressable |
| 360 | <ActivityIndicator size="small" /> | 376 | accessibilityRole="button" |
| 361 | ) : ( | 377 | accessibilityLabel={`Open ${attachment.name}`} |
| 362 | <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" /> | 378 | accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }} |
| 363 | )} | 379 | accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }} |
| 364 | <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}> | 380 | disabled={opening || httpBaseUrl === null} |
| 365 | {attachment.name} | 381 | className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle" |
| 366 | </Text> | 382 | onPress={() => |
| 367 | <Text className="text-xs text-foreground-muted"> | 383 | isPdf |
| 368 | {formatAttachmentSize(attachment.sizeBytes)} | 384 | ? props.onPressPreview({ |
| 369 | </Text> | 385 | kind: "pdf", |
| 370 | </Pressable> | 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 | readonly sourceKey: string; | 447 | readonly sourceKey: string; |
| 392 | readonly unavailable: boolean; | 448 | readonly unavailable: boolean; |
| 393 | readonly alt: string | null; | 449 | readonly alt: string | null; |
| 394 | readonly onPressImage: (uri: string) => void; | 450 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 395 | }) { | 451 | }) { |
| 452 | const sourceIdentifier = useId(); | ||
| 396 | const [availableWidth, setAvailableWidth] = useState(0); | 453 | const [availableWidth, setAvailableWidth] = useState(0); |
| 397 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); | 454 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 398 | const [failedUri, setFailedUri] = useState<string | null>(null); | 455 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| @@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { · 38 unmodified lines | |||
| 437 | )} | 494 | )} |
| 438 | </View> | 495 | </View> |
| 439 | ) : ( | 496 | ) : ( |
| 440 | <TouchableOpacity | 497 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 441 | accessibilityRole="imagebutton" | 498 | <Pressable |
| 442 | accessibilityLabel={props.alt ?? "Markdown image"} | 499 | accessibilityRole="imagebutton" |
| 443 | activeOpacity={0.7} | 500 | accessibilityLabel={props.alt ?? "Markdown image"} |
| 444 | onPress={() => props.onPressImage(props.uri!)} | 501 | onPress={() => |
| 445 | style={{ alignSelf: "flex-start" }} | 502 | props.onPressPreview({ |
| 446 | > | 503 | kind: "image", |
| 447 | <View | 504 | uri: props.uri!, |
| 448 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | 505 | name: props.alt ?? "Image", |
| 449 | style={{ | 506 | sourceIdentifier, |
| 450 | ...frameStyle, | 507 | }) |
| 451 | }} | 508 | } |
| 509 | style={{ alignSelf: "flex-start" }} | ||
| 452 | > | 510 | > |
| 453 | <ThreadMarkdownImageRequest | 511 | <View |
| 454 | key={props.uri} | 512 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" |
| 455 | uri={props.uri} | 513 | style={{ |
| 456 | onLoad={setSourceSize} | 514 | ...frameStyle, |
| 457 | onError={() => setFailedUri(props.uri)} | 515 | }} |
| 458 | /> | 516 | > |
| 459 | </View> | 517 | <ThreadMarkdownImageRequest |
| 460 | </TouchableOpacity> | 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 | {props.alt ? ( | 527 | {props.alt ? ( |
| 463 | <Text selectable className="text-xs text-foreground-muted"> | 528 | <Text selectable className="text-xs text-foreground-muted"> |
| @@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { · 42 unmodified lines | |||
| 506 | readonly threadId: ThreadId; | 571 | readonly threadId: ThreadId; |
| 507 | readonly path: string; | 572 | readonly path: string; |
| 508 | readonly alt: string | null; | 573 | readonly alt: string | null; |
| 509 | readonly onPressImage: (uri: string) => void; | 574 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 510 | }) { | 575 | }) { |
| 511 | const assetUrl = useAssetUrlState(props.environmentId, { | 576 | const assetUrl = useAssetUrlState(props.environmentId, { |
| 512 | _tag: "workspace-file", | 577 | _tag: "workspace-file", |
| @@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { · 7 unmodified lines | |||
| 520 | sourceKey={props.path} | 585 | sourceKey={props.path} |
| 521 | unavailable={assetUrl._tag === "Failure"} | 586 | unavailable={assetUrl._tag === "Failure"} |
| 522 | alt={props.alt} | 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 | sourceKey="unavailable" | 597 | sourceKey="unavailable" |
| 533 | unavailable | 598 | unavailable |
| 534 | alt={props.alt} | 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 | readonly onToggleWorkGroup: (groupId: string) => void; | 1329 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 1265 | readonly onToggleWorkRow: (rowId: string) => void; | 1330 | readonly onToggleWorkRow: (rowId: string) => void; |
| 1266 | readonly onToggleTurnFold: (turnId: TurnId) => void; | 1331 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1267 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | 1332 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 1268 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; | 1333 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 1269 | readonly onMarkdownLinkPress: (href: string) => void; | 1334 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1270 | readonly renderMarkdownImage: MarkdownImageRenderer; | 1335 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| @@ -1375,14 +1440,16 @@ function renderFeedEntry( · 104 unmodified lines | |||
| 1375 | key={attachment.id} | 1440 | key={attachment.id} |
| 1376 | environmentId={props.environmentId} | 1441 | environmentId={props.environmentId} |
| 1377 | attachmentId={attachment.id} | 1442 | attachmentId={attachment.id} |
| 1443 | name={attachment.name} | ||
| 1378 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" | 1444 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1379 | onPressImage={props.onPressImage} | 1445 | onPressPreview={props.onPressPreview} |
| 1380 | /> | 1446 | /> |
| 1381 | ) : isFileAttachment(attachment) ? ( | 1447 | ) : isFileAttachment(attachment) ? ( |
| 1382 | <MessageAttachmentFile | 1448 | <MessageAttachmentFile |
| 1383 | key={attachment.id} | 1449 | key={attachment.id} |
| 1384 | environmentId={props.environmentId} | 1450 | environmentId={props.environmentId} |
| 1385 | attachment={attachment} | 1451 | attachment={attachment} |
| 1452 | onPressPreview={props.onPressPreview} | ||
| 1386 | onPressVideo={props.onPressVideo} | 1453 | onPressVideo={props.onPressVideo} |
| 1387 | /> | 1454 | /> |
| 1388 | ) : ( | 1455 | ) : ( |
| @@ -1436,14 +1503,16 @@ function renderFeedEntry( · 47 unmodified lines | |||
| 1436 | key={attachment.id} | 1503 | key={attachment.id} |
| 1437 | environmentId={props.environmentId} | 1504 | environmentId={props.environmentId} |
| 1438 | attachmentId={attachment.id} | 1505 | attachmentId={attachment.id} |
| 1506 | name={attachment.name} | ||
| 1439 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" | 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 | ) : isFileAttachment(attachment) ? ( | 1510 | ) : isFileAttachment(attachment) ? ( |
| 1443 | <MessageAttachmentFile | 1511 | <MessageAttachmentFile |
| 1444 | key={attachment.id} | 1512 | key={attachment.id} |
| 1445 | environmentId={props.environmentId} | 1513 | environmentId={props.environmentId} |
| 1446 | attachment={attachment} | 1514 | attachment={attachment} |
| 1515 | onPressPreview={props.onPressPreview} | ||
| 1447 | onPressVideo={props.onPressVideo} | 1516 | onPressVideo={props.onPressVideo} |
| 1448 | /> | 1517 | /> |
| 1449 | ) : ( | 1518 | ) : ( |
| @@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 356 unmodified lines | |||
| 1806 | expandedTurnIds: new Set(), | 1875 | expandedTurnIds: new Set(), |
| 1807 | }); | 1876 | }); |
| 1808 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; | 1877 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; |
| 1809 | const [expandedImage, setExpandedImage] = useState<{ | 1878 | const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); |
| 1810 | uri: string; | ||
| 1811 | headers?: Record<string, string>; | ||
| 1812 | } | null>(null); | ||
| 1813 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); | 1879 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); |
| 1814 | useEffect(() => { | 1880 | useEffect(() => { |
| 1815 | setExpandedVideo(null); | 1881 | setExpandedVideo(null); |
| 1882 | setExpandedFile(null); | ||
| 1816 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); | 1883 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); |
| 1817 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | 1884 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1818 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ | 1885 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| @@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 39 unmodified lines | |||
| 1858 | ); | 1925 | ); |
| 1859 | if (relativePath) { | 1926 | if (relativePath) { |
| 1860 | void Haptics.selectionAsync(); | 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 | navigation.navigate("ThreadFile", { | 1944 | navigation.navigate("ThreadFile", { |
| 1862 | environmentId: String(props.environmentId), | 1945 | environmentId: String(props.environmentId), |
| 1863 | threadId: String(props.threadId), | 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 | if (presentation.href) { | 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 | void tryOpenExternalUrl(presentation.href, "markdown-link"); | 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 | sourceKey={imageSource.uri} | 1973 | sourceKey={imageSource.uri} |
| 1885 | unavailable={false} | 1974 | unavailable={false} |
| 1886 | alt={image.alt} | 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 | threadId={props.threadId} | 1986 | threadId={props.threadId} |
| 1898 | path={imageSource.path} | 1987 | path={imageSource.path} |
| 1899 | alt={image.alt} | 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 | [suspendEndScrollMaintenanceForDisclosure], | 2374 | [suspendEndScrollMaintenanceForDisclosure], |
| 2286 | ); | 2375 | ); |
| 2287 | 2376 | ||
| 2288 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | 2377 | const onPressPreview = useCallback((source: FilePreviewSource) => { |
| 2289 | setExpandedImage({ uri, headers }); | 2378 | setExpandedFile((current) => current ?? source); |
| 2290 | }, []); | 2379 | }, []); |
| 2291 | const onPressVideo = useCallback( | 2380 | const onPressVideo = useCallback( |
| 2292 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { | 2381 | (attachment: ChatFileAttachment, sourceIdentifier: string) => { |
| @@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 57 unmodified lines | |||
| 2350 | onToggleWorkGroup, | 2439 | onToggleWorkGroup, |
| 2351 | onToggleWorkRow, | 2440 | onToggleWorkRow, |
| 2352 | onToggleTurnFold, | 2441 | onToggleTurnFold, |
| 2353 | onPressImage, | 2442 | onPressPreview, |
| 2354 | onPressVideo, | 2443 | onPressVideo, |
| 2355 | onMarkdownLinkPress, | 2444 | onMarkdownLinkPress, |
| 2356 | renderMarkdownImage, | 2445 | renderMarkdownImage, |
| @@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 22 unmodified lines | |||
| 2379 | userBubbleMaxWidth, | 2468 | userBubbleMaxWidth, |
| 2380 | onCopyWorkRow, | 2469 | onCopyWorkRow, |
| 2381 | onMarkdownLinkPress, | 2470 | onMarkdownLinkPress, |
| 2382 | onPressImage, | 2471 | onPressPreview, |
| 2383 | onPressVideo, | 2472 | onPressVideo, |
| 2384 | onToggleTurnFold, | 2473 | onToggleTurnFold, |
| 2385 | onToggleWorkGroup, | 2474 | onToggleWorkGroup, |
| @@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 179 unmodified lines | |||
| 2565 | </View> | 2654 | </View> |
| 2566 | 2655 | ||
| 2567 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> | 2656 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> |
| 2568 | <ImageViewing | 2657 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> |
| 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 | /> | ||
| 2585 | </> | 2658 | </> |
| 2586 | ); | 2659 | ); |
| 2587 | }); | 2660 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; | ||
| 2 | import type { ImagePickerAsset } from "expo-image-picker"; | 3 | import type { ImagePickerAsset } from "expo-image-picker"; |
| 3 | 4 | ||
| 4 | const mocks = vi.hoisted(() => ({ | 5 | const mocks = vi.hoisted(() => ({ |
| @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ · 4 unmodified lines | |||
| 9 | delete: vi.fn(), | 10 | delete: vi.fn(), |
| 10 | open: vi.fn(), | 11 | open: vi.fn(), |
| 11 | size: vi.fn(), | 12 | size: vi.fn(), |
| 13 | readBase64: vi.fn(), | ||
| 12 | })); | 14 | })); |
| 13 | 15 | ||
| 14 | vi.mock("expo-file-system", () => { | 16 | vi.mock("expo-file-system", () => { |
| @@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { · 40 unmodified lines | |||
| 55 | mocks.copy(this.uri, destination.uri); | 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 | delete(): void { | 64 | delete(): void { |
| 59 | mocks.delete(this.uri); | 65 | mocks.delete(this.uri); |
| 60 | } | 66 | } |
| @@ -95,9 +101,115 @@ describe("composer file attachments", () => { · 34 unmodified lines | |||
| 95 | mocks.delete.mockReset(); | 101 | mocks.delete.mockReset(); |
| 96 | mocks.open.mockReset(); | 102 | mocks.open.mockReset(); |
| 97 | mocks.size.mockReset(); | 103 | mocks.size.mockReset(); |
| 104 | mocks.readBase64.mockReset(); | ||
| 98 | mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); | 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 | describe("photo library videos", () => { | 213 | describe("photo library videos", () => { |
| 102 | const image: ImagePickerAsset = { | 214 | const image: ImagePickerAsset = { |
| 103 | uri: "file:///picker/photo.png", | 215 | uri: "file:///picker/photo.png", |
| @@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { · 350 unmodified lines | |||
| 351 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; | 351 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; |
| 352 | break; | 352 | break; |
| 353 | } | 353 | } |
| 354 | const mimeType = asset.mimeType?.toLowerCase(); | 354 | let mimeType = asset.mimeType?.toLowerCase(); |
| 355 | if (asset.type === "video" || mimeType?.startsWith("video/")) { | 355 | if (asset.type === "video" || mimeType?.startsWith("video/")) { |
| 356 | if (input.maxVideoBytes === undefined) { | 356 | if (input.maxVideoBytes === undefined) { |
| 357 | error = "Video attachments are unavailable here."; | 357 | error = "Video attachments are unavailable here."; |
| @@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { · 17 unmodified lines | |||
| 375 | } | 375 | } |
| 376 | continue; | 376 | continue; |
| 377 | } | 377 | } |
| 378 | if (!mimeType?.startsWith("image/")) { | 378 | if (asset.type !== "image" && !mimeType?.startsWith("image/")) { |
| 379 | error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; | 379 | error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; |
| 380 | continue; | 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 | if (!base64) { | 384 | if (!base64) { |
| 389 | error = `Failed to read '${asset.fileName ?? "image"}'.`; | 385 | error = `Failed to read '${asset.fileName ?? "image"}'.`; |
| 390 | continue; | 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 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | 419 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { |
| 395 | error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; | 420 | error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; |
| 396 | continue; | 421 | continue; |
| 397 | } | 422 | } |
| 398 | 423 | ||
| 424 | const dataUrl = `data:${mimeType};base64,${base64}`; | ||
| 399 | attachments.push({ | 425 | attachments.push({ |
| 400 | id: uuidv4(), | 426 | id: uuidv4(), |
| 401 | type: "image", | 427 | type: "image", |
| 402 | name: asset.fileName ?? "image", | 428 | name, |
| 403 | mimeType, | 429 | mimeType, |
| 404 | sizeBytes, | 430 | sizeBytes, |
| 405 | dataUrl: `data:${mimeType};base64,${base64}`, | 431 | dataUrl, |
| 406 | previewUri: asset.uri, | 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 | const attachment = { | 29 | const attachment = { |
| 30 | type: "file" as const, | 30 | type: "file" as const, |
| @@ -45,9 +45,22 @@ beforeEach(() => { · 14 unmodified lines | |||
| 45 | mocks.share.mockResolvedValue(undefined); | 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 | it("resolves the current iOS container and releases its playback lease once", async () => { | 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 | expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); | 64 | expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); |
| 52 | expect(mocks.retain).toHaveBeenCalledWith(attachment); | 65 | expect(mocks.retain).toHaveBeenCalledWith(attachment); |
| 53 | const release = mocks.retain.mock.results[0]!.value; | 66 | const release = mocks.retain.mock.results[0]!.value; |
| @@ -62,7 +75,7 @@ describe("loadLocalVideoPreview", () => { · 8 unmodified lines | |||
| 62 | async (sourceIdentifier) => { | 75 | async (sourceIdentifier) => { |
| 63 | const shared = Promise.withResolvers<void>(); | 76 | const shared = Promise.withResolvers<void>(); |
| 64 | mocks.share.mockReturnValue(shared.promise); | 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 | const share = preview!.share(new AbortController().signal, sourceIdentifier); | 79 | const share = preview!.share(new AbortController().signal, sourceIdentifier); |
| 67 | expect(mocks.retain).toHaveBeenCalledTimes(2); | 80 | expect(mocks.retain).toHaveBeenCalledTimes(2); |
| 68 | const releasePlayback = mocks.retain.mock.results[0]!.value; | 81 | const releasePlayback = mocks.retain.mock.results[0]!.value; |
| @@ -78,7 +91,7 @@ describe("loadLocalVideoPreview", () => { · 9 unmodified lines | |||
| 78 | 91 | ||
| 79 | it("releases a failed share while keeping playback retained", async () => { | 92 | it("releases a failed share while keeping playback retained", async () => { |
| 80 | mocks.share.mockRejectedValue(new Error("Sharing unavailable")); | 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 | await expect(preview!.share(new AbortController().signal)).rejects.toThrow( | 95 | await expect(preview!.share(new AbortController().signal)).rejects.toThrow( |
| 83 | "Sharing unavailable", | 96 | "Sharing unavailable", |
| 84 | ); | 97 | ); |
| @@ -89,7 +102,7 @@ describe("loadLocalVideoPreview", () => { · 4 unmodified lines | |||
| 89 | 102 | ||
| 90 | it("releases a load canceled during native module loading", async () => { | 103 | it("releases a load canceled during native module loading", async () => { |
| 91 | const controller = new AbortController(); | 104 | const controller = new AbortController(); |
| 92 | const loading = loadLocalVideoPreview(attachment, controller.signal); | 105 | const loading = loadLocalAttachmentPreview(attachment, controller.signal); |
| 93 | controller.abort(); | 106 | controller.abort(); |
| 94 | await expect(loading).resolves.toBeNull(); | 107 | await expect(loading).resolves.toBeNull(); |
| 95 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); | 108 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); |
| @@ -98,14 +111,14 @@ describe("loadLocalVideoPreview", () => { · 2 unmodified lines | |||
| 98 | 111 | ||
| 99 | it("reports missing files and releases their lease", async () => { | 112 | it("reports missing files and releases their lease", async () => { |
| 100 | mocks.exists.mockReturnValue(false); | 113 | mocks.exists.mockReturnValue(false); |
| 101 | await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow( | 114 | await expect( |
| 102 | "This video is no longer available. Attach the file again.", | 115 | loadLocalAttachmentPreview(attachment, new AbortController().signal), |
| 103 | ); | 116 | ).rejects.toThrow("This attachment is no longer available. Attach the file again."); |
| 104 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); | 117 | expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); |
| 105 | }); | 118 | }); |
| 106 | 119 | ||
| 107 | it("does not start sharing a disposed preview", async () => { | 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 | preview!.dispose(); | 122 | preview!.dispose(); |
| 110 | await preview!.share(new AbortController().signal); | 123 | await preview!.share(new AbortController().signal); |
| 111 | expect(mocks.share).not.toHaveBeenCalled(); | 124 | expect(mocks.share).not.toHaveBeenCalled(); |
| @@ -5,8 +5,8 @@ import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles · 4 unmodified lines | |||
| 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; | 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; |
| 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | 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. */ | 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ |
| 9 | export async function loadLocalVideoPreview( | 9 | export async function loadLocalAttachmentPreview( |
| 10 | attachment: DraftComposerFileAttachment, | 10 | attachment: DraftComposerFileAttachment, |
| 11 | signal: AbortSignal, | 11 | signal: AbortSignal, |
| 12 | ): Promise<AttachmentPreviewFile | null> { | 12 | ): Promise<AttachmentPreviewFile | null> { |
| @@ -54,6 +54,6 @@ export async function loadLocalVideoPreview( · 41 unmodified lines | |||
| 54 | } catch (cause) { | 54 | } catch (cause) { |
| 55 | release(); | 55 | release(); |
| 56 | if (signal.aborted) return null; | 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 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | 104 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 105 | import * as ServerConfig from "./config.ts"; | 105 | import * as ServerConfig from "./config.ts"; |
| 106 | import { makeRoutesLayer } from "./server.ts"; | 106 | import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; |
| 107 | import { | 107 | import { |
| 108 | isThreadDetailEvent, | 108 | isThreadDetailEvent, |
| 109 | resolveAvailableEditorsForConfig, | 109 | resolveAvailableEditorsForConfig, |
| @@ -629,6 +629,7 @@ const buildAppUnderTest = (options?: { · 519 unmodified lines | |||
| 629 | { | 629 | { |
| 630 | disableListenLog: true, | 630 | disableListenLog: true, |
| 631 | disableLogger: true, | 631 | disableLogger: true, |
| 632 | routerConfig: HTTP_ROUTER_CONFIG, | ||
| 632 | }, | 633 | }, |
| 633 | ).pipe( | 634 | ).pipe( |
| 634 | Layer.provide( | 635 | Layer.provide( |
| @@ -1527,6 +1528,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 892 unmodified lines | |||
| 1527 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("compresses large JSON responses through the composed routes", () => | 1566 | it.effect("compresses large JSON responses through the composed routes", () => |
| 1531 | Effect.gen(function* () { | 1567 | Effect.gen(function* () { |
| 1532 | const descriptor = { | 1568 | const descriptor = { |
| @@ -123,6 +123,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; · 122 unmodified lines | |||
| 123 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; | 123 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 124 | import { forkParked, ServerActivation } from "./serverActivation.ts"; | 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 | // Effect's default preemptive shutdown waits 20s before finalizing request scopes. | 132 | // Effect's default preemptive shutdown waits 20s before finalizing request scopes. |
| 127 | // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer | 133 | // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer |
| 128 | // already closes the websocket gracefully. Do not add an artificial drain before | 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 | const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { | 682 | const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { |
| 677 | disableLogger: !config.logWebSocketEvents, | 683 | disableLogger: !config.logWebSocketEvents, |
| 684 | routerConfig: HTTP_ROUTER_CONFIG, | ||
| 678 | }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); | 685 | }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); |
| 679 | const serverApplicationLayer = Layer.mergeAll( | 686 | const serverApplicationLayer = Layer.mergeAll( |
| 680 | routesLayer, | 687 | routesLayer, |
| @@ -193,6 +193,20 @@ describe("pruneScanCache with an unwalked root", () => { · 192 unmodified lines | |||
| 193 | expect(removed).toBe(0); | 193 | expect(removed).toBe(0); |
| 194 | expect(cache.size).toBe(1); | 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 | describe("dedupeWithinFile", () => { | 212 | describe("dedupeWithinFile", () => { |
| @@ -14,6 +14,9 @@ · 13 unmodified lines | |||
| 14 | * | 14 | * |
| 15 | * @module usageScanCache | 15 | * @module usageScanCache |
| 16 | */ | 16 | */ |
| 17 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 18 | import * as NodePath from "node:path"; | ||
| 19 | |||
| 17 | import type { UsageProviderKind } from "@t3tools/contracts"; | 20 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 18 | 21 | ||
| 19 | import type { UsageRecord } from "./usageTranscripts.ts"; | 22 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| @@ -229,7 +232,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number · 209 unmodified lines | |||
| 229 | let removed = 0; | 232 | let removed = 0; |
| 230 | for (const [path, entry] of cache) { | 233 | for (const [path, entry] of cache) { |
| 231 | const agedOut = entry.mtimeMs < options.retentionCutoffMs; | 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 | const deleted = | 244 | const deleted = |
| 234 | underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); | 245 | underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); |
| 235 | if (agedOut || deleted) { | 246 | if (agedOut || deleted) { |
| @@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 264 unmodified lines | |||
| 265 | let needed = 0; | 265 | let needed = 0; |
| 266 | let groups = 0; | 266 | let groups = 0; |
| 267 | for (const child of current.children) { | 267 | for (const child of current.children) { |
| 268 | if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; | 268 | if (!(child instanceof HTMLElement)) continue; |
| 269 | needed += contentWidth(child); | 269 | const width = contentWidth(child); |
| 270 | if (width <= 1) continue; | ||
| 271 | needed += width; | ||
| 270 | groups += 1; | 272 | groups += 1; |
| 271 | } | 273 | } |
| 272 | needed += stripGap * Math.max(0, groups - 1); | 274 | needed += stripGap * Math.max(0, groups - 1); |
| @@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 83 unmodified lines | |||
| 356 | // Label widths can change without the strip box moving (font family or | 358 | // Label widths can change without the strip box moving (font family or |
| 357 | // size preferences), so re-measure on every render as well as on resize | 359 | // size preferences), so re-measure on every render as well as on resize |
| 358 | // and font loads. | 360 | // and font loads. |
| 359 | useEffect(() => { | 361 | useLayoutEffect(() => { |
| 360 | measure(); | 362 | measure(); |
| 361 | }); | 363 | }); |
| 362 | 364 | ||
| @@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 124 unmodified lines | |||
| 487 | onUsePreviousWorktree={onUsePreviousWorktree} | 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 | {showEnvironmentIndicator && availableEnvironments && ( | 493 | {showEnvironmentIndicator && availableEnvironments && ( |
| 492 | <> | 494 | <> |
| 493 | <BranchToolbarEnvironmentSelector | 495 | <BranchToolbarEnvironmentSelector |
| @@ -520,7 +522,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 26 unmodified lines | |||
| 520 | 522 | ||
| 521 | {showGitControls ? ( | 523 | {showGitControls ? ( |
| 522 | <BranchToolbarBranchSelector | 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 | environmentId={environmentId} | 526 | environmentId={environmentId} |
| 525 | threadId={threadId} | 527 | threadId={threadId} |
| 526 | {...(draftId ? { draftId } : {})} | 528 | {...(draftId ? { draftId } : {})} |
| @@ -51,20 +51,25 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe · 50 unmodified lines | |||
| 51 | if (envLocked) { | 51 | if (envLocked) { |
| 52 | return ( | 52 | return ( |
| 53 | <span | 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 | data-composer-context-control | 55 | data-composer-context-control |
| 56 | > | 56 | > |
| 57 | {activeWorktreePath ? ( | 57 | {activeWorktreePath ? ( |
| 58 | <> | 58 | <FolderGitIcon className="size-3 shrink-0" /> |
| 59 | <FolderGitIcon className="size-3" /> | ||
| 60 | {resolveLockedWorkspaceLabel(activeWorktreePath)} | ||
| 61 | </> | ||
| 62 | ) : ( | 59 | ) : ( |
| 63 | <> | 60 | <FolderIcon className="size-3 shrink-0" /> |
| 64 | <FolderIcon className="size-3" /> | ||
| 65 | {resolveLockedWorkspaceLabel(activeWorktreePath)} | ||
| 66 | </> | ||
| 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 | </span> | 73 | </span> |
| 69 | ); | 74 | ); |
| 70 | } | 75 | } |
| @@ -288,13 +288,16 @@ export const ChatHeader = memo(function ChatHeader({ · 287 unmodified lines | |||
| 288 | className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" | 288 | className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" |
| 289 | onContextMenu={handleHeaderContextMenu} | 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 | {/* The project always leads the header: knowing which project a | 295 | {/* The project always leads the header: knowing which project a |
| 293 | thread lives in is priority zero, and the thread title alone | 296 | thread lives in is priority zero, and the thread title alone |
| 294 | doesn't answer it. */} | 297 | doesn't answer it. */} |
| 295 | {activeProjectName ? ( | 298 | {activeProjectName ? ( |
| 296 | <> | 299 | <> |
| 297 | <WorkspaceBreadcrumbItem> | 300 | <WorkspaceBreadcrumbItem className="shrink"> |
| 298 | <Tooltip> | 301 | <Tooltip> |
| 299 | <TooltipTrigger | 302 | <TooltipTrigger |
| 300 | render={ | 303 | render={ |
| @@ -302,7 +305,7 @@ export const ChatHeader = memo(function ChatHeader({ · 1 unmodified line | |||
| 302 | type="button" | 305 | type="button" |
| 303 | aria-label={`New thread in ${activeProjectName}`} | 306 | aria-label={`New thread in ${activeProjectName}`} |
| 304 | onClick={onNewThreadInProject} | 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 | <WorkspaceBreadcrumbSeparator /> | 323 | <WorkspaceBreadcrumbSeparator /> |
| 321 | </> | 324 | </> |
| 322 | ) : null} | 325 | ) : null} |
| 323 | <WorkspaceBreadcrumbItem current className="flex-1"> | 326 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> |
| 324 | {renamingTitle !== null ? ( | 327 | {renamingTitle !== null ? ( |
| 325 | <input | 328 | <input |
| 326 | autoFocus | 329 | autoFocus |
| @@ -3,9 +3,9 @@ import type { · 2 unmodified lines | |||
| 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, | 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, |
| 4 | } from "@pierre/trees"; | 4 | } from "@pierre/trees"; |
| 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; | 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 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 8 | import { RotateCw } from "lucide-react"; | 8 | import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; |
| 9 | import { useEffect, useMemo, useRef } from "react"; | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 10 | 10 | ||
| 11 | import { Button } from "~/components/ui/button"; | 11 | import { Button } from "~/components/ui/button"; |
| @@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; · 9 unmodified lines | |||
| 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; | 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 22 | 22 | ||
| 23 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; | 23 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; |
| 24 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | ||
| 24 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; | 25 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| 25 | 26 | ||
| 26 | interface FileBrowserPanelProps { | 27 | interface FileBrowserPanelProps { |
| @@ -121,6 +122,10 @@ export default function FileBrowserPanel({ · 94 unmodified lines | |||
| 121 | ); | 122 | ); |
| 122 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); | 123 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); |
| 123 | const treePaths = useMemo(() => entries.map(treePath), [entries]); | 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 | const previousTreePathsRef = useRef<readonly string[]>([]); | 129 | const previousTreePathsRef = useRef<readonly string[]>([]); |
| 125 | const syncingSelectionRef = useRef(false); | 130 | const syncingSelectionRef = useRef(false); |
| 126 | const treeSelectionPathRef = useRef<string | null>(null); | 131 | const treeSelectionPathRef = useRef<string | null>(null); |
| @@ -252,6 +257,12 @@ export default function FileBrowserPanel({ · 125 unmodified lines | |||
| 252 | unsafeCSS: TREE_UNSAFE_CSS, | 257 | unsafeCSS: TREE_UNSAFE_CSS, |
| 253 | }); | 258 | }); |
| 254 | const search = useFileTreeSearch(model); | 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 | const handleSearchValueChange = (value: string) => { | 266 | const handleSearchValueChange = (value: string) => { |
| 256 | if (value.trim().length === 0) { | 267 | if (value.trim().length === 0) { |
| 257 | search.close(); | 268 | search.close(); |
| @@ -376,6 +387,32 @@ export default function FileBrowserPanel({ · 118 unmodified lines | |||
| 376 | onValueChange={handleSearchValueChange} | 387 | onValueChange={handleSearchValueChange} |
| 377 | onClose={search.close} | 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 | </div> | 416 | </div> |
| 380 | {entriesQuery.error && entriesQuery.data === null ? ( | 417 | {entriesQuery.error && entriesQuery.data === null ? ( |
| 381 | <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div> | 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 | import { | 3 | import { |
| 4 | applyWslEnableSelection, | 4 | applyWslEnableSelection, |
| 5 | isQrShareableEndpoint, | 5 | isQrShareableEndpoint, |
| 6 | isWslSettingsRowVisible, | ||
| 6 | selectQrEndpointOption, | 7 | selectQrEndpointOption, |
| 7 | } from "./ConnectionsSettings.logic"; | 8 | } from "./ConnectionsSettings.logic"; |
| 8 | 9 | ||
| @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { · 6 unmodified lines | |||
| 15 | preflightError: null, | 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 | describe("applyWslEnableSelection", () => { | 38 | describe("applyWslEnableSelection", () => { |
| 19 | it("clears WSL-only and updates the distro before enabling both backends", async () => { | 39 | it("clears WSL-only and updates the distro before enabling both backends", async () => { |
| 20 | const calls: Array<string> = []; | 40 | const calls: Array<string> = []; |
| @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { · 10 unmodified lines | |||
| 11 | return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; | 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 | export type QrEndpointOption = { | 22 | export type QrEndpointOption = { |
| 15 | /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ | 23 | /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ |
| 16 | readonly id: string; | 24 | readonly id: string; |
| @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls · 42 unmodified lines | |||
| 43 | import { | 43 | import { |
| 44 | applyWslEnableSelection, | 44 | applyWslEnableSelection, |
| 45 | isQrShareableEndpoint, | 45 | isQrShareableEndpoint, |
| 46 | isWslSettingsRowVisible, | ||
| 46 | selectQrEndpointOption, | 47 | selectQrEndpointOption, |
| 47 | } from "./ConnectionsSettings.logic"; | 48 | } from "./ConnectionsSettings.logic"; |
| 48 | import { | 49 | import { |
| @@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { · 2717 unmodified lines | |||
| 2766 | // retry so the row doesn't flicker away, and the button reflects the | 2767 | // retry so the row doesn't flicker away, and the button reflects the |
| 2767 | // loading state. With no error we simply haven't loaded yet (or WSL | 2768 | // loading state. With no error we simply haven't loaded yet (or WSL |
| 2768 | // management isn't available), so render nothing. | 2769 | // management isn't available), so render nothing. |
| 2769 | if (desktopWslError && canManageLocalBackend) { | 2770 | if ( |
| 2771 | isWslSettingsRowVisible({ state: null, error: desktopWslError }) && | ||
| 2772 | canManageLocalBackend | ||
| 2773 | ) { | ||
| 2770 | return ( | 2774 | return ( |
| 2771 | <SettingsRow | 2775 | <SettingsRow |
| 2772 | title="WSL backend" | 2776 | {...searchableSetting("wsl-backend")} |
| 2773 | description="Couldn't load the WSL backend state." | 2777 | description="Couldn't load the WSL backend state." |
| 2774 | status={<span className="block text-destructive">{desktopWslError}</span>} | 2778 | status={<span className="block text-destructive">{desktopWslError}</span>} |
| 2775 | control={ | 2779 | control={ |
| @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { · 18 unmodified lines | |||
| 2794 | // be stranded on a WSL preference they can't clear, so render a recovery | 2798 | // be stranded on a WSL preference they can't clear, so render a recovery |
| 2795 | // row that switches back to Windows. When WSL is unavailable AND unused, | 2799 | // row that switches back to Windows. When WSL is unavailable AND unused, |
| 2796 | // there's nothing to recover — keep the section hidden as before. | 2800 | // there's nothing to recover — keep the section hidden as before. |
| 2801 | if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { | ||
| 2802 | return null; | ||
| 2803 | } | ||
| 2797 | if (!desktopWslState.available) { | 2804 | if (!desktopWslState.available) { |
| 2798 | if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; | ||
| 2799 | return ( | 2805 | return ( |
| 2800 | <SettingsRow | 2806 | <SettingsRow |
| 2801 | title="WSL backend" | 2807 | {...searchableSetting("wsl-backend")} |
| 2802 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." | 2808 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." |
| 2803 | status={ | 2809 | status={ |
| 2804 | desktopWslError ? ( | 2810 | desktopWslError ? ( |
| @@ -2835,7 +2841,7 @@ export function ConnectionsSettings() { · 30 unmodified lines | |||
| 2835 | return ( | 2841 | return ( |
| 2836 | <> | 2842 | <> |
| 2837 | <SettingsRow | 2843 | <SettingsRow |
| 2838 | title="WSL backend" | 2844 | {...searchableSetting("wsl-backend")} |
| 2839 | 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." | 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 | status={ | 2846 | status={ |
| 2841 | desktopWslError ? ( | 2847 | desktopWslError ? ( |
| @@ -7,6 +7,8 @@ import { · 6 unmodified lines | |||
| 7 | type ComponentType, | 7 | type ComponentType, |
| 8 | type KeyboardEvent, | 8 | type KeyboardEvent, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 11 | import { desktopWslStateAtom } from "~/state/desktopWslState"; | ||
| 10 | import { | 12 | import { |
| 11 | ArchiveIcon, | 13 | ArchiveIcon, |
| 12 | BlocksIcon, | 14 | BlocksIcon, |
| @@ -21,6 +23,8 @@ import { · 8 unmodified lines | |||
| 21 | } from "lucide-react"; | 23 | } from "lucide-react"; |
| 22 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 24 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 23 | 25 | ||
| 26 | import { isElectron } from "~/env"; | ||
| 27 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | ||
| 24 | import { Button } from "../ui/button"; | 28 | import { Button } from "../ui/button"; |
| 25 | import { Input } from "../ui/input"; | 29 | import { Input } from "../ui/input"; |
| 26 | import { Kbd } from "../ui/kbd"; | 30 | import { Kbd } from "../ui/kbd"; |
| @@ -38,6 +42,7 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines | |||
| 38 | import { scrollToSettingsTarget } from "./settingsLayout"; | 42 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 39 | import { | 43 | import { |
| 40 | searchSettings, | 44 | searchSettings, |
| 45 | SETTINGS_SEARCH_ITEMS, | ||
| 41 | SETTINGS_SECTION_LABELS, | 46 | SETTINGS_SECTION_LABELS, |
| 42 | type SettingsPath, | 47 | type SettingsPath, |
| 43 | type SettingsSearchItem, | 48 | type SettingsSearchItem, |
| @@ -78,10 +83,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 34 unmodified lines | |||
| 78 | const searchInputRef = useRef<HTMLInputElement>(null); | 83 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 79 | const [query, setQuery] = useState(""); | 84 | const [query, setQuery] = useState(""); |
| 80 | const [activeResultIndex, setActiveResultIndex] = useState(0); | 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 | const isSearching = query.trim().length > 0; | 99 | const isSearching = query.trim().length > 0; |
| 83 | const hasResults = results.length > 0; | 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 | useEffect(() => { | 106 | useEffect(() => { |
| 86 | const result = results[activeResultIndex]; | 107 | const result = results[activeResultIndex]; |
| 87 | if (!result) return; | 108 | if (!result) return; |
| @@ -71,6 +71,17 @@ describe("searchSettings", () => { · 70 unmodified lines | |||
| 71 | it("hides desktop-only settings from browser search", () => { | 71 | it("hides desktop-only settings from browser search", () => { |
| 72 | expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); | 72 | expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); |
| 73 | expect(searchSettings("quit confirmation")).toEqual([]); | 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 | it("keeps catalog result ids unique", () => { | 87 | it("keeps catalog result ids unique", () => { |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { isElectron } from "~/env"; | 1 | import { isElectron } from "~/env"; |
| 2 | import { isWindowsPlatform } from "~/lib/utils"; | ||
| 2 | 3 | ||
| 3 | export type SettingsPath = | 4 | export type SettingsPath = |
| 4 | | "/settings/general" | 5 | | "/settings/general" |
| @@ -18,6 +19,9 @@ export interface SettingsSearchItem { · 13 unmodified lines | |||
| 18 | // Its row only renders in the desktop app, so a browser result would land on | 19 | // Its row only renders in the desktop app, so a browser result would land on |
| 19 | // an anchor that isn't there. | 20 | // an anchor that isn't there. |
| 20 | readonly desktopOnly?: boolean; | 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 | title: "Remote environments", | 268 | title: "Remote environments", |
| 265 | to: "/settings/connections", | 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 | id: "archive", | 279 | id: "archive", |
| 269 | title: "Archived threads", | 280 | title: "Archived threads", |
| @@ -309,6 +320,8 @@ export function searchSettings( · 39 unmodified lines | |||
| 309 | return items.filter( | 320 | return items.filter( |
| 310 | (item) => | 321 | (item) => |
| 311 | (isElectron || item.desktopOnly !== true) && | 322 | (isElectron || item.desktopOnly !== true) && |
| 323 | (!item.windowsOnly || | ||
| 324 | isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) && | ||
| 312 | normalizeSearchText(item.title).includes(normalizedQuery), | 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 | ## Native media presentations | 52 | ## Native media presentations |
| 53 | 53 | ||
| 54 | `PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's | 54 | `PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, |
| 55 | full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child | 55 | image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child |
| 56 | and pass the stable identifier to the presentation. The registry keeps weak | 56 | and pass the stable identifier to the presentation. The registry keeps weak |
| 57 | references to source views; recycled or compact composer thumbnails can register | 57 | references to source views; recycled or compact composer thumbnails can register |
| 58 | the same identifier. Identifiers must distinguish simultaneously visible attachments. | 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 | 59 | The source registration does not own the preview. Android uses a regular view. |
| 60 | viewer. Android uses a regular view. | ||
| 61 | 60 | ||
| 62 | On iOS, video previews mount `AVPlayerViewController` temporarily inside the | 61 | On iOS, video previews mount `AVPlayerViewController` temporarily inside the |
| 63 | registered source and enter full screen through AVKit. AVKit | 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 | the player uses a standard modal presentation. Programmatic entry uses the same | 66 | the player uses a standard modal presentation. Programmatic entry uses the same |
| 68 | guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; | 67 | guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; |
| 69 | if that selector is unavailable, the player also falls back to a standard modal. | 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 | Received videos open directly from their signed asset URL. AVKit handles buffering; | 89 | Received videos open directly from their signed asset URL. AVKit handles buffering; |
| 73 | the client does not download the entire file or show a separate opening overlay before | 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 | Videos use the server's file upload limit. You can also share photos, videos, and files into | 20 | Videos use the server's file upload limit. You can also share photos, videos, and files into |
| 21 | T3 Code from other apps through the system share sheet. Mobile uploads happen when the message | 21 | T3 Code from other apps through the system share sheet. Mobile uploads happen when the message |
| 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile | 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 | Select a video attachment before or after sending to play it. Web and desktop use the browser's | 31 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 26 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. | 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 | and shows **Attach again** next to it. Attach the file again or remove it, then send. | 42 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
| 37 | 43 | ||
| 38 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into | 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 | On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, | 48 | On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, |
| 42 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list | 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 | effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { | 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 | Effect.gen(function* () { | 464 | Effect.gen(function* () { |
| 465 | const readEnvironment = vi.fn( | 465 | const readEnvironment = vi.fn( |
| 466 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | 466 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => |
| @@ -483,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 16 unmodified lines | |||
| 483 | ), | 483 | ), |
| 484 | ).toEqual({ | 484 | ).toEqual({ |
| 485 | PATH: [ | 485 | PATH: [ |
| 486 | "C:\\Shell\\Bin", | ||
| 487 | "C:\\Windows\\System32", | ||
| 486 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 488 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 487 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | 489 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 488 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | 490 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| @@ -490,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line | |||
| 490 | "C:\\Users\\testuser\\.local\\bin", | 492 | "C:\\Users\\testuser\\.local\\bin", |
| 491 | "C:\\Users\\testuser\\.bun\\bin", | 493 | "C:\\Users\\testuser\\.bun\\bin", |
| 492 | "C:\\Users\\testuser\\scoop\\shims", | 494 | "C:\\Users\\testuser\\scoop\\shims", |
| 493 | "C:\\Shell\\Bin", | ||
| 494 | "C:\\Windows\\System32", | ||
| 495 | ].join(";"), | 495 | ].join(";"), |
| 496 | }); | 496 | }); |
| 497 | expect(readEnvironment).toHaveBeenCalledTimes(1); | 497 | expect(readEnvironment).toHaveBeenCalledTimes(1); |
| @@ -532,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 34 unmodified lines | |||
| 532 | PATH: [ | 532 | PATH: [ |
| 533 | "C:\\Profile\\Node", | 533 | "C:\\Profile\\Node", |
| 534 | "C:\\Windows\\System32", | 534 | "C:\\Windows\\System32", |
| 535 | "C:\\Shell\\Bin", | ||
| 535 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 536 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 536 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | 537 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 537 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | 538 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| @@ -539,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line | |||
| 539 | "C:\\Users\\testuser\\.local\\bin", | 540 | "C:\\Users\\testuser\\.local\\bin", |
| 540 | "C:\\Users\\testuser\\.bun\\bin", | 541 | "C:\\Users\\testuser\\.bun\\bin", |
| 541 | "C:\\Users\\testuser\\scoop\\shims", | 542 | "C:\\Users\\testuser\\scoop\\shims", |
| 542 | "C:\\Shell\\Bin", | ||
| 543 | ].join(";"), | 543 | ].join(";"), |
| 544 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | 544 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 545 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | 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 | ).toEqual({ | 577 | ).toEqual({ |
| 578 | PATH: [ | 578 | PATH: [ |
| 579 | "C:\\Windows\\System32", | ||
| 579 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 580 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 580 | "C:\\Users\\testuser\\.local\\bin", | 581 | "C:\\Users\\testuser\\.local\\bin", |
| 581 | "C:\\Users\\testuser\\.bun\\bin", | 582 | "C:\\Users\\testuser\\.bun\\bin", |
| 582 | "C:\\Users\\testuser\\scoop\\shims", | 583 | "C:\\Users\\testuser\\scoop\\shims", |
| 583 | "C:\\Windows\\System32", | ||
| 584 | ].join(";"), | 584 | ].join(";"), |
| 585 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | 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 | }).PATH; | 728 | }).PATH; |
| 729 | const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); | 729 | const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); |
| 730 | const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); | 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 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; | 734 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; |
| 733 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); | 735 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); |
| 734 | 736 | ||