mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(app): improve image picker, markdown rendering, and UI interactions
This commit is contained in:
@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
|||||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||||
import {
|
import {
|
||||||
deleteAttachments,
|
deleteAttachments,
|
||||||
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from '@/attachments/service'
|
} from '@/attachments/service'
|
||||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||||
@@ -390,16 +391,24 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
async function handlePickImage() {
|
async function handlePickImage() {
|
||||||
const result = await pickImages()
|
const result = await pickImages()
|
||||||
if (!result?.assets?.length) {
|
if (!result?.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newImages = await Promise.all(
|
const newImages = await Promise.all(
|
||||||
result.assets.map(async (asset) => {
|
result.map(async (pickedImage) => {
|
||||||
|
if (pickedImage.source.kind === 'blob') {
|
||||||
|
return await persistAttachmentFromBlob({
|
||||||
|
blob: pickedImage.source.blob,
|
||||||
|
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||||
|
fileName: pickedImage.fileName ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return await persistAttachmentFromFileUri({
|
return await persistAttachmentFromFileUri({
|
||||||
uri: asset.uri,
|
uri: pickedImage.source.uri,
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||||
fileName: asset.fileName ?? null,
|
fileName: pickedImage.fileName ?? null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
isValidElement,
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } from "react";
|
import type { ReactNode, ComponentType } from "react";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
@@ -433,20 +436,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
containerSpacing: {
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
// Used in custom markdownRules for inline code styling
|
|
||||||
markdownCodeInline: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: theme.borderRadius.sm,
|
|
||||||
fontFamily: Fonts.mono,
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
markdownCodeInlineLink: {
|
|
||||||
color: theme.colors.primary,
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
// Used in custom markdownRules for path chip styling
|
// Used in custom markdownRules for path chip styling
|
||||||
pathChip: {
|
pathChip: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -463,6 +452,35 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function MarkdownLink({
|
||||||
|
href,
|
||||||
|
style,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
style: any;
|
||||||
|
onPress: (url: string) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const hoverProps =
|
||||||
|
Platform.OS === "web"
|
||||||
|
? { onHoverIn: () => setHovered(true), onHoverOut: () => setHovered(false) }
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="link"
|
||||||
|
onPress={() => onPress(href)}
|
||||||
|
{...hoverProps}
|
||||||
|
>
|
||||||
|
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getInlineCodeAutoLinkUrl(
|
function getInlineCodeAutoLinkUrl(
|
||||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||||
content: string
|
content: string
|
||||||
@@ -776,7 +794,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
node: any,
|
node: any,
|
||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
_parent: any,
|
_parent: any,
|
||||||
_styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {}
|
||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
const content = node.content ?? "";
|
||||||
@@ -803,30 +821,21 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||||
if (inlineCodeLinkUrl) {
|
if (inlineCodeLinkUrl) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<MarkdownLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
accessibilityRole="link"
|
href={inlineCodeLinkUrl}
|
||||||
onPress={() => {
|
style={[inheritedStyles, styles.code_inline, styles.link]}
|
||||||
handleLinkPress(inlineCodeLinkUrl);
|
onPress={handleLinkPress}
|
||||||
}}
|
|
||||||
style={[
|
|
||||||
inheritedStyles,
|
|
||||||
assistantMessageStylesheet.markdownCodeInline,
|
|
||||||
assistantMessageStylesheet.markdownCodeInlineLink,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</MarkdownLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={node.key}
|
key={node.key}
|
||||||
style={[
|
style={[inheritedStyles, styles.code_inline]}
|
||||||
inheritedStyles,
|
|
||||||
assistantMessageStylesheet.markdownCodeInline,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -877,6 +886,25 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
link: (
|
||||||
|
node: any,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
) => (
|
||||||
|
<MarkdownLink
|
||||||
|
key={node.key}
|
||||||
|
href={node.attributes?.href ?? ""}
|
||||||
|
style={styles.link}
|
||||||
|
onPress={handleLinkPress}
|
||||||
|
>
|
||||||
|
{Children.map(children, (child) =>
|
||||||
|
isValidElement(child)
|
||||||
|
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
|
||||||
|
: child
|
||||||
|
)}
|
||||||
|
</MarkdownLink>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
archiveStatus?: 'idle' | 'pending' | 'success'
|
archiveStatus?: 'idle' | 'pending' | 'success'
|
||||||
archivePendingLabel?: string
|
archivePendingLabel?: string
|
||||||
onArchive?: () => void
|
onArchive?: () => void
|
||||||
|
onCopyBranchName?: () => void
|
||||||
onCopyPath?: () => void
|
onCopyPath?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +613,7 @@ function WorkspaceRowInner({
|
|||||||
archiveStatus = 'idle',
|
archiveStatus = 'idle',
|
||||||
archivePendingLabel,
|
archivePendingLabel,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onCopyBranchName,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
@@ -696,6 +698,15 @@ function WorkspaceRowInner({
|
|||||||
Copy path
|
Copy path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||||
|
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
Copy branch name
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||||
@@ -733,6 +744,7 @@ function WorkspaceRowWithMenu({
|
|||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
canCopyBranchName,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -742,6 +754,7 @@ function WorkspaceRowWithMenu({
|
|||||||
drag: () => void
|
drag: () => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
|
canCopyBranchName: boolean
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||||
@@ -827,6 +840,11 @@ function WorkspaceRowWithMenu({
|
|||||||
toast.copied('Path copied')
|
toast.copied('Path copied')
|
||||||
}, [toast, workspace.workspaceId])
|
}, [toast, workspace.workspaceId])
|
||||||
|
|
||||||
|
const handleCopyBranchName = useCallback(() => {
|
||||||
|
void Clipboard.setStringAsync(workspace.name)
|
||||||
|
toast.copied('Branch name copied')
|
||||||
|
}, [toast, workspace.name])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowInner
|
<WorkspaceRowInner
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
@@ -843,6 +861,7 @@ function WorkspaceRowWithMenu({
|
|||||||
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||||
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||||
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1008,6 +1027,7 @@ function WorkspaceRow({
|
|||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
canCopyBranchName,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -1017,6 +1037,7 @@ function WorkspaceRow({
|
|||||||
drag: () => void
|
drag: () => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
|
canCopyBranchName: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
@@ -1028,6 +1049,7 @@ function WorkspaceRow({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
|
canCopyBranchName={canCopyBranchName}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1117,7 @@ function ProjectBlock({
|
|||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
|
canCopyBranchName={project.projectKind === 'git'}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
type Ref,
|
||||||
|
type MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -182,6 +184,16 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref && typeof ref === "object") {
|
||||||
|
(ref as MutableRefObject<T>).current = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ContextMenu({
|
export function ContextMenu({
|
||||||
open,
|
open,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
@@ -231,6 +243,7 @@ export function ContextMenuTrigger({
|
|||||||
enabledOnMobile = false,
|
enabledOnMobile = false,
|
||||||
enabledOnWeb = true,
|
enabledOnWeb = true,
|
||||||
longPressDelayMs,
|
longPressDelayMs,
|
||||||
|
triggerRef,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
Omit<PressableProps, "style"> & {
|
Omit<PressableProps, "style"> & {
|
||||||
@@ -239,6 +252,7 @@ export function ContextMenuTrigger({
|
|||||||
enabledOnMobile?: boolean;
|
enabledOnMobile?: boolean;
|
||||||
enabledOnWeb?: boolean;
|
enabledOnWeb?: boolean;
|
||||||
longPressDelayMs?: number;
|
longPressDelayMs?: number;
|
||||||
|
triggerRef?: Ref<View | null>;
|
||||||
}
|
}
|
||||||
>): ReactElement {
|
>): ReactElement {
|
||||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||||
@@ -268,10 +282,18 @@ export function ContextMenuTrigger({
|
|||||||
[ctx, disabled, shouldEnableOnThisPlatform]
|
[ctx, disabled, shouldEnableOnThisPlatform]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRef = useCallback(
|
||||||
|
(node: View | null) => {
|
||||||
|
assignRef(ctx.triggerRef, node);
|
||||||
|
assignRef(triggerRef, node);
|
||||||
|
},
|
||||||
|
[ctx.triggerRef, triggerRef]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...props}
|
{...props}
|
||||||
ref={ctx.triggerRef}
|
ref={handleRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
delayLongPress={longPressDelayMs}
|
delayLongPress={longPressDelayMs}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
createContext,
|
createContext,
|
||||||
|
isValidElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -8,6 +11,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
type Ref,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -53,6 +57,26 @@ function useTooltipContext(componentName: string): TooltipContextValue {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composeEventHandlers<E>(
|
||||||
|
original?: (event: E) => void,
|
||||||
|
injected?: (event: E) => void
|
||||||
|
): (event: E) => void {
|
||||||
|
return (event: E) => {
|
||||||
|
original?.(event);
|
||||||
|
injected?.(event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref && typeof ref === "object") {
|
||||||
|
(ref as { current: T }).current = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function useControllableOpenState({
|
function useControllableOpenState({
|
||||||
open,
|
open,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
@@ -224,8 +248,15 @@ export function TooltipTrigger({
|
|||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onPress,
|
onPress,
|
||||||
|
asChild = false,
|
||||||
|
triggerRefProp = "ref",
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<PressableProps>): ReactElement {
|
}: PropsWithChildren<
|
||||||
|
PressableProps & {
|
||||||
|
asChild?: boolean;
|
||||||
|
triggerRefProp?: string;
|
||||||
|
}
|
||||||
|
>): ReactElement {
|
||||||
const ctx = useTooltipContext("TooltipTrigger");
|
const ctx = useTooltipContext("TooltipTrigger");
|
||||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -276,37 +307,86 @@ export function TooltipTrigger({
|
|||||||
[onHoverOut, close]
|
[onHoverOut, close]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
onFocus?.(e);
|
||||||
|
if (!ctx.enabled || disabled) return;
|
||||||
|
clearOpenTimer();
|
||||||
|
ctx.setOpen(true);
|
||||||
|
},
|
||||||
|
[clearOpenTimer, ctx, disabled, onFocus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
onBlur?.(e);
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
[close, onBlur]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
onPress?.(e);
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
[close, onPress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerProps = {
|
||||||
|
...props,
|
||||||
|
disabled,
|
||||||
|
onHoverIn: handleHoverIn,
|
||||||
|
onHoverOut: handleHoverOut,
|
||||||
|
onFocus: handleFocus,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
onPress: handlePress,
|
||||||
|
...(Platform.OS === "web"
|
||||||
|
? ({
|
||||||
|
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||||
|
onPointerEnter: handleHoverIn,
|
||||||
|
onPointerLeave: handleHoverOut,
|
||||||
|
onMouseEnter: handleHoverIn,
|
||||||
|
onMouseLeave: handleHoverOut,
|
||||||
|
} as any)
|
||||||
|
: null),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (asChild) {
|
||||||
|
const child = Children.only(children);
|
||||||
|
if (!isValidElement(child)) {
|
||||||
|
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||||
|
}
|
||||||
|
|
||||||
|
const childProps = child.props as Record<string, any>;
|
||||||
|
const mergedProps = {
|
||||||
|
...childProps,
|
||||||
|
...triggerProps,
|
||||||
|
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
|
||||||
|
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
|
||||||
|
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
|
||||||
|
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
|
||||||
|
onPress: composeEventHandlers(childProps.onPress, handlePress),
|
||||||
|
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
|
||||||
|
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
|
||||||
|
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
|
||||||
|
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
|
||||||
|
} as Record<string, any>;
|
||||||
|
|
||||||
|
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||||
|
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||||
|
assignRef(existingRefProp, node);
|
||||||
|
assignRef(ctx.triggerRef, node);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cloneElement(child, mergedProps);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...props}
|
{...triggerProps}
|
||||||
ref={ctx.triggerRef}
|
ref={ctx.triggerRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled}
|
|
||||||
onHoverIn={handleHoverIn}
|
|
||||||
onHoverOut={handleHoverOut}
|
|
||||||
onFocus={(e) => {
|
|
||||||
onFocus?.(e);
|
|
||||||
if (!ctx.enabled || disabled) return;
|
|
||||||
clearOpenTimer();
|
|
||||||
ctx.setOpen(true);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
onBlur?.(e);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
onPress={(e) => {
|
|
||||||
onPress?.(e);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
{...(Platform.OS === "web"
|
|
||||||
? ({
|
|
||||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
|
||||||
onPointerEnter: handleHoverIn,
|
|
||||||
onPointerLeave: handleHoverOut,
|
|
||||||
onMouseEnter: handleHoverIn,
|
|
||||||
onMouseLeave: handleHoverOut,
|
|
||||||
} as any)
|
|
||||||
: null)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
107
packages/app/src/hooks/image-attachment-picker.test.ts
Normal file
107
packages/app/src/hooks/image-attachment-picker.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const tauriState = vi.hoisted(() => ({
|
||||||
|
api: null as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/tauri", () => ({
|
||||||
|
getTauri: () => tauriState.api,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizePickedImageAssets,
|
||||||
|
openImagePathsWithTauriDialog,
|
||||||
|
} from "./image-attachment-picker";
|
||||||
|
|
||||||
|
describe("image-attachment-picker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tauriState.api = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a picked File into a blob source", async () => {
|
||||||
|
const file = new File(["hello"], "picked.png", { type: "image/png" });
|
||||||
|
|
||||||
|
const result = await normalizePickedImageAssets([
|
||||||
|
{
|
||||||
|
uri: "blob:test",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: null,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.source.kind).toBe("blob");
|
||||||
|
expect(result[0]?.fileName).toBe("picked.png");
|
||||||
|
expect(result[0]?.mimeType).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps filesystem picker results as file uris", async () => {
|
||||||
|
const result = await normalizePickedImageAssets([
|
||||||
|
{
|
||||||
|
uri: "file:///tmp/picked.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "picked.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
source: { kind: "file_uri", uri: "file:///tmp/picked.png" },
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "picked.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts data urls into blob sources when no file path exists", async () => {
|
||||||
|
const result = await normalizePickedImageAssets([
|
||||||
|
{
|
||||||
|
uri: "data:image/png;base64,AAEC",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "inline.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.source.kind).toBe("blob");
|
||||||
|
expect(result[0]?.fileName).toBe("inline.png");
|
||||||
|
expect(result[0]?.mimeType).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the tauri dialog api when available", async () => {
|
||||||
|
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||||
|
tauriState.api = {
|
||||||
|
dialog: { open },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await openImagePathsWithTauriDialog();
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
multiple: true,
|
||||||
|
directory: false,
|
||||||
|
title: "Attach images",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to core invoke for the tauri dialog plugin", async () => {
|
||||||
|
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
|
||||||
|
tauriState.api = {
|
||||||
|
core: { invoke },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await openImagePathsWithTauriDialog();
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
|
||||||
|
options: expect.objectContaining({
|
||||||
|
multiple: true,
|
||||||
|
directory: false,
|
||||||
|
title: "Attach images",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(result).toEqual(["/tmp/one.png"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
packages/app/src/hooks/image-attachment-picker.ts
Normal file
110
packages/app/src/hooks/image-attachment-picker.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
|
export type PickedImageSource =
|
||||||
|
| { kind: "file_uri"; uri: string }
|
||||||
|
| { kind: "blob"; blob: Blob };
|
||||||
|
|
||||||
|
export interface PickedImageAttachmentInput {
|
||||||
|
source: PickedImageSource;
|
||||||
|
mimeType?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpoImagePickerAssetLike {
|
||||||
|
uri: string;
|
||||||
|
mimeType?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
file?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_FILE_EXTENSIONS = [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"avif",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"tiff",
|
||||||
|
"bmp",
|
||||||
|
"svg",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAbsoluteWindowsPath(value: string): boolean {
|
||||||
|
return /^[a-zA-Z]:[\\/]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldTreatAsFileUri(uri: string): boolean {
|
||||||
|
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobFromUri(uri: string): Promise<Blob> {
|
||||||
|
const response = await fetch(uri);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read picked image from '${uri}'.`);
|
||||||
|
}
|
||||||
|
return await response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizePickedImageAssets(
|
||||||
|
assets: readonly ExpoImagePickerAssetLike[]
|
||||||
|
): Promise<PickedImageAttachmentInput[]> {
|
||||||
|
return await Promise.all(
|
||||||
|
assets.map(async (asset) => {
|
||||||
|
if (asset.file instanceof Blob) {
|
||||||
|
return {
|
||||||
|
source: { kind: "blob", blob: asset.file },
|
||||||
|
mimeType: asset.mimeType ?? asset.file.type ?? null,
|
||||||
|
fileName: asset.fileName ?? asset.file.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldTreatAsFileUri(asset.uri)) {
|
||||||
|
return {
|
||||||
|
source: { kind: "file_uri", uri: asset.uri },
|
||||||
|
mimeType: asset.mimeType ?? null,
|
||||||
|
fileName: asset.fileName ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
|
||||||
|
mimeType: asset.mimeType ?? null,
|
||||||
|
fileName: asset.fileName ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
|
||||||
|
if (!selection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(selection) ? selection : [selection];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
||||||
|
const tauri = getTauri();
|
||||||
|
const options = {
|
||||||
|
directory: false,
|
||||||
|
multiple: true,
|
||||||
|
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
|
||||||
|
title: "Attach images",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogOpen = tauri?.dialog?.open;
|
||||||
|
if (typeof dialogOpen === "function") {
|
||||||
|
return normalizeTauriDialogSelection(await dialogOpen(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoke = tauri?.core?.invoke;
|
||||||
|
if (typeof invoke !== "function") {
|
||||||
|
throw new Error("Tauri dialog API is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await invoke("plugin:dialog|open", { options });
|
||||||
|
return normalizeTauriDialogSelection(
|
||||||
|
Array.isArray(result) || typeof result === "string" || result === null ? result : null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { isTauriEnvironment } from "@/utils/tauri";
|
||||||
type ImagePickerSuccessResult = ImagePicker.ImagePickerSuccessResult;
|
import {
|
||||||
|
normalizePickedImageAssets,
|
||||||
|
openImagePathsWithTauriDialog,
|
||||||
|
type PickedImageAttachmentInput,
|
||||||
|
} from "@/hooks/image-attachment-picker";
|
||||||
|
|
||||||
interface UseImageAttachmentPickerResult {
|
interface UseImageAttachmentPickerResult {
|
||||||
pickImages: () => Promise<ImagePickerSuccessResult | null>;
|
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||||
@@ -37,6 +42,18 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
isPickingRef.current = true;
|
isPickingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (Platform.OS === "web" && isTauriEnvironment()) {
|
||||||
|
const selectedPaths = await openImagePathsWithTauriDialog();
|
||||||
|
if (selectedPaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return selectedPaths.map((path) => ({
|
||||||
|
source: { kind: "file_uri" as const, uri: path },
|
||||||
|
mimeType: null,
|
||||||
|
fileName: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const hasPermission = await ensurePermission();
|
const hasPermission = await ensurePermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return null;
|
return null;
|
||||||
@@ -44,7 +61,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
|
|
||||||
const pendingResult = await ImagePicker.getPendingResultAsync();
|
const pendingResult = await ImagePicker.getPendingResultAsync();
|
||||||
if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) {
|
if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) {
|
||||||
return pendingResult as ImagePickerSuccessResult;
|
return await normalizePickedImageAssets(pendingResult.assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
@@ -57,7 +74,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return await normalizePickedImageAssets(result.assets);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ImageAttachmentPicker] Failed to pick image:", error);
|
console.error("[ImageAttachmentPicker] Failed to pick image:", error);
|
||||||
Alert.alert("Error", "Failed to select image");
|
Alert.alert("Error", "Failed to select image");
|
||||||
|
|||||||
@@ -156,8 +156,6 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const layoutItem = layout.items[index] ?? null;
|
const layoutItem = layout.items[index] ?? null;
|
||||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||||
const showLabel = layoutItem?.showLabel ?? true;
|
const showLabel = layoutItem?.showLabel ?? true;
|
||||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
|
||||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
|
||||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||||
const tooltipLabel =
|
const tooltipLabel =
|
||||||
tab.kind === "agent" && tab.titleState === "loading"
|
tab.kind === "agent" && tab.titleState === "loading"
|
||||||
@@ -172,11 +170,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
return (
|
return (
|
||||||
<ContextMenu key={tab.key}>
|
<ContextMenu key={tab.key}>
|
||||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||||
testID={`workspace-tab-tooltip-${tab.key}`}
|
|
||||||
accessibilityRole="none"
|
|
||||||
style={styles.tabTooltipTrigger}
|
|
||||||
>
|
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
testID={`workspace-tab-${tab.key}`}
|
testID={`workspace-tab-${tab.key}`}
|
||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
@@ -228,9 +222,11 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
isActive && styles.tabLabelActive,
|
isActive && styles.tabLabelActive,
|
||||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||||
]}
|
]}
|
||||||
|
selectable={false}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
>
|
>
|
||||||
{renderedLabel}
|
{tab.label}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -411,9 +407,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
tabTooltipTrigger: {
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
tab: {
|
tab: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -421,6 +414,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
tabHandle: {
|
tabHandle: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -428,6 +422,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
tabIcon: {
|
tabIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -444,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
tabLabelSkeleton: {
|
tabLabelSkeleton: {
|
||||||
width: 96,
|
width: 96,
|
||||||
|
|||||||
@@ -344,6 +344,10 @@ function WorkspaceScreenContent({
|
|||||||
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
||||||
|
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||||
|
const currentBranchName =
|
||||||
|
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||||
|
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||||
|
: null;
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore(
|
const desktopFileExplorerOpen = usePanelStore(
|
||||||
(state) => state.desktop.fileExplorerOpen
|
(state) => state.desktop.fileExplorerOpen
|
||||||
@@ -1025,6 +1029,20 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
}, [normalizedWorkspaceId, toast]);
|
}, [normalizedWorkspaceId, toast]);
|
||||||
|
|
||||||
|
const handleCopyBranchName = useCallback(async () => {
|
||||||
|
if (!currentBranchName) {
|
||||||
|
toast.error("Branch name not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Clipboard.setStringAsync(currentBranchName);
|
||||||
|
toast.copied("Branch name");
|
||||||
|
} catch {
|
||||||
|
toast.error("Copy failed");
|
||||||
|
}
|
||||||
|
}, [currentBranchName, toast]);
|
||||||
|
|
||||||
const handleBulkCloseTabs = useCallback(
|
const handleBulkCloseTabs = useCallback(
|
||||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
||||||
const { tabsToClose, title, logLabel } = input;
|
const { tabsToClose, title, logLabel } = input;
|
||||||
@@ -1389,6 +1407,20 @@ function WorkspaceScreenContent({
|
|||||||
>
|
>
|
||||||
Copy workspace path
|
Copy workspace path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{currentBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-header-copy-branch-name"
|
||||||
|
leading={
|
||||||
|
<Copy
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onSelect={handleCopyBranchName}
|
||||||
|
>
|
||||||
|
Copy branch name
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
40
packages/app/src/styles/markdown-styles.test.ts
Normal file
40
packages/app/src/styles/markdown-styles.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMarkdownStyles } from "./markdown-styles";
|
||||||
|
import { darkTheme } from "./theme";
|
||||||
|
|
||||||
|
describe("createMarkdownStyles", () => {
|
||||||
|
it("applies shrink-and-wrap constraints to long markdown text and links", () => {
|
||||||
|
const styles = createMarkdownStyles(darkTheme);
|
||||||
|
|
||||||
|
expect(styles.body).toMatchObject({
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styles.paragraph).toMatchObject({
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
width: "100%",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styles.text).toMatchObject({
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styles.link).toMatchObject({
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styles.blocklink).toMatchObject({
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,10 +18,16 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
width: "100%" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
text: {
|
text: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
paragraph: {
|
paragraph: {
|
||||||
@@ -31,6 +37,9 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
flexDirection: "row" as const,
|
flexDirection: "row" as const,
|
||||||
alignItems: "flex-start" as const,
|
alignItems: "flex-start" as const,
|
||||||
justifyContent: "flex-start" as const,
|
justifyContent: "flex-start" as const,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
width: "100%" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -117,13 +126,19 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
link: {
|
link: {
|
||||||
color: theme.colors.primary,
|
color: theme.colors.accentBright,
|
||||||
textDecorationLine: "underline" as const,
|
textDecorationLine: "none" as const,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
blocklink: {
|
blocklink: {
|
||||||
color: theme.colors.primary,
|
color: theme.colors.accentBright,
|
||||||
textDecorationLine: "underline" as const,
|
textDecorationLine: "none" as const,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "anywhere" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -135,7 +150,8 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: theme.borderRadius.sm,
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 0,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const lightSemanticColors = {
|
|||||||
|
|
||||||
// Brand
|
// Brand
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
|
accentBright: "#239956",
|
||||||
accentForeground: "#ffffff",
|
accentForeground: "#ffffff",
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
@@ -192,6 +193,7 @@ const darkSemanticColors = {
|
|||||||
|
|
||||||
// Brand
|
// Brand
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
|
accentBright: "#7ccba0",
|
||||||
accentForeground: "#ffffff",
|
accentForeground: "#ffffff",
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
|
|||||||
59
packages/app/src/utils/tauri-daemon-transport.test.ts
Normal file
59
packages/app/src/utils/tauri-daemon-transport.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const tauriMock = vi.hoisted(() => {
|
||||||
|
let listenerCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const connect = vi.fn(async () => {
|
||||||
|
let listenerActive = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
addListener: vi.fn(() => {
|
||||||
|
listenerActive = true;
|
||||||
|
listenerCleanup = vi.fn(() => {
|
||||||
|
if (!listenerActive) {
|
||||||
|
throw new TypeError(
|
||||||
|
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
listenerActive = false;
|
||||||
|
});
|
||||||
|
return listenerCleanup;
|
||||||
|
}),
|
||||||
|
send: vi.fn(async () => undefined),
|
||||||
|
disconnect: vi.fn(async () => {
|
||||||
|
listenerCleanup?.();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
getListenerCleanup: () => listenerCleanup,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./tauri", () => ({
|
||||||
|
getTauri: () => ({
|
||||||
|
websocket: {
|
||||||
|
connect: tauriMock.connect,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("tauri-daemon-transport", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tauriMock.connect.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unregister the websocket listener twice during close", async () => {
|
||||||
|
const mod = await import("./tauri-daemon-transport");
|
||||||
|
const transportFactory = mod.createTauriWebSocketTransportFactory();
|
||||||
|
expect(transportFactory).not.toBeNull();
|
||||||
|
|
||||||
|
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(() => transport.close()).not.toThrow();
|
||||||
|
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -229,14 +229,10 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
|
|||||||
return {
|
return {
|
||||||
...transport,
|
...transport,
|
||||||
close: (code?: number, reason?: string) => {
|
close: (code?: number, reason?: string) => {
|
||||||
|
wsListenerCleanup = null;
|
||||||
transport.close(code, reason);
|
transport.close(code, reason);
|
||||||
disposed = true;
|
disposed = true;
|
||||||
try {
|
ws = null;
|
||||||
wsListenerCleanup?.();
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
wsListenerCleanup = null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,22 @@ export interface TauriDialogAskOptions {
|
|||||||
kind?: "info" | "warning" | "error";
|
kind?: "info" | "warning" | "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TauriDialogOpenOptions {
|
||||||
|
title?: string;
|
||||||
|
defaultPath?: string;
|
||||||
|
directory?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
filters?: Array<{
|
||||||
|
name: string;
|
||||||
|
extensions: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TauriDialogApi {
|
export interface TauriDialogApi {
|
||||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||||
|
open?: (
|
||||||
|
options?: TauriDialogOpenOptions
|
||||||
|
) => Promise<string | string[] | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TauriCoreApi {
|
export interface TauriCoreApi {
|
||||||
|
|||||||
Reference in New Issue
Block a user