| <script lang="ts"> |
| import { toast } from 'svelte-sonner'; |
| import { getContext, tick, onDestroy } from 'svelte'; |
| import { formatFileSize } from '$lib/utils'; |
| import type { FileEntry } from '$lib/apis/terminal'; |
|
|
| import Dropdown from '$lib/components/common/Dropdown.svelte'; |
| import Folder from '../../icons/Folder.svelte'; |
| import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte'; |
| import GarbageBin from '../../icons/GarbageBin.svelte'; |
| import Pencil from '../../icons/Pencil.svelte'; |
| import Clipboard from '../../icons/Clipboard.svelte'; |
|
|
| const i18n = getContext('i18n'); |
|
|
| export let entry: FileEntry; |
| export let currentPath: string; |
| export let terminalUrl: string = ''; |
| export let terminalKey: string = ''; |
|
|
| export let onOpen: (entry: FileEntry) => void = () => {}; |
| export let onDownload: (path: string) => void = () => {}; |
| export let onDelete: (path: string, name: string) => void = () => {}; |
| export let onMove: (source: string, destFolder: string) => void = () => {}; |
| export let onRename: (oldPath: string, newName: string) => void = () => {}; |
|
|
| |
| export let selected: boolean = false; |
| export let selectionMode: boolean = false; |
| export let selectedPaths: Set<string> = new Set(); |
| export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {}; |
| export let onLongPress: () => void = () => {}; |
| export let showDate: boolean = false; |
|
|
| const formatRelativeTime = (epoch: number): string => { |
| const diff = Math.floor(Date.now() / 1000) - epoch; |
| if (diff < 60) return 'just now'; |
| if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; |
| if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; |
| if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`; |
| if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`; |
| return `${Math.floor(diff / 31536000)}y ago`; |
| }; |
|
|
| let dragOverFolder = false; |
|
|
| |
| let renaming = false; |
| let renameValue = ''; |
| let renameInput: HTMLInputElement; |
|
|
| const startRename = async () => { |
| renameValue = entry.name; |
| renaming = true; |
| await tick(); |
| renameInput?.focus(); |
| if (entry.type === 'file') { |
| const dotIdx = entry.name.lastIndexOf('.'); |
| renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length); |
| } else { |
| renameInput?.select(); |
| } |
| }; |
|
|
| const submitRename = () => { |
| const newName = renameValue.trim(); |
| renaming = false; |
| if (!newName || newName === entry.name) return; |
| onRename(`${currentPath}${entry.name}`, newName); |
| }; |
|
|
| const cancelRename = () => { |
| renaming = false; |
| renameValue = ''; |
| }; |
|
|
| |
| let longPressTimer: ReturnType<typeof setTimeout> | null = null; |
| let didLongPress = false; |
|
|
| const onPointerDown = (e: PointerEvent) => { |
| if (e.pointerType !== 'touch') return; |
| didLongPress = false; |
| longPressTimer = setTimeout(() => { |
| didLongPress = true; |
| onLongPress(); |
| onSelect(entry, e as any); |
| }, 500); |
| }; |
|
|
| const onPointerUp = () => { |
| if (longPressTimer) { |
| clearTimeout(longPressTimer); |
| longPressTimer = null; |
| } |
| }; |
|
|
| const onPointerCancel = () => { |
| if (longPressTimer) { |
| clearTimeout(longPressTimer); |
| longPressTimer = null; |
| } |
| }; |
|
|
| onDestroy(() => { |
| if (longPressTimer) clearTimeout(longPressTimer); |
| }); |
|
|
| |
| const handleClick = (e: MouseEvent) => { |
| if (renaming) return; |
| if (didLongPress) { |
| didLongPress = false; |
| return; |
| } |
|
|
| |
| if (e.metaKey || e.ctrlKey || e.shiftKey) { |
| e.preventDefault(); |
| onSelect(entry, e); |
| return; |
| } |
|
|
| |
| if (selectionMode) { |
| onSelect(entry, e); |
| return; |
| } |
|
|
| |
| onOpen(entry); |
| }; |
| </script> |
|
|
| <li class="group"> |
| <div |
| class="w-full flex items-center transition |
| {selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'} |
| {dragOverFolder |
| ? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset' |
| : ''}" |
| role={entry.type === 'directory' ? 'button' : undefined} |
| on:dragover={(e) => { |
| if (entry.type !== 'directory') return; |
| if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| dragOverFolder = true; |
| }} |
| on:dragleave={(e) => { |
| if (entry.type !== 'directory') return; |
| e.stopPropagation(); |
| dragOverFolder = false; |
| }} |
| on:drop={(e) => { |
| if (entry.type !== 'directory') return; |
| const raw = e.dataTransfer?.getData('application/x-terminal-file-move'); |
| if (!raw) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| dragOverFolder = false; |
| try { |
| const data = JSON.parse(raw); |
| const paths = data.paths || (data.path ? [data.path] : []); |
| const destFolder = `${currentPath}${entry.name}/`; |
| for (const p of paths) { |
| if (p + '/' === destFolder || p === destFolder) continue; |
| onMove(p, destFolder); |
| } |
| } catch {} |
| }} |
| > |
| <button |
| class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0" |
| draggable={true} |
| on:dragstart={(e) => { |
| const filePath = `${currentPath}${entry.name}`; |
| // If dragging a selected item, drag all selected |
| if (selected && selectedPaths.size > 1) { |
| e.dataTransfer?.setData( |
| 'application/x-terminal-file-move', |
| JSON.stringify({ paths: [...selectedPaths] }) |
| ); |
| // Custom drag ghost showing count |
| const ghost = document.createElement('div'); |
| ghost.style.cssText = |
| 'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:8px;background:#374151;color:#fff;font-size:12px;white-space:nowrap;pointer-events:none;'; |
| ghost.textContent = `${selectedPaths.size} items`; |
| document.body.appendChild(ghost); |
| e.dataTransfer?.setDragImage(ghost, 0, 0); |
| requestAnimationFrame(() => ghost.remove()); |
| } else { |
| e.dataTransfer?.setData( |
| 'application/x-terminal-file-move', |
| JSON.stringify({ path: filePath, name: entry.name }) |
| ); |
| } |
| if (entry.type === 'file') { |
| e.dataTransfer?.setData( |
| 'application/x-terminal-file', |
| JSON.stringify({ |
| path: filePath, |
| name: entry.name, |
| url: terminalUrl, |
| key: terminalKey |
| }) |
| ); |
| } |
| }} |
| on:pointerdown={onPointerDown} |
| on:pointerup={onPointerUp} |
| on:pointercancel={onPointerCancel} |
| on:click={handleClick} |
| on:dblclick|preventDefault|stopPropagation={() => { |
| startRename(); |
| }} |
| > |
| {#if selectionMode || selected} |
| |
| <div |
| class="size-3.5 shrink-0 rounded border transition-colors flex items-center justify-center |
| {selected |
| ? 'bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 text-white' |
| : 'border-gray-300 dark:border-gray-600'}" |
| > |
| {#if selected} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 20 20" |
| fill="currentColor" |
| class="size-2.5" |
| > |
| <path |
| fill-rule="evenodd" |
| d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| {/if} |
| </div> |
| {/if} |
| {#if entry.type === 'directory'} |
| <Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" /> |
| {:else} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| stroke-width="1.5" |
| class="size-4 shrink-0 text-gray-400" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" |
| /> |
| </svg> |
| {/if} |
| {#if renaming} |
| |
| <input |
| bind:this={renameInput} |
| bind:value={renameValue} |
| class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0" |
| on:keydown={(e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| submitRename(); |
| } |
| if (e.key === 'Escape') { |
| e.preventDefault(); |
| cancelRename(); |
| } |
| }} |
| on:blur={submitRename} |
| on:click|stopPropagation |
| /> |
| {:else} |
| <span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate"> |
| {entry.name} |
| </span> |
| {/if} |
| {#if entry.type === 'file' && entry.size !== undefined && !renaming} |
| {#if showDate && entry.modified} |
| <span class="text-[10px] text-gray-400 shrink-0" |
| >{formatRelativeTime(entry.modified)}</span |
| > |
| {/if} |
| <span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span> |
| {:else if entry.type === 'directory' && showDate && entry.modified && !renaming} |
| <span class="text-[10px] text-gray-400 shrink-0">{formatRelativeTime(entry.modified)}</span> |
| {/if} |
| </button> |
| |
| <Dropdown align="end" sideOffset={4}> |
| <button |
| class="shrink-0 p-0.5 mr-1 rounded-lg transition |
| text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400 |
| hover:bg-gray-100 dark:hover:bg-gray-800" |
| aria-label={$i18n.t('More')} |
| > |
| <EllipsisHorizontal className="size-3.5" /> |
| </button> |
| |
| <div slot="content"> |
| <div |
| class="min-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800" |
| > |
| <button |
| type="button" |
| class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm" |
| on:click={(e) => { |
| e.stopPropagation(); |
| const path = |
| entry.type === 'directory' |
| ? `${currentPath}${entry.name}/` |
| : `${currentPath}${entry.name}`; |
| onDownload(path); |
| }} |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 20 20" |
| fill="currentColor" |
| class="size-4" |
| > |
| <path |
| d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z" |
| /> |
| <path |
| d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z" |
| /> |
| </svg> |
| <div class="flex items-center">{$i18n.t('Download')}</div> |
| </button> |
| |
| <button |
| type="button" |
| class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm" |
| on:click={(e) => { |
| e.stopPropagation(); |
| const path = |
| entry.type === 'directory' |
| ? `${currentPath}${entry.name}/` |
| : `${currentPath}${entry.name}`; |
| navigator.clipboard.writeText(path).then(() => { |
| toast.success($i18n.t('Path copied')); |
| }); |
| }} |
| > |
| <Clipboard className="size-4" strokeWidth="1.5" /> |
| <div class="flex items-center">{$i18n.t('Copy Path')}</div> |
| </button> |
| |
| <button |
| type="button" |
| class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm" |
| on:click={(e) => { |
| e.stopPropagation(); |
| startRename(); |
| }} |
| > |
| <Pencil className="size-4" strokeWidth="1.5" /> |
| <div class="flex items-center">{$i18n.t('Rename')}</div> |
| </button> |
| |
| <button |
| type="button" |
| class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm" |
| on:click={(e) => { |
| e.stopPropagation(); |
| onDelete(`${currentPath}${entry.name}`, entry.name); |
| }} |
| > |
| <GarbageBin className="size-4" /> |
| <div class="flex items-center">{$i18n.t('Delete')}</div> |
| </button> |
| </div> |
| </div> |
| </Dropdown> |
| </div> |
| </li> |
| |