| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import type { MenuAction } from "@react-native-menu/menu"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 5 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 5 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| ... | ... | @@ -11,9 +12,10 @@ import { |
| 11 | 12 | ThreadId, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | |
| 14 | | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 15 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 15 | 16 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 18 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 17 | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 18 | 20 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 19 | 21 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| ... | ... | @@ -473,6 +475,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 473 | 475 | const navigation = useNavigation(); |
| 474 | 476 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 475 | 477 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 478 | const isAndroid = Platform.OS === "android"; |
| 476 | 479 | const params = props.route.params; |
| 477 | 480 | const relativePath = normalizeRoutePath(params.path); |
| 478 | 481 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); |
| ... | ... | @@ -554,6 +557,91 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 554 | 557 | ); |
| 555 | 558 | useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); |
| 556 | 559 | |
| 560 | const fileMenuActions = useMemo(() => { |
| 561 | if (relativePath === null) return []; |
| 562 | const canToggleMode = canPreview && !isImageFile; |
| 563 | return [ |
| 564 | canToggleMode |
| 565 | ? ({ |
| 566 | id: "preview", |
| 567 | title: "Preview", |
| 568 | icon: "eye", |
| 569 | inline: true, |
| 570 | onPress: () => setModeOverride({ path: relativePath, mode: "preview" }), |
| 571 | } as const) |
| 572 | : null, |
| 573 | canToggleMode |
| 574 | ? ({ |
| 575 | id: "source", |
| 576 | title: "Source", |
| 577 | icon: "doc.text", |
| 578 | inline: true, |
| 579 | onPress: () => setModeOverride({ path: relativePath, mode: "source" }), |
| 580 | } as const) |
| 581 | : null, |
| 582 | { |
| 583 | id: "copy-path", |
| 584 | title: "Copy path", |
| 585 | icon: "doc.on.doc", |
| 586 | inline: false, |
| 587 | onPress: () => copyTextWithHaptic(relativePath), |
| 588 | } as const, |
| 589 | isBrowserFile && typeof assetPreviewUri === "string" |
| 590 | ? ({ |
| 591 | id: "open-browser", |
| 592 | title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser", |
| 593 | icon: "safari", |
| 594 | inline: false, |
| 595 | onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), |
| 596 | } as const) |
| 597 | : null, |
| 598 | resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) |
| 599 | ? ({ |
| 600 | id: "refresh", |
| 601 | title: "Refresh", |
| 602 | icon: "arrow.clockwise", |
| 603 | inline: false, |
| 604 | onPress: () => setPreviewRevision((current) => current + 1), |
| 605 | } as const) |
| 606 | : null, |
| 607 | ].filter((action) => action !== null); |
| 608 | }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); |
| 609 | |
| 610 | const androidFileMenuActions = useMemo<MenuAction[]>( |
| 611 | () => |
| 612 | fileMenuActions.map((action) => ({ |
| 613 | id: action.id, |
| 614 | title: action.title, |
| 615 | image: action.icon, |
| 616 | state: action.id === resolvedActiveMode ? "on" : undefined, |
| 617 | })), |
| 618 | [fileMenuActions, resolvedActiveMode], |
| 619 | ); |
| 620 | const handleAndroidFileMenuAction = useCallback( |
| 621 | (event: { nativeEvent: { event: string } }) => { |
| 622 | const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event); |
| 623 | void action?.onPress(); |
| 624 | }, |
| 625 | [fileMenuActions], |
| 626 | ); |
| 627 | const handleReturnToThread = useCallback(() => { |
| 628 | if (environmentId !== null && threadId !== null) { |
| 629 | navigation.dispatch( |
| 630 | StackActions.replace("Thread", { |
| 631 | environmentId: String(environmentId), |
| 632 | threadId: String(threadId), |
| 633 | }), |
| 634 | ); |
| 635 | } |
| 636 | }, [environmentId, navigation, threadId]); |
| 637 | const handleBack = useCallback(() => { |
| 638 | if (navigation.canGoBack()) { |
| 639 | navigation.goBack(); |
| 640 | return; |
| 641 | } |
| 642 | handleReturnToThread(); |
| 643 | }, [handleReturnToThread, navigation]); |
| 644 | |
| 557 | 645 | if (selectedThread === null || environmentId === null || threadId === null) { |
| 558 | 646 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; |
| 559 | 647 | } |
| ... | ... | @@ -582,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 582 | 670 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid |
| 583 | 671 | // sheet-colored header — this route's content scrolls internally, so |
| 584 | 672 | // there is nothing for glass to sample). Only dynamic values here. |
| 673 | headerShown: !isAndroid, |
| 585 | 674 | headerTintColor: iconColor, |
| 586 | 675 | headerTitle: basename(relativePath), |
| 587 | 676 | title: basename(relativePath), |
| ... | ... | @@ -589,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 589 | 678 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 590 | 679 | }} |
| 591 | 680 | /> |
| 681 | {isAndroid ? ( |
| 682 | <AndroidScreenHeader |
| 683 | title={basename(relativePath)} |
| 684 | subtitle={headerSubtitle} |
| 685 | onBack={handleBack} |
| 686 | trailing={ |
| 687 | <> |
| 688 | {fileInspector.supported ? ( |
| 689 | <AndroidHeaderIconButton |
| 690 | accessibilityLabel={ |
| 691 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" |
| 692 | } |
| 693 | icon="sidebar.right" |
| 694 | onPress={toggleAuxiliaryPane} |
| 695 | /> |
| 696 | ) : null} |
| 697 | <ControlPillMenu |
| 698 | actions={androidFileMenuActions} |
| 699 | isAnchoredToRight |
| 700 | title="File actions" |
| 701 | onPressAction={handleAndroidFileMenuAction} |
| 702 | > |
| 703 | <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" /> |
| 704 | </ControlPillMenu> |
| 705 | </> |
| 706 | } |
| 707 | /> |
| 708 | ) : null} |
| 592 | 709 | <WorkspaceSidebarToolbar> |
| 593 | 710 | {fileInspector.supported ? ( |
| 594 | 711 | <NativeHeaderToolbar.Button |
| 595 | 712 | accessibilityLabel="Return to chat" |
| 596 | 713 | icon="chevron.left" |
| 597 | | onPress={() => { |
| 598 | | navigation.dispatch( |
| 599 | | StackActions.replace("Thread", { |
| 600 | | environmentId: String(environmentId), |
| 601 | | threadId: String(threadId), |
| 602 | | }), |
| 603 | | ); |
| 604 | | }} |
| 714 | onPress={handleReturnToThread} |
| 605 | 715 | /> |
| 606 | 716 | ) : null} |
| 607 | 717 | </WorkspaceSidebarToolbar> |
| ... | ... | @@ -617,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 617 | 727 | /> |
| 618 | 728 | ) : null} |
| 619 | 729 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> |
| 620 | | {canPreview && !isImageFile ? ( |
| 730 | {fileMenuActions.some(({ inline }) => inline) ? ( |
| 621 | 731 | <NativeHeaderToolbar.Menu inline> |
| 732 | {fileMenuActions |
| 733 | .filter(({ inline }) => inline) |
| 734 | .map((action) => ( |
| 735 | <NativeHeaderToolbar.MenuAction |
| 736 | key={action.id} |
| 737 | icon={action.icon} |
| 738 | isOn={action.id === resolvedActiveMode} |
| 739 | onPress={action.onPress} |
| 740 | > |
| 741 | {action.title} |
| 742 | </NativeHeaderToolbar.MenuAction> |
| 743 | ))} |
| 744 | </NativeHeaderToolbar.Menu> |
| 745 | ) : null} |
| 746 | {fileMenuActions |
| 747 | .filter(({ inline }) => !inline) |
| 748 | .map((action) => ( |
| 622 | 749 | <NativeHeaderToolbar.MenuAction |
| 623 | | icon="eye" |
| 624 | | isOn={resolvedActiveMode === "preview"} |
| 625 | | onPress={() => setModeOverride({ path: relativePath, mode: "preview" })} |
| 626 | | > |
| 627 | | Preview |
| 628 | | </NativeHeaderToolbar.MenuAction> |
| 629 | | <NativeHeaderToolbar.MenuAction |
| 630 | | icon="doc.text" |
| 631 | | isOn={resolvedActiveMode === "source"} |
| 632 | | onPress={() => setModeOverride({ path: relativePath, mode: "source" })} |
| 750 | key={action.id} |
| 751 | icon={action.icon} |
| 752 | onPress={action.onPress} |
| 633 | 753 | > |
| 634 | | Source |
| 754 | {action.title} |
| 635 | 755 | </NativeHeaderToolbar.MenuAction> |
| 636 | | </NativeHeaderToolbar.Menu> |
| 637 | | ) : null} |
| 638 | | <NativeHeaderToolbar.MenuAction |
| 639 | | icon="doc.on.doc" |
| 640 | | onPress={() => copyTextWithHaptic(relativePath)} |
| 641 | | > |
| 642 | | Copy path |
| 643 | | </NativeHeaderToolbar.MenuAction> |
| 644 | | {isBrowserFile && typeof assetPreviewUri === "string" ? ( |
| 645 | | <NativeHeaderToolbar.MenuAction |
| 646 | | icon="safari" |
| 647 | | onPress={() => { |
| 648 | | void tryOpenExternalUrl(assetPreviewUri, "file-preview"); |
| 649 | | }} |
| 650 | | > |
| 651 | | Open in Safari |
| 652 | | </NativeHeaderToolbar.MenuAction> |
| 653 | | ) : null} |
| 654 | | {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( |
| 655 | | <NativeHeaderToolbar.MenuAction |
| 656 | | icon="arrow.clockwise" |
| 657 | | onPress={() => { |
| 658 | | setPreviewRevision((current) => current + 1); |
| 659 | | }} |
| 660 | | > |
| 661 | | Refresh |
| 662 | | </NativeHeaderToolbar.MenuAction> |
| 663 | | ) : null} |
| 756 | ))} |
| 664 | 757 | </NativeHeaderToolbar.Menu> |
| 665 | 758 | </NativeHeaderToolbar> |
| 666 | 759 | <FileContent |