This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-13 15:20:59 -06:00
1 parent 5c05608e3a
commit 7dfbdd221a
5 files changed
+632 -447

No files matched your search

@@ -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>