mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar
This commit is contained in:
parent
c49a47e6bc
commit
5d78d66b00
7 changed files with 359 additions and 60 deletions
|
|
@ -5,12 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, Paperclip, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
|
||||
import { isImageMime } from '@/lib/file-utils'
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
|
|
@ -1659,6 +1659,7 @@ function App() {
|
|||
filename: attachment.filename,
|
||||
mediaType: attachment.mediaType,
|
||||
size: attachment.size,
|
||||
thumbnailUrl: attachment.thumbnailUrl,
|
||||
}))
|
||||
: undefined
|
||||
setConversation((prev) => [...prev, {
|
||||
|
|
@ -2951,24 +2952,12 @@ function App() {
|
|||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{item.attachments.map((attachment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-muted text-muted-foreground px-2 py-1 rounded-md"
|
||||
>
|
||||
{isImageMime(attachment.mediaType) ? (
|
||||
<span className="size-3 rounded bg-primary/20" />
|
||||
) : (
|
||||
<Paperclip className="size-3" />
|
||||
)}
|
||||
{attachment.filename}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{item.content}
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,27 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowUp, LoaderIcon, Paperclip, Plus, Square, X } from 'lucide-react'
|
||||
import {
|
||||
ArrowUp,
|
||||
AudioLines,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
FileIcon,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
LoaderIcon,
|
||||
Plus,
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
getAttachmentIconKind,
|
||||
getAttachmentToneClass,
|
||||
getAttachmentTypeLabel,
|
||||
} from '@/lib/attachment-presentation'
|
||||
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
|
|
@ -25,6 +45,25 @@ export type StagedAttachment = {
|
|||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
return AudioLines
|
||||
case 'video':
|
||||
return FileVideo
|
||||
case 'spreadsheet':
|
||||
return FileSpreadsheet
|
||||
case 'archive':
|
||||
return FileArchive
|
||||
case 'code':
|
||||
return FileCode2
|
||||
case 'text':
|
||||
return FileText
|
||||
default:
|
||||
return FileIcon
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onStop?: () => void
|
||||
|
|
@ -53,6 +92,7 @@ function ChatInputInner({
|
|||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
|
||||
const [focusNonce, setFocusNonce] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||
|
||||
|
|
@ -102,6 +142,7 @@ function ChatInputInner({
|
|||
}
|
||||
if (newAttachments.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...newAttachments])
|
||||
setFocusNonce((value) => value + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -157,27 +198,45 @@ function ChatInputInner({
|
|||
return (
|
||||
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||
<img src={attachment.thumbnailUrl} alt="" className="size-4 rounded object-cover" />
|
||||
) : (
|
||||
<Paperclip className="size-3" />
|
||||
)}
|
||||
<span className="max-w-[120px] truncate">{attachment.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
className="transition-colors hover:text-foreground"
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
|
||||
return (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||
attachment.isImage && attachment.thumbnailUrl
|
||||
? 'bg-muted'
|
||||
: getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<Icon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-4 py-4">
|
||||
|
|
@ -210,7 +269,7 @@ function ChatInputInner({
|
|||
placeholder="Type your message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={isActive}
|
||||
focusTrigger={isActive ? runId : undefined}
|
||||
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
|
||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isProcessing ? (
|
||||
|
|
|
|||
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
AudioLines,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
FileIcon,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { MessageAttachment } from '@/lib/chat-conversation'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
getAttachmentIconKind,
|
||||
getAttachmentToneClass,
|
||||
getAttachmentTypeLabel,
|
||||
} from '@/lib/attachment-presentation'
|
||||
import { isImageMime, toFileUrl } from '@/lib/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
return AudioLines
|
||||
case 'video':
|
||||
return FileVideo
|
||||
case 'spreadsheet':
|
||||
return FileSpreadsheet
|
||||
case 'archive':
|
||||
return FileArchive
|
||||
case 'code':
|
||||
return FileCode2
|
||||
case 'text':
|
||||
return FileText
|
||||
default:
|
||||
return FileIcon
|
||||
}
|
||||
}
|
||||
|
||||
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
|
||||
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
|
||||
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
|
||||
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
|
||||
|
||||
useEffect(() => {
|
||||
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
|
||||
setSrc(nextSrc)
|
||||
setTriedBase64(Boolean(attachment.thumbnailUrl))
|
||||
}, [attachment.thumbnailUrl, fallbackFileUrl])
|
||||
|
||||
const loadBase64 = useMemo(
|
||||
() => async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
|
||||
const mimeType = result.mimeType || attachment.mediaType || 'image/*'
|
||||
setSrc(`data:${mimeType};base64,${result.data}`)
|
||||
} catch {
|
||||
// Keep current src; fallback rendering (broken image icon) is better than crashing.
|
||||
}
|
||||
},
|
||||
[attachment.mediaType, attachment.path]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (attachment.thumbnailUrl || triedBase64) return
|
||||
setTriedBase64(true)
|
||||
void loadBase64()
|
||||
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Image attachment"
|
||||
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
|
||||
onError={() => {
|
||||
if (triedBase64) return
|
||||
setTriedBase64(true)
|
||||
void loadBase64()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatMessageAttachmentsProps {
|
||||
attachments: MessageAttachment[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||
if (attachments.length === 0) return null
|
||||
|
||||
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mediaType))
|
||||
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mediaType))
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{fileAttachments.map((attachment, index) => {
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
return (
|
||||
<span
|
||||
key={`${attachment.path}-${index}`}
|
||||
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
|
||||
title={attachmentName}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-12 shrink-0 items-center justify-center rounded-xl',
|
||||
getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Maximize2, Minimize2, Paperclip, SquarePen } from 'lucide-react'
|
||||
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { isImageMime } from '@/lib/file-utils'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
|
|
@ -27,6 +26,7 @@ import { FileCardProvider } from '@/contexts/file-card-context'
|
|||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatTabViewState,
|
||||
|
|
@ -260,24 +260,12 @@ export function ChatSidebar({
|
|||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{item.attachments.map((attachment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{isImageMime(attachment.mediaType) ? (
|
||||
<span className="size-3 rounded bg-primary/20" />
|
||||
) : (
|
||||
<Paperclip className="size-3" />
|
||||
)}
|
||||
{attachment.filename}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{item.content}
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { getExtension } from '@/lib/file-utils'
|
||||
|
||||
export type AttachmentLike = {
|
||||
filename?: string
|
||||
path: string
|
||||
mediaType: string
|
||||
}
|
||||
|
||||
export type AttachmentIconKind =
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'spreadsheet'
|
||||
| 'archive'
|
||||
| 'code'
|
||||
| 'text'
|
||||
| 'file'
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||
])
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
'csv', 'tsv', 'xls', 'xlsx',
|
||||
])
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
|
||||
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
|
||||
])
|
||||
|
||||
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
|
||||
if (attachment.filename) return attachment.filename
|
||||
const fromPath = attachment.path.split(/[\\/]/).pop()
|
||||
return fromPath || attachment.path
|
||||
}
|
||||
|
||||
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
|
||||
const ext = getExtension(getAttachmentDisplayName(attachment))
|
||||
if (ext) return ext.toUpperCase()
|
||||
|
||||
const mediaType = attachment.mediaType.toLowerCase()
|
||||
if (mediaType.startsWith('audio/')) return 'AUDIO'
|
||||
if (mediaType.startsWith('video/')) return 'VIDEO'
|
||||
if (mediaType.startsWith('text/')) return 'TEXT'
|
||||
if (mediaType.startsWith('image/')) return 'IMAGE'
|
||||
|
||||
const [, subtypeRaw = 'file'] = mediaType.split('/')
|
||||
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
|
||||
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
|
||||
return cleaned ? cleaned.toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
|
||||
const mediaType = attachment.mediaType.toLowerCase()
|
||||
const ext = getExtension(attachment.filename || attachment.path)
|
||||
|
||||
if (mediaType.startsWith('audio/')) return 'audio'
|
||||
if (mediaType.startsWith('video/')) return 'video'
|
||||
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
|
||||
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
|
||||
if (
|
||||
mediaType.includes('json')
|
||||
|| mediaType.includes('javascript')
|
||||
|| mediaType.includes('typescript')
|
||||
|| mediaType.includes('xml')
|
||||
|| CODE_EXTENSIONS.has(ext)
|
||||
) {
|
||||
return 'code'
|
||||
}
|
||||
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function getAttachmentToneClass(typeLabel: string): string {
|
||||
switch (typeLabel) {
|
||||
case 'PDF':
|
||||
return 'bg-red-500 text-white'
|
||||
case 'CSV':
|
||||
case 'XLS':
|
||||
case 'XLSX':
|
||||
case 'TSV':
|
||||
return 'bg-emerald-500 text-white'
|
||||
case 'ZIP':
|
||||
case 'RAR':
|
||||
case '7Z':
|
||||
case 'TAR':
|
||||
case 'GZ':
|
||||
return 'bg-amber-500 text-white'
|
||||
case 'MP3':
|
||||
case 'WAV':
|
||||
case 'M4A':
|
||||
case 'FLAC':
|
||||
case 'AAC':
|
||||
return 'bg-fuchsia-500 text-white'
|
||||
case 'MP4':
|
||||
case 'MOV':
|
||||
case 'AVI':
|
||||
case 'WEBM':
|
||||
return 'bg-violet-500 text-white'
|
||||
default:
|
||||
return 'bg-primary/85 text-primary-foreground'
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export interface MessageAttachment {
|
|||
filename: string
|
||||
mediaType: string
|
||||
size?: number
|
||||
thumbnailUrl?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
|
|||
|
|
@ -41,3 +41,21 @@ export function getExtension(filePath: string): string {
|
|||
const dotIndex = name.lastIndexOf('.');
|
||||
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
if (
|
||||
filePath.startsWith('data:') ||
|
||||
filePath.startsWith('file://') ||
|
||||
filePath.startsWith('http://') ||
|
||||
filePath.startsWith('https://')
|
||||
) {
|
||||
return filePath;
|
||||
}
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const encoded = encodeURI(normalized);
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return `file:///${encoded}`;
|
||||
}
|
||||
return `file://${encoded}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue