This commit is contained in:
Timothy Jaeryang Baek
2026-08-13 15:20:59 -06:00
parent 5c05608e3a
commit 7dfbdd221a
5 changed files with 632 additions and 447 deletions
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,11 @@
<script lang="ts">
import { getContext } from 'svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
export let count: number = 0;
export let hasFiles: boolean = false;
export let canDelete = true;
export let onDelete: () => void = () => {};
@@ -15,81 +14,51 @@
export let onClear: () => void = () => {};
</script>
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/50 shrink-0">
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 flex-1 truncate">
<div
class="flex h-8 items-center gap-2 px-2 bg-blue-50 dark:bg-blue-500/5 shrink-0 border-t border-gray-50 dark:border-gray-850/30"
>
<span class="text-[0.6875rem] font-medium text-blue-600 dark:text-blue-400 flex-1 truncate">
{$i18n.t('{{count}} selected', { count })}
</span>
<Tooltip content={$i18n.t('Select All')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onSelectAll}
aria-label={$i18n.t('Select All')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.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>
<Icon name="check" size={12} strokeWidth={1.6} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onDownload}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<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>
<Icon name="download" size={12} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-red-500 disabled:opacity-30 disabled:hover:text-gray-400"
on:click={onDelete}
disabled={!canDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
<Icon name="trash" size={12} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Deselect')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onClear}
aria-label={$i18n.t('Deselect')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
<Icon name="xmark" size={12} strokeWidth={1.5} />
</button>
</Tooltip>
</div>
@@ -6,16 +6,18 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.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';
import FileTypeIcon from './FileTypeIcon.svelte';
import Icon from './Icon.svelte';
const i18n: any = getContext('i18n');
export let entry: FileEntry;
export let currentPath: string;
export let fullPath: string | null = null;
export let depth = 0;
export let rowIndex = 0;
export let expanded = false;
export let loadingChildren = false;
export let terminalUrl: string = '';
export let terminalKey: string = '';
@@ -29,13 +31,23 @@
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 onSelect: (
entry: FileEntry,
event: MouseEvent,
path: string,
index: number
) => void = () => {};
export let onLongPress: () => void = () => {};
export let onToggleExpand: (path: string) => void = () => {};
export let showDate: boolean = false;
export let parentWritable = true;
$: entryPath =
fullPath ?? (entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`);
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
$: rowIndent = `${12 + depth * 16}px`;
const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch;
@@ -48,6 +60,13 @@
};
let dragOverFolder = false;
let expandTimer: ReturnType<typeof setTimeout> | null = null;
const clearExpandTimer = () => {
if (!expandTimer) return;
clearTimeout(expandTimer);
expandTimer = null;
};
// ── Rename state ─────────────────────────────────────────────────────
let renaming = false;
@@ -71,7 +90,7 @@
const newName = renameValue.trim();
renaming = false;
if (!newName || newName === entry.name) return;
onRename(`${currentPath}${entry.name}`, newName);
onRename(entryPath.replace(/\/$/, ''), newName);
};
const cancelRename = () => {
@@ -89,7 +108,7 @@
longPressTimer = setTimeout(() => {
didLongPress = true;
onLongPress();
onSelect(entry, e as any);
onSelect(entry, e as any, entryPath, rowIndex);
}, 500);
};
@@ -109,6 +128,7 @@
onDestroy(() => {
if (longPressTimer) clearTimeout(longPressTimer);
clearExpandTimer();
});
// ── Click handler ────────────────────────────────────────────────────
@@ -122,13 +142,13 @@
// Modifier click → toggle/range select
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
onSelect(entry, e);
onSelect(entry, e, entryPath, rowIndex);
return;
}
// In selection mode (touch) → toggle select
if (selectionMode) {
onSelect(entry, e);
onSelect(entry, e, entryPath, rowIndex);
return;
}
@@ -137,14 +157,13 @@
};
</script>
<li class="group">
<li class="group" data-file-row>
<div
class="w-full flex items-center transition
{selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50/40 dark:hover:bg-gray-850/30'}
class="w-full flex items-center transition-colors duration-75
{selected ? 'bg-blue-50 dark:bg-blue-500/10' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
{dragOverFolder
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
? 'bg-blue-50 dark:bg-blue-500/10 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 (!writable) return;
@@ -152,11 +171,18 @@
e.preventDefault();
e.stopPropagation();
dragOverFolder = true;
if (!expanded && !expandTimer) {
expandTimer = setTimeout(() => {
onToggleExpand(directoryPath);
expandTimer = null;
}, 600);
}
}}
on:dragleave={(e) => {
if (entry.type !== 'directory') return;
e.stopPropagation();
dragOverFolder = false;
clearExpandTimer();
}}
on:drop={(e) => {
if (entry.type !== 'directory') return;
@@ -166,10 +192,11 @@
e.preventDefault();
e.stopPropagation();
dragOverFolder = false;
clearExpandTimer();
try {
const data = JSON.parse(raw);
const paths = data.paths || (data.path ? [data.path] : []);
const destFolder = `${currentPath}${entry.name}/`;
const destFolder = directoryPath;
for (const p of paths) {
if (p + '/' === destFolder || p === destFolder) continue;
onMove(p, destFolder);
@@ -177,15 +204,35 @@
} catch {}
}}
>
{#if entry.type === 'directory'}
<button
type="button"
class="mr-1 flex w-4 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
style="margin-left: {rowIndent};"
on:click|stopPropagation={() => onToggleExpand(directoryPath)}
aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={9}
strokeWidth={1.5}
class={loadingChildren ? 'animate-pulse' : ''}
/>
</button>
{:else}
<span class="mr-1 w-4 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
{/if}
<button
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
type="button"
class="flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-2 text-left"
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
e.preventDefault();
return;
}
const filePath = `${currentPath}${entry.name}`;
const filePath = entryPath.replace(/\/$/, '');
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(
@@ -203,7 +250,7 @@
} else {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
JSON.stringify({ path: entry.type === 'directory' ? directoryPath : filePath, name: entry.name })
);
}
if (entry.type === 'file') {
@@ -229,34 +276,23 @@
{#if selectionMode || selected}
<!-- Checkbox indicator -->
<div
class="size-3.5 shrink-0 rounded border transition-colors flex items-center justify-center
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>
<Icon name="check" size={10} strokeWidth={2} />
{/if}
</div>
{/if}
<FileTypeIcon name={entry.name} type={entry.type} />
<FileTypeIcon name={entry.name} type={entry.type} size={13} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<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"
class="flex-1 text-xs bg-transparent border border-gray-100 dark:border-white/[0.06] 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();
@@ -294,65 +330,79 @@
<Dropdown align="end" sideOffset={4}>
<button
class="shrink-0 p-0.5 mr-1 rounded-lg transition
class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded 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"
hover:bg-gray-50/40 dark:hover:bg-white/4"
aria-label={$i18n.t('More')}
>
<EllipsisHorizontal className="size-3.5" />
<Icon name="three-dots" size={12} strokeWidth={1.4} />
</button>
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
onDownload(path);
onOpen(entry);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<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>
<Icon name={entry.type === 'directory' ? 'folder' : 'eye'} size={12} strokeWidth={1.4} />
<div class="flex items-center">
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
</div>
</button>
{#if entry.type === 'directory'}
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
onToggleExpand(directoryPath);
}}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={12}
strokeWidth={1.4}
/>
<div class="flex items-center">
{expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
</div>
</button>
{:else}
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
onDownload(entryPath);
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
{/if}
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
navigator.clipboard.writeText(path).then(() => {
navigator.clipboard.writeText(entryPath).then(() => {
toast.success($i18n.t('Path copied'));
});
}}
>
<Clipboard className="size-3.5" strokeWidth="1.5" />
<Icon name="copy" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
@@ -360,21 +410,21 @@
startRename();
}}
>
<Pencil className="size-3.5" strokeWidth="1.5" />
<Icon name="pencil" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
onDelete(`${currentPath}${entry.name}`, entry.name);
onDelete(entryPath.replace(/\/$/, ''), entry.name);
}}
>
<GarbageBin className="size-3.5" />
<Icon name="trash" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</DropdownMenu>
@@ -20,11 +20,13 @@
export let onUploadFiles: (files: File[]) => void = () => {};
export let onDownloadDir: () => void = () => {};
export let onMove: (source: string, destFolder: string) => void = () => {};
export let showHidden = false;
export let onToggleHidden: () => void = () => {};
// Sort controls
export let sortBy: 'name' | 'date' = 'name';
export let sortBy: 'name' | 'size' | 'date' = 'name';
export let sortAsc: boolean = true;
export let onSort: (mode: 'name' | 'date') => void = () => {};
export let onSort: (mode: 'name' | 'size' | 'date') => void = () => {};
// Back / forward navigation
export let canGoBack = false;
@@ -46,45 +48,45 @@
});
</script>
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
<div class="m-0 flex items-center gap-1 px-1 pt-0 pb-1.5 shrink-0 border-b border-gray-50 dark:border-gray-850/30">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 p-1 rounded transition {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<Icon name="chevron-left" size={14} strokeWidth={1.5} />
<Icon name="chevron-left" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 p-1 rounded transition {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<Icon name="chevron-right" size={14} strokeWidth={1.5} />
<Icon name="chevron-right" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<div
bind:this={breadcrumbEl}
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-none"
>
{#each breadcrumbs as crumb, i}
{#if showSeparator(i)}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none">/</span>
{/if}
<button
class="text-xs shrink-0 px-1 py-0.5 rounded transition
class="text-xs shrink-0 p-0 transition
{!selectedFile && i === breadcrumbs.length - 1
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}
@@ -120,8 +122,8 @@
</button>
{/each}
{#if selectedFile}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
<span class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300">
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none">/</span>
<span class="text-xs shrink-0 p-0 text-gray-700 dark:text-gray-300">
{selectedFile.split('/').pop()}
</span>
{/if}
@@ -132,11 +134,11 @@
<Tooltip content={$i18n.t('Refresh')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
>
<Icon name="refresh" size={14} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
<Icon name="refresh" size={11} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
@@ -144,10 +146,10 @@
<Dropdown align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Sort')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Sort')}
>
<Icon name="sort" size={14} strokeWidth={1.4} />
<Icon name="sort" size={11} strokeWidth={1.4} />
</button>
</Tooltip>
@@ -155,7 +157,7 @@
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => onSort('name')}
>
<span class="flex-1 text-left">{$i18n.t('Name')}</span>
@@ -172,7 +174,24 @@
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => onSort('size')}
>
<span class="flex-1 text-left">{$i18n.t('Size')}</span>
{#if sortBy === 'size'}
<Icon
name="chevron-up"
size={12}
strokeWidth={1.5}
class="text-gray-500 dark:text-gray-400 transition-transform {sortAsc
? ''
: 'rotate-180'}"
/>
{/if}
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => onSort('date')}
>
<span class="flex-1 text-left">{$i18n.t('Date Modified')}</span>
@@ -190,45 +209,64 @@
</DropdownMenu>
</div>
</Dropdown>
<Tooltip content={$i18n.t('New Folder')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFolder}
disabled={!writable}
aria-label={$i18n.t('New Folder')}
>
<Icon name="folder" size={14} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('New File')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFile}
disabled={!writable}
aria-label={$i18n.t('New File')}
>
<Icon name="empty-page" size={14} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDownloadDir}
aria-label={$i18n.t('Download')}
>
<Icon name="download" size={14} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Upload')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
disabled={!writable}
aria-label={$i18n.t('Upload')}
>
<Icon name="upload" size={14} strokeWidth={1.4} />
</button>
</Tooltip>
<Dropdown align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Actions')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Actions')}
>
<Icon name="three-dots" size={11} strokeWidth={1.4} />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={onNewFolder}
disabled={!writable}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={onNewFile}
disabled={!writable}
>
<Icon name="empty-page" size={12} strokeWidth={1.4} />
<span>{$i18n.t('New File')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
disabled={!writable}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<span>{$i18n.t('Upload')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={onDownloadDir}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<span>{$i18n.t('Download')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={onToggleHidden}
>
<Icon name="eye" size={12} strokeWidth={1.4} />
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
</button>
</DropdownMenu>
</div>
</Dropdown>
<input
bind:this={uploadInput}
type="file"
+13 -40
View File
@@ -3,6 +3,7 @@
import type { ListeningPort } from '$lib/apis/terminal';
import { getListeningPorts, getPortProxyUrl } from '$lib/apis/terminal';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher<{ previewPort: number }>();
@@ -55,21 +56,15 @@
<div class="px-2 py-1">
<button
class="flex items-center gap-1 w-full text-xs font-normal text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
class="flex items-center gap-1 w-full text-xs font-normal text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100"
on:click={() => (expanded = !expanded)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 transition-transform {expanded ? '' : '-rotate-90'}"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
<Icon
name="chevron-down"
size={12}
strokeWidth={1.4}
class="transition-transform {expanded ? '' : '-rotate-90'}"
/>
{$i18n.t('Ports')}
<span class="ml-auto flex items-center gap-1">
{#if ports.length > 0}
@@ -81,22 +76,11 @@
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click|stopPropagation={loadPorts}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 {loading ? 'animate-spin' : ''}"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="refresh" size={11} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
</span>
@@ -111,7 +95,7 @@
{:else}
{#each ports as port}
<button
class="flex items-center w-full gap-2 px-1.5 py-1 text-xs rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition group"
class="flex h-7 items-center w-full gap-2 px-1.5 text-xs rounded-lg hover:bg-gray-50/40 dark:hover:bg-white/4 transition-colors duration-75 group"
on:click={() => previewPort(port.port)}
>
<span class="font-mono text-blue-500 dark:text-blue-400 shrink-0">
@@ -125,21 +109,10 @@
<span
role="button"
tabindex="-1"
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition shrink-0 p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition shrink-0 flex h-5 w-5 items-center justify-center rounded hover:text-gray-600 dark:hover:text-gray-300"
on:click|stopPropagation={() => openPortExternal(port.port)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="external-link" size={11} strokeWidth={1.4} />
</span>
</Tooltip>
</button>