This commit is contained in:
Timothy Jaeryang Baek committed 2026-03-23 23:39:52 -05:00
1 parent 3a4b862e81
commit 139e764b2f
45 files changed
+6251 -5730

No files matched your search

@@ -0,0 +1,95 @@
<script lang="ts">
import { getContext } from 'svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Tooltip from '../../common/Tooltip.svelte';
const i18n = getContext('i18n');
export let count: number = 0;
export let hasFiles: boolean = false;
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
export let onSelectAll: () => void = () => {};
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-medium text-gray-600 dark:text-gray-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"
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>
</button>
</Tooltip>
{#if hasFiles}
<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"
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>
</button>
</Tooltip>
{/if}
<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"
on:click={onDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
</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"
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>
</button>
</Tooltip>
</div>
@@ -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
@@ -48,8 +48,17 @@
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -64,8 +73,17 @@
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -104,7 +122,8 @@
dragOverCrumb = null;
try {
const data = JSON.parse(raw);
if (data.path) onMove(data.path, crumb.path);
const paths = data.paths || (data.path ? [data.path] : []);
for (const p of paths) onMove(p, crumb.path);
} catch {}
}}
>
@@ -49,7 +49,7 @@
let editTextarea: HTMLTextAreaElement;
// Reset edit state when switching files
$: selectedFile, resetEdit();
$: (selectedFile, resetEdit());
const resetEdit = () => {
editing = false;
@@ -242,7 +242,7 @@
}
export let showRaw = false;
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
$: (selectedFile, (showRaw = false)); // reset to preview mode when switching files
// Auto-switch to raw/editor mode for empty previewable files so the user
// can start editing immediately instead of seeing a blank preview.
@@ -5,7 +5,6 @@
const i18n = getContext('i18n');
export let baseUrl: string;
export let port: number;
export let path: string = '';
@@ -59,10 +58,14 @@
})();
const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`;
const syncUrlBar = () => { urlInput = makeDisplayUrl(path); };
const syncUrlBar = () => {
urlInput = makeDisplayUrl(path);
};
urlInput = makeDisplayUrl(path);
const refresh = () => { iframeKey += 1; };
const refresh = () => {
iframeKey += 1;
};
const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer');
@@ -130,8 +133,17 @@
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -146,8 +158,17 @@
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -159,17 +180,24 @@
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5" class:animate-spin={isLoading}>
<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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
class:animate-spin={isLoading}
>
<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>
</button>
</Tooltip>
<!-- URL bar -->
<form
class="flex-1 min-w-0"
on:submit|preventDefault={navigateUrl}
>
<form class="flex-1 min-w-0" on:submit|preventDefault={navigateUrl}>
<input
type="text"
bind:value={urlInput}
@@ -185,8 +213,17 @@
on:click={openExternal}
aria-label={$i18n.t('Open in new tab')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<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>
</button>
</Tooltip>
@@ -198,8 +235,15 @@
on:click={onClose}
aria-label={$i18n.t('Close')}
>
<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
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>
</button>
</Tooltip>
@@ -227,9 +271,18 @@
<style>
@keyframes loading-bar {
0% { width: 0; margin-left: 0; }
50% { width: 60%; margin-left: 20%; }
100% { width: 0; margin-left: 100%; }
0% {
width: 0;
margin-left: 0;
}
50% {
width: 60%;
margin-left: 20%;
}
100% {
width: 0;
margin-left: 100%;
}
}
.animate-loading-bar {
animation: loading-bar 1.5s ease-in-out infinite;