This commit is contained in:
Timothy Jaeryang Baek
2026-03-23 23:39:52 -05:00
parent 3a4b862e81
commit 139e764b2f
45 changed files with 6251 additions and 5730 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
@@ -22,6 +22,13 @@
export let onMove: (source: string, destFolder: string) => void = () => {};
export let onRename: (oldPath: string, newName: string) => void = () => {};
// ── Selection ─────────────────────────────────────────────────────────
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 = () => {};
let dragOverFolder = false;
// ── Rename state ─────────────────────────────────────────────────────
@@ -34,7 +41,6 @@
renaming = true;
await tick();
renameInput?.focus();
// Select the name without extension for files
if (entry.type === 'file') {
const dotIdx = entry.name.lastIndexOf('.');
renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length);
@@ -54,11 +60,69 @@
renaming = false;
renameValue = '';
};
// ── Long-press for touch selection ───────────────────────────────────
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);
});
// ── Click handler ────────────────────────────────────────────────────
const handleClick = (e: MouseEvent) => {
if (renaming) return;
if (didLongPress) {
didLongPress = false;
return;
}
// Modifier click → toggle/range select
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
onSelect(entry, e);
return;
}
// In selection mode (touch) → toggle select
if (selectionMode) {
onSelect(entry, e);
return;
}
// Normal click → open
onOpen(entry);
};
</script>
<li class="group">
<div
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition
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'
: ''}"
@@ -84,11 +148,11 @@
dragOverFolder = false;
try {
const data = JSON.parse(raw);
if (data.path) {
const destFolder = `${currentPath}${entry.name}/`;
// Don't allow dropping a folder onto itself
if (data.path + '/' === destFolder || data.path === destFolder) return;
onMove(data.path, destFolder);
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 {}
}}
@@ -98,12 +162,26 @@
draggable={true}
on:dragstart={(e) => {
const filePath = `${currentPath}${entry.name}`;
// Internal move data
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
);
// Keep existing chat-attachment drag for files
// 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',
@@ -116,13 +194,38 @@
);
}
}}
on:click={() => {
if (!renaming) onOpen(entry);
}}
on:pointerdown={onPointerDown}
on:pointerup={onPointerUp}
on:pointercancel={onPointerCancel}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
>
{#if selectionMode || selected}
<!-- Checkbox indicator -->
<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}
@@ -148,8 +251,14 @@
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(); }
if (e.key === 'Enter') {
e.preventDefault();
submitRename();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
on:blur={submitRename}
on:click|stopPropagation