feat: add text truncation hook and marquee support (#217)
Introduce a hook to detect text truncation for dynamic UI updates Replace static marquee spans with a reusable marquee component in files and labels Enable copying of terminal selection on mouse up and touch end
This commit is contained in:
committed by
GitHub
parent
f34027df10
commit
efe9ad27b7
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
export const FileAttachmentButton = memo(() => {
|
||||
@@ -125,6 +126,21 @@ interface FileChipProps {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const TruncatedMarquee = memo(({ text, title }: { text: string; title?: string }) => {
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const isTruncated = useIsTextTruncated(labelRef, [text]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn('marquee-text', isTruncated && 'marquee-text--active')}
|
||||
title={title ?? text}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const getFileIcon = () => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
@@ -169,9 +185,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
</div>
|
||||
{getFileIcon()}
|
||||
<div className="overflow-hidden max-w-[200px]">
|
||||
<span className="marquee-text" title={file.serverPath || displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
<TruncatedMarquee text={displayName} title={file.serverPath || displayName} />
|
||||
</div>
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
({formatFileSize(file.size)})
|
||||
@@ -292,9 +306,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
|
||||
>
|
||||
{getFileIcon(file.mime)}
|
||||
<div className="overflow-hidden max-w-[200px]">
|
||||
<span className="marquee-text">
|
||||
{extractFilename(file.filename)}
|
||||
</span>
|
||||
<TruncatedMarquee text={extractFilename(file.filename)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = ComponentType<any>;
|
||||
@@ -1083,6 +1084,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
return getModelDisplayName(currentModel);
|
||||
};
|
||||
|
||||
const currentModelDisplayName = getCurrentModelDisplayName();
|
||||
const modelLabelRef = React.useRef<HTMLSpanElement>(null);
|
||||
const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]);
|
||||
|
||||
const getAgentDisplayName = () => {
|
||||
if (!uiAgentName) {
|
||||
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
|
||||
@@ -2122,19 +2127,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
) : (
|
||||
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
|
||||
)}
|
||||
<span
|
||||
key={`${currentProviderId}-${currentModelId}`}
|
||||
className={cn(
|
||||
'model-controls__model-label overflow-hidden',
|
||||
controlTextSize,
|
||||
'font-medium whitespace-nowrap text-foreground min-w-0',
|
||||
'max-w-[260px]'
|
||||
)}
|
||||
>
|
||||
<span className="marquee-text">
|
||||
{getCurrentModelDisplayName()}
|
||||
<span
|
||||
ref={modelLabelRef}
|
||||
key={`${currentProviderId}-${currentModelId}`}
|
||||
className={cn(
|
||||
'model-controls__model-label overflow-hidden',
|
||||
controlTextSize,
|
||||
'font-medium whitespace-nowrap text-foreground min-w-0',
|
||||
'max-w-[260px]'
|
||||
)}
|
||||
>
|
||||
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
|
||||
{currentModelDisplayName}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -2246,13 +2252,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
|
||||
)}
|
||||
<span
|
||||
ref={modelLabelRef}
|
||||
className={cn(
|
||||
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
|
||||
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
|
||||
)}
|
||||
>
|
||||
<span className="marquee-text">
|
||||
{getCurrentModelDisplayName()}
|
||||
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
|
||||
{currentModelDisplayName}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -122,6 +122,55 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copySelectionToClipboard = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const text = selection.toString();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.anchorNode;
|
||||
const focusNode = selection.focusNode;
|
||||
if (anchorNode && !container.contains(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
if (focusNode && !container.contains(focusNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to execCommand
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetWriteState = React.useCallback(() => {
|
||||
pendingWriteRef.current = '';
|
||||
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
|
||||
@@ -782,6 +831,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
void copySelectionToClipboard();
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
void copySelectionToClipboard();
|
||||
}}
|
||||
>
|
||||
{enableTouchScroll ? (
|
||||
<textarea
|
||||
|
||||
@@ -49,6 +49,7 @@ type GitViewSnapshot = {
|
||||
directory?: string;
|
||||
selectedPaths: string[];
|
||||
commitMessage: string;
|
||||
generatedHighlights: string[];
|
||||
};
|
||||
|
||||
type GitmojiEntry = {
|
||||
@@ -179,7 +180,7 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
|
||||
return null;
|
||||
};
|
||||
|
||||
let gitViewSnapshot: GitViewSnapshot | null = null;
|
||||
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
||||
|
||||
const useEffectiveDirectory = () => {
|
||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||
@@ -226,9 +227,8 @@ export const GitView: React.FC = () => {
|
||||
} = useGitStore();
|
||||
|
||||
const initialSnapshot = React.useMemo(() => {
|
||||
if (!gitViewSnapshot) return null;
|
||||
if (gitViewSnapshot.directory !== currentDirectory) return null;
|
||||
return gitViewSnapshot;
|
||||
if (!currentDirectory) return null;
|
||||
return gitViewSnapshots.get(currentDirectory) ?? null;
|
||||
}, [currentDirectory]);
|
||||
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
@@ -273,7 +273,9 @@ export const GitView: React.FC = () => {
|
||||
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||
initialSnapshot?.generatedHighlights ?? []
|
||||
);
|
||||
const clearGeneratedHighlights = React.useCallback(() => {
|
||||
setGeneratedHighlights([]);
|
||||
}, []);
|
||||
@@ -346,19 +348,14 @@ export const GitView: React.FC = () => {
|
||||
}, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (!currentDirectory) {
|
||||
gitViewSnapshot = null;
|
||||
return;
|
||||
}
|
||||
|
||||
gitViewSnapshot = {
|
||||
directory: currentDirectory,
|
||||
selectedPaths: Array.from(selectedPaths),
|
||||
commitMessage,
|
||||
};
|
||||
};
|
||||
}, [commitMessage, currentDirectory, selectedPaths]);
|
||||
if (!currentDirectory) return;
|
||||
gitViewSnapshots.set(currentDirectory, {
|
||||
directory: currentDirectory,
|
||||
selectedPaths: Array.from(selectedPaths),
|
||||
commitMessage,
|
||||
generatedHighlights,
|
||||
});
|
||||
}, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadProfiles();
|
||||
|
||||
@@ -44,6 +44,7 @@ const MODIFIER_ARROW_SUFFIX: Record<Modifier, string> = {
|
||||
cmd: '3',
|
||||
};
|
||||
|
||||
|
||||
const STREAM_OPTIONS = {
|
||||
retry: {
|
||||
maxRetries: 3,
|
||||
@@ -429,6 +430,7 @@ export const TerminalView: React.FC = () => {
|
||||
}
|
||||
}, [clearBuffer, effectiveDirectory, setConnectionError, terminal]);
|
||||
|
||||
|
||||
const handleViewportInput = React.useCallback(
|
||||
(data: string) => {
|
||||
if (!data) {
|
||||
|
||||
@@ -60,6 +60,15 @@ const branchToTitle = (branch: string): string => {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
type PullRequestDraftSnapshot = {
|
||||
title: string;
|
||||
body: string;
|
||||
draft: boolean;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
||||
|
||||
const openExternal = async (url: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
||||
@@ -96,14 +105,20 @@ export const PullRequestSection: React.FC<{
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSidebarSection]);
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]);
|
||||
const initialSnapshot = React.useMemo(
|
||||
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
|
||||
[snapshotKey]
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = React.useState(() => branchToTitle(branch));
|
||||
const [body, setBody] = React.useState('');
|
||||
const [draft, setDraft] = React.useState(false);
|
||||
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
||||
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
||||
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
||||
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
||||
|
||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||
@@ -359,11 +374,25 @@ export const PullRequestSection: React.FC<{
|
||||
}, [branch, canShow, directory, github]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitle(branchToTitle(branch));
|
||||
setBody('');
|
||||
setDraft(false);
|
||||
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
||||
setTitle(snapshot?.title ?? branchToTitle(branch));
|
||||
setBody(snapshot?.body ?? '');
|
||||
setDraft(snapshot?.draft ?? false);
|
||||
setIsOpen(snapshot?.isOpen ?? true);
|
||||
void refresh();
|
||||
}, [branch, refresh]);
|
||||
}, [branch, refresh, snapshotKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directory || !branch) {
|
||||
return;
|
||||
}
|
||||
pullRequestDraftSnapshots.set(snapshotKey, {
|
||||
title,
|
||||
body,
|
||||
draft,
|
||||
isOpen,
|
||||
});
|
||||
}, [snapshotKey, title, body, draft, isOpen, directory, branch]);
|
||||
|
||||
const generateDescription = React.useCallback(async () => {
|
||||
if (isGenerating) return;
|
||||
|
||||
Reference in New Issue
Block a user