From 9159b808d35a88e74fc91e11070f3270cdb321f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 13:03:27 -0700 Subject: [PATCH] feat(mobile): long-press file references for path and open actions (#9258) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/mobile/modules/t3-markdown-text/index.ts | 2 + .../t3-markdown-text/ios/T3MarkdownText.mm | 101 ++++++++++++++++-- .../t3-markdown-text/ios/T3MarkdownTextRun.h | 3 + .../t3-markdown-text/ios/T3MarkdownTextRun.mm | 72 ++++++++++++- .../src/MarkdownTextPrimitive.tsx | 6 ++ .../src/NativeMarkdownSelectableText.ios.tsx | 24 ++++- .../src/SelectableMarkdownText.ios.tsx | 76 +++++++------ .../src/SelectableMarkdownText.types.ts | 13 +++ .../src/T3MarkdownTextRunNativeComponent.ts | 6 ++ .../src/features/files/filePath.test.ts | 1 + apps/mobile/src/features/files/filePath.ts | 7 +- .../src/features/threads/ThreadFeed.tsx | 54 ++++++++-- .../src/features/threads/fileChipMenu.test.ts | 44 ++++++++ .../src/features/threads/fileChipMenu.ts | 49 +++++++++ .../src/native/SelectableMarkdownText.ios.tsx | 2 + .../src/native/SelectableMarkdownText.tsx | 2 + docs/user/composer.md | 3 +- 17 files changed, 411 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/features/threads/fileChipMenu.test.ts create mode 100644 apps/mobile/src/features/threads/fileChipMenu.ts diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts index 89bce5395c8c976dcd3f04d30a06868d677ac7d9..81b5f13f28df7d771bc9bf43833a7f30dbf2eb66 100644 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -21,6 +21,8 @@ export { type MarkdownHighlightedToken, } from "./src/SelectableMarkdownText"; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 6fa61aab17e9fb030939b6084b9e92661535dfd7..25f1e94c110ffc77a51f0112fd2ad5d0055b1fcd 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -198,6 +198,8 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) BOOL _suppressSelectionChange; NSMutableDictionary * _attachmentImages; NSMutableSet * _pendingAttachmentUris; + UILongPressGestureRecognizer *_longPressGestureRecognizer; + UITapGestureRecognizer *_pressGestureRecognizer; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -223,21 +225,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) _textView.textContainerInset = UIEdgeInsetsZero; _textView.textContainer.lineFragmentPadding = 0; _textView.delegate = self; + // Chat text supports selection and contextual actions, but not drag-and-drop. + _textView.textDragInteraction.enabled = NO; + _textView.linkTextAttributes = @{}; // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. _textView.layoutManager.usesFontLeading = NO; [self addSubview:_textView]; - const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; - longPressGestureRecognizer.delegate = self; + _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + _longPressGestureRecognizer.delegate = self; - const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handlePressIfNecessary:)]; - pressGestureRecognizer.delegate = self; - [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + _pressGestureRecognizer.delegate = self; + [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; - [_textView addGestureRecognizer:pressGestureRecognizer]; - [_textView addGestureRecognizer:longPressGestureRecognizer]; + [_textView addGestureRecognizer:_pressGestureRecognizer]; + [_textView addGestureRecognizer:_longPressGestureRecognizer]; } return self; @@ -312,6 +317,26 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) convertedAttrString, _state->getData().attachmentRanges, _attachmentImages); + NSUInteger runLocation = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); + runLocation = NSMaxRange(runRange); + if (![textChild hasContextMenu] || runRange.length == 0 || + NSMaxRange(runRange) > convertedAttrString.length) { + continue; + } + + NSURL *link = [NSURL URLWithString: + [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; + if (link != nil) { + [convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange]; + } + } [self loadAttachmentImages:_state->getData().attachmentRanges]; // Setting attributedText clears any active text selection, and re-assigning @@ -484,6 +509,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) return YES; } +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer +{ + if (gestureRecognizer != _longPressGestureRecognizer && + gestureRecognizer != _pressGestureRecognizer) { + return YES; + } + + const auto location = [self getLocationOfPress:gestureRecognizer]; + const auto child = [self getTouchChild:location]; + return ![child hasContextMenu]; +} + - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { return YES; @@ -507,6 +544,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) // MARK: - Touch handling +- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange +{ + NSUInteger location = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange range = NSMakeRange(location, textChild.text.length); + if (NSIntersectionRange(range, characterRange).length > 0) { + return textChild; + } + location = NSMaxRange(range); + } + return nil; +} + - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender { return [sender locationInView:_textView]; @@ -550,6 +605,10 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender { + if (sender.state != UIGestureRecognizerStateBegan) { + return; + } + const auto location = [self getLocationOfPress:sender]; const auto child = [self getTouchChild:location]; @@ -560,6 +619,30 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) // MARK: - UITextViewDelegate +- (nullable UIAction *)textView:(UITextView *)textView + primaryActionForTextItem:(UITextItem *)textItem + defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + if (![child hasContextMenu]) { + return defaultAction; + } + + __weak T3MarkdownTextRun *weakChild = child; + return [UIAction actionWithHandler:^(__kindof UIAction *action) { + [weakChild onPress]; + }]; +} + +- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView + menuConfigurationForTextItem:(UITextItem *)textItem + defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + UIMenu *menu = [child contextMenu]; + return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; +} + - (void)textViewDidChangeSelection:(UITextView *)textView { if (_suppressSelectionChange) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h index b8b4065711062111b713267a3ab99f3a172fe1c9..a3b2b419135a93f4f4647c2318fa78459c2cd113 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *text; +- (nullable UIMenu *)contextMenu; +- (BOOL)hasContextMenu; +- (void)onContextMenuAction:(NSString *)actionIdentifier; - (void)onPress; - (void)onLongPress; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm index 4549084f03f67536e08019d6992b41d5a2ddb220..d2de6884396f2e1a291203a1d2692d78cb48de6e 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -15,8 +15,7 @@ using namespace facebook::react; @implementation T3MarkdownTextRun { NSString * _text; - RCTBubblingEventBlock _onPress; - RCTBubblingEventBlock _onLongPress; + NSString * _contextMenuConfig; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -43,9 +42,78 @@ using namespace facebook::react; _text = text; } + if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { + _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; + } + [super updateProps:props oldProps:oldProps]; } +- (BOOL)hasContextMenu +{ + return _contextMenuConfig.length > 0; +} + +- (nullable UIMenu *)contextMenu +{ + if (_contextMenuConfig.length == 0) { + return nil; + } + + NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![config isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSArray *actionConfigs = config[@"actions"]; + if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { + return nil; + } + + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; + __weak T3MarkdownTextRun *weakSelf = self; + for (NSDictionary *actionConfig in actionConfigs) { + if (![actionConfig isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString *actionIdentifier = actionConfig[@"id"]; + NSString *title = actionConfig[@"title"]; + if (![actionIdentifier isKindOfClass:[NSString class]] || + ![title isKindOfClass:[NSString class]]) { + continue; + } + + UIAction *action = [UIAction actionWithTitle:title + image:nil + identifier:actionIdentifier + handler:^(__kindof UIAction *selectedAction) { + [weakSelf onContextMenuAction:selectedAction.identifier]; + }]; + if ([actionConfig[@"disabled"] boolValue]) { + action.attributes = UIMenuElementAttributesDisabled; + } + [actions addObject:action]; + } + + if (actions.count == 0) { + return nil; + } + NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; + return [UIMenu menuWithTitle:title children:actions]; +} + +- (void)onContextMenuAction:(NSString *)actionIdentifier +{ + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ + static_cast(self.tag), + actionIdentifier.UTF8String, + }); + } +} + - (void)onPress { if (_eventEmitter != nullptr) { std::dynamic_pointer_cast(_eventEmitter) diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 6ed7fecd2d31ca177d22f5f9d14b0dd14d262f53..2cd54b5c1ef2edfb7ed539d57b9d7e58b034cb1e 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -24,8 +24,14 @@ export type SelectionChangeEvent = { nativeEvent: { target: number; start: number; end: number }; }; +export type ContextMenuActionEvent = { + nativeEvent: { target: number; actionIdentifier: string }; +}; + export type MarkdownTextPrimitiveProps = TextProps & { uiTextView?: boolean; + contextMenuConfig?: string; + onContextMenuAction?: (event: ContextMenuActionEvent) => void; /** * Fired when the native text selection changes. Only fires on iOS when * `uiTextView` is true. Note: fires on every selection-edge adjustment diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 994c8ce2ed5a112c8980ad3b9d0fa9c702df669a..ea4bd0f2488289818ed02833c1c2e75599e586f5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -1,9 +1,23 @@ +import { createContext, useContext } from "react"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; -import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; +import type { + MarkdownFileContextMenu, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +export interface MarkdownFileContextMenuHandlers { + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + +/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ +export const MarkdownFileContextMenuContext = createContext( + null, +); const EXTERNAL_LINK_PREFIX = "◉ "; const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; @@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: { readonly onLinkPress?: (href: string) => void; }) { const colorScheme = useColorScheme(); + const menu = useContext(MarkdownFileContextMenuContext); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: { > {keyedRuns.map(({ key, run, text }) => { const href = run.href; + const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) + : undefined + } > {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 188a45e07322c381192237a18df7ddc4bffe4904..2a231c6035840a458aa90ce714b5cf333e10f03f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -9,7 +9,11 @@ import { nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import { + MarkdownFileContextMenuContext, + NativeMarkdownSelectableText, + type MarkdownFileContextMenuHandlers, +} from "./NativeMarkdownSelectableText.ios"; import type { SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -38,6 +42,8 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + fileContextMenu, + onFileContextMenuAction, renderImage, marginTop = 0, marginBottom = 0, @@ -61,41 +67,51 @@ export function SelectableMarkdownText({ ); }, [markdown, preserveSoftBreaks, skills]); + const fileContextMenuHandlers = useMemo( + () => + fileContextMenu && onFileContextMenuAction + ? { fileContextMenu, onFileContextMenuAction } + : null, + [fileContextMenu, onFileContextMenuAction], + ); + return ( - {/* A percentage width here creates a cyclic intrinsic measurement inside + + {/* A percentage width here creates a cyclic intrinsic measurement inside shrink-to-fit containers such as user-message bubbles. Yoga then gives the native text node an unbounded second pass and the parent only clips the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + ); - - return ( - - {content} - - ); - })} - + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 00260b0c4f27276eb65cc3ab5a4198d86e8ba748..50b1cccb66023e1c937320bc247e7c7b67ce26f0 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -50,6 +50,17 @@ export interface MarkdownImageRequest { */ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; +export interface MarkdownFileContextMenuAction { + readonly id: string; + readonly title: string; + readonly disabled?: boolean; +} + +export interface MarkdownFileContextMenu { + readonly title?: string; + readonly actions: ReadonlyArray; +} + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction?: (href: string, actionId: string) => void; readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts index 7f8fab8d8440d74e0c9bde455cd078057ffe74bf..040e44bc18b5f6bf35227dfb64a6a17ac361ce89 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -11,6 +11,10 @@ interface TargetedEvent { target: Int32; } +interface ContextMenuActionEvent extends TargetedEvent { + actionIdentifier: string; +} + type TextDecorationLine = "none" | "underline" | "line-through"; type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; @@ -42,8 +46,10 @@ interface NativeProps extends ViewProps { textDecorationColor?: ColorValue; textAlign?: WithDefault; shadowRadius?: WithDefault; + contextMenuConfig?: string; onPress?: BubblingEventHandler; onLongPress?: BubblingEventHandler; + onContextMenuAction?: BubblingEventHandler; } export default codegenNativeComponent("T3MarkdownTextRun", { diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index cd453b1d2c989b720298fafc59a08a33dff23f43..898cc4a16f5a860310509491b17d624d314e22ec 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => { it("rejects paths outside the workspace", () => { expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull(); expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); }); }); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index f2d377de126f4f5289956539dc4f85ff18d97c6b..2598b58d7c9b0229c7f5d83ef123820c0a5ad650 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath( return null; } - return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); + const relativePath = normalizedTarget.slice(normalizedRoot.length + 1); + // `/repo/../x` starts with the root but escapes it. + if (relativePath.split("/").includes("..")) { + return null; + } + return normalizeRelativePath(relativePath); } export function isVideoPreviewFile(path: string): boolean { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e515d6f72efdb762b7aecabfa9b864c6903b6dda..1b7cb5f373ecc75a9d8bad0aabbec1f228e80f15 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, @@ -176,6 +177,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout @@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: { ); } +/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ +interface MarkdownLinkHandlers { + readonly onLinkPress: (href: string) => void; + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; @@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { markdown={markdown} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -1465,7 +1474,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; - readonly onMarkdownLinkPress: (href: string) => void; + readonly markdownLinkHandlers: MarkdownLinkHandlers; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; @@ -1573,7 +1582,7 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} - onLinkPress={props.onMarkdownLinkPress} + linkHandlers={props.markdownLinkHandlers} renderImage={props.renderMarkdownImage} /> ) : null} @@ -1634,7 +1643,7 @@ function renderFeedEntry( ; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); @@ -1717,7 +1726,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ); @@ -1759,7 +1768,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const markdownLinkHandlers = useMemo( + () => ({ + onLinkPress: onMarkdownLinkPress, + fileContextMenu: (href) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + return target ? fileChipMenu(target) : undefined; + }, + onFileContextMenuAction: (href, actionId) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + if (!target) return; + switch (actionId as FileChipAction) { + case "copy-full-path": + if (target.fullPath) copyTextWithHaptic(target.fullPath); + return; + case "copy-relative-path": + if (target.relativePath) copyTextWithHaptic(target.relativePath); + return; + case "open-file": + onMarkdownLinkPress(href); + return; + } + }, + }), + [onMarkdownLinkPress, props.workspaceRoot], + ); const renderMarkdownImage = useCallback( (image) => { const media = resolveMarkdownMediaPreview(image.href, { @@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressPreview, onPressVideo, - onMarkdownLinkPress, + markdownLinkHandlers, renderMarkdownImage, renderViewedImage, iconSubtleColor, @@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, userBubbleMaxWidth, onCopyWorkRow, - onMarkdownLinkPress, + markdownLinkHandlers, onPressPreview, onPressVideo, onToggleTurnFold, diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb9bad3a41959a0ddb734c9fbe8cf19606a460e2 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; + +describe("resolveFileChipTarget", () => { + it("resolves a workspace-relative link to both paths", () => { + expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({ + fullPath: "/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("keeps only the full path for a host file outside the workspace", () => { + expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({ + fullPath: "/tmp/report.md", + }); + }); + + it("keeps only the relative path when the workspace root is unknown", () => { + expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); + }); + + it("ignores links that are not files or cannot be opened", () => { + expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull(); + expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull(); + expect(resolveFileChipTarget("../other/file.ts", "/repo")).toBeNull(); + }); +}); + +describe("fileChipMenu", () => { + it("offers only the copies the target can satisfy", () => { + expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({ + title: "/tmp/report.md", + actions: [ + { id: "copy-full-path", title: "Copy full path" }, + { id: "open-file", title: "Open in file viewer" }, + ], + }); + expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([ + "copy-relative-path", + "open-file", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts new file mode 100644 index 0000000000000000000000000000000000000000..3630a62b3551b9257cd93457b0d24adccec25d76 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -0,0 +1,49 @@ +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; + +import { + isAbsolutePath, + resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; + +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; + +export interface FileChipTarget { + /** The host path, when the link is absolute or the workspace root is known. */ + readonly fullPath?: string; + /** The path inside the workspace, when the link resolves there. */ + readonly relativePath?: string; +} + +/** Null when the link is not a file or resolves nowhere the feed can open, such as `~/x` or `../x`. */ +export function resolveFileChipTarget( + href: string, + workspaceRoot: string | null | undefined, +): FileChipTarget | null { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind !== "file") return null; + const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); + const fullPath = isAbsolutePath(presentation.path) + ? presentation.path + : workspaceRoot && relativePath + ? resolveWorkspaceFilePath(workspaceRoot, relativePath) + : undefined; + if (!fullPath && !relativePath) return null; + return { + ...(fullPath ? { fullPath } : {}), + ...(relativePath ? { relativePath } : {}), + }; +} + +/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { + return { + title: target.fullPath ?? target.relativePath ?? "", + actions: [ + ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), + ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), + { id: "open-file", title: "Open in file viewer" }, + ], + }; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 7c2c037eed33693d97ee2cce77f80673feb21d34..55c2f818b3d7ae04e8a0a2460e58e64116e933fa 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 7ee4d21b15603789e5d7850a724bf6eacd98168e..8b54df001aefa7a8433a30f295c138bec7a0cf39 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/docs/user/composer.md b/docs/user/composer.md index 326eb59ce0b0383115370e18f4869a3e5ce526fa..eea5a658a3373853ea3c306b45835ed03cbf637c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file viewer from the same menu. Saving downloads a copy only when you request it; it does not change -how the video buffers during playback. +how the video buffers during playback. On iOS, touch and hold a file reference in a message to +copy its full or relative path or open it in the file viewer. Use Markdown image syntax to embed either kind of media: -- 2.54.0