| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 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 | import { ActivityIndicator, Platform, View } from "react-native"; | 5 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| ... | @@ -11,9 +12,10 @@ import { | ... | @@ -11,9 +12,10 @@ import { |
| 11 | ThreadId, | 12 | ThreadId, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 13 | | 14 | |
| 14 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 15 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 15 | import { SymbolView } from "../../components/AppSymbol"; | 16 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | 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"; |
| 17 | import { EmptyState } from "../../components/EmptyState"; | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 18 | import { LoadingScreen } from "../../components/LoadingScreen"; | 20 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 19 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; | 21 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| ... | @@ -473,6 +475,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -473,6 +475,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 473 | const navigation = useNavigation(); | 475 | const navigation = useNavigation(); |
| 474 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | 476 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 475 | const iconColor = useUniwindTheme()["--color-icon"]; | 477 | const iconColor = useUniwindTheme()["--color-icon"]; |
| | 478 | const isAndroid = Platform.OS === "android"; |
| 476 | const params = props.route.params; | 479 | const params = props.route.params; |
| 477 | const relativePath = normalizeRoutePath(params.path); | 480 | const relativePath = normalizeRoutePath(params.path); |
| 478 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); | 481 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); |
| ... | @@ -554,6 +557,91 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -554,6 +557,91 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 554 | ); | 557 | ); |
| 555 | useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); | 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 | if (selectedThread === null || environmentId === null || threadId === null) { | 645 | if (selectedThread === null || environmentId === null || threadId === null) { |
| 558 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; | 646 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; |
| 559 | } | 647 | } |
| ... | @@ -582,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -582,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 582 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid | 670 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid |
| 583 | // sheet-colored header — this route's content scrolls internally, so | 671 | // sheet-colored header — this route's content scrolls internally, so |
| 584 | // there is nothing for glass to sample). Only dynamic values here. | 672 | // there is nothing for glass to sample). Only dynamic values here. |
| | 673 | headerShown: !isAndroid, |
| 585 | headerTintColor: iconColor, | 674 | headerTintColor: iconColor, |
| 586 | headerTitle: basename(relativePath), | 675 | headerTitle: basename(relativePath), |
| 587 | title: basename(relativePath), | 676 | title: basename(relativePath), |
| ... | @@ -589,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -589,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 589 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, | 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 | <WorkspaceSidebarToolbar> | 709 | <WorkspaceSidebarToolbar> |
| 593 | {fileInspector.supported ? ( | 710 | {fileInspector.supported ? ( |
| 594 | <NativeHeaderToolbar.Button | 711 | <NativeHeaderToolbar.Button |
| 595 | accessibilityLabel="Return to chat" | 712 | accessibilityLabel="Return to chat" |
| 596 | icon="chevron.left" | 713 | icon="chevron.left" |
| 597 | onPress={() => { | 714 | onPress={handleReturnToThread} |
| 598 | navigation.dispatch( | | |
| 599 | StackActions.replace("Thread", { | | |
| 600 | environmentId: String(environmentId), | | |
| 601 | threadId: String(threadId), | | |
| 602 | }), | | |
| 603 | ); | | |
| 604 | }} | | |
| 605 | /> | 715 | /> |
| 606 | ) : null} | 716 | ) : null} |
| 607 | </WorkspaceSidebarToolbar> | 717 | </WorkspaceSidebarToolbar> |
| ... | @@ -617,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -617,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 617 | /> | 727 | /> |
| 618 | ) : null} | 728 | ) : null} |
| 619 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> | 729 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> |
| 620 | {canPreview && !isImageFile ? ( | 730 | {fileMenuActions.some(({ inline }) => inline) ? ( |
| 621 | <NativeHeaderToolbar.Menu inline> | 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 | <NativeHeaderToolbar.MenuAction | 749 | <NativeHeaderToolbar.MenuAction |
| 623 | icon="eye" | 750 | key={action.id} |
| 624 | isOn={resolvedActiveMode === "preview"} | 751 | icon={action.icon} |
| 625 | onPress={() => setModeOverride({ path: relativePath, mode: "preview" })} | 752 | onPress={action.onPress} |
| 626 | > | | |
| 627 | Preview | | |
| 628 | </NativeHeaderToolbar.MenuAction> | | |
| 629 | <NativeHeaderToolbar.MenuAction | | |
| 630 | icon="doc.text" | | |
| 631 | isOn={resolvedActiveMode === "source"} | | |
| 632 | onPress={() => setModeOverride({ path: relativePath, mode: "source" })} | | |
| 633 | > | 753 | > |
| 634 | Source | 754 | {action.title} |
| 635 | </NativeHeaderToolbar.MenuAction> | 755 | </NativeHeaderToolbar.MenuAction> |
| 636 | </NativeHeaderToolbar.Menu> | 756 | ))} |
| 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} | | |
| 664 | </NativeHeaderToolbar.Menu> | 757 | </NativeHeaderToolbar.Menu> |
| 665 | </NativeHeaderToolbar> | 758 | </NativeHeaderToolbar> |
| 666 | <FileContent | 759 | <FileContent |