This commit is contained in:
Timothy Jaeryang Baek 2026-10-21 12:27:06 +09:00
parent fbeee59e99
commit a76c02e58e
6 changed files with 565 additions and 196 deletions

View file

@ -17,8 +17,6 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import { toast } from 'svelte-sonner';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import type { Readable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
@ -344,6 +342,7 @@
let sortBy: SortMode = 'name';
let sortAsc = true;
let showHidden = false;
let viewMode: 'grid' | 'list' = 'list';
let visibleEntries: BrowserRow[] = [];
let expandedDirs: Set<string> = new Set();
let treeCache: Map<string, FileEntry[]> = new Map();
@ -462,9 +461,23 @@
});
$: visibleEntries = withRowIndexes(
buildVisibleRows(entries, currentPath, expandedDirs, treeCache, showHidden, sortBy, sortAsc)
buildVisibleRows(
entries,
currentPath,
viewMode === 'list' ? expandedDirs : new Set(),
treeCache,
showHidden,
sortBy,
sortAsc
)
);
const changeViewMode = (mode: 'grid' | 'list') => {
clearSelection();
viewMode = mode;
localStorage.setItem('fileNav:viewMode', mode);
};
// ── Navigation history ──────────────────────────────────────────────
type NavEntry = { path: string; file: string | null };
let navHistory: NavEntry[] = [];
@ -1796,6 +1809,7 @@
// ── Lifecycle ────────────────────────────────────────────────────────
onMount(() => {
showHidden = localStorage.getItem('fileNav:showHidden') === 'true';
viewMode = localStorage.getItem('fileNav:viewMode') === 'grid' ? 'grid' : 'list';
const terminal = getTerminal();
let handledDisplayFile = false;
@ -2028,6 +2042,8 @@
{sortBy}
{sortAsc}
{showHidden}
viewMode={isSearching ? null : viewMode}
onViewModeChange={changeViewMode}
onGoBack={goBack}
onGoForward={goForward}
onNavigate={loadDir}
@ -2527,18 +2543,25 @@
{/if}
{#if visibleEntries.length > 0 || creatingFolder || creatingFile}
<ul>
<ul
class={viewMode === 'grid'
? 'grid grid-cols-[repeat(auto-fill,minmax(min(100px,calc((100%_-_0.625rem)/2)),1fr))] gap-2.5 p-3'
: ''}
>
{#each visibleEntries as entry (entry.fullPath)}
<FileEntryRow
{entry}
{viewMode}
currentPath={entry.parentPath}
fullPath={entry.fullPath}
depth={entry.depth}
rowIndex={entry.rowIndex}
expanded={expandedDirs.has(entry.fullPath)}
loadingChildren={loadingDirs.has(entry.fullPath)}
folderContentsVersion={loadGeneration}
terminalUrl={selectedTerminal.url}
terminalKey={selectedTerminal.key}
terminalSessionId={chatId ?? undefined}
selected={selectedEntries.has(entry.fullPath)}
{selectionMode}
selectedPaths={selectedEntries}
@ -2648,7 +2671,7 @@
refreshBrowser();
}}
>
<Icon name="refresh" size={12} strokeWidth={1.4} />
<Icon name="refresh" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Refresh')}</span>
</button>
<button
@ -2660,7 +2683,7 @@
startNewFile();
}}
>
<Plus className="size-3.5" />
<Icon name="file-plus" size={14} strokeWidth={1.5} />
<span>{$i18n.t('New File')}</span>
</button>
<button
@ -2672,7 +2695,7 @@
startNewFolder();
}}
>
<Folder className="size-3.5" />
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
@ -2684,7 +2707,7 @@
openUploadPicker();
}}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<Icon name="upload-tray" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Upload')}</span>
</button>
<button
@ -2696,7 +2719,7 @@
openUploadPicker(true);
}}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<Icon name="folder-upload" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Upload Folder')}</span>
</button>
<button
@ -2707,7 +2730,7 @@
downloadFile(currentPath);
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<Icon name="download-tray" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Download')}</span>
</button>
<button
@ -2718,7 +2741,7 @@
toggleHidden();
}}
>
<Icon name="eye" size={12} strokeWidth={1.4} />
<Icon name={showHidden ? 'eye-off' : 'eye'} size={14} strokeWidth={1.5} />
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
</button>
</DropdownMenu>

View file

@ -1,16 +1,24 @@
<script context="module" lang="ts">
let closeActiveHoverPreview: (() => void) | null = null;
</script>
<script lang="ts">
import { toast } from 'svelte-sonner';
import { LinkPreview } from 'bits-ui';
import { mobile } from '$lib/stores';
import { getContext, tick, onDestroy } from 'svelte';
import { copyToClipboard, formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
import { listFiles, type FileEntry } from '$lib/apis/terminal';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import FileTypeIcon from './FileTypeIcon.svelte';
import Icon from './Icon.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
export let dragType = 'application/x-terminal-file-move';
export let variant: 'terminal' | 'workspace' = 'terminal';
export let viewMode: 'grid' | 'list' = 'list';
const i18n: any = getContext('i18n');
export let entry: FileEntry;
@ -20,8 +28,10 @@
export let rowIndex = 0;
export let expanded = false;
export let loadingChildren = false;
export let folderContentsVersion = 0;
export let terminalUrl: string = '';
export let terminalKey: string = '';
export let terminalSessionId: string | undefined = undefined;
export let onOpen: (entry: FileEntry) => void = () => {};
export let onNewFolder: ((path: string) => void) | undefined = undefined;
@ -53,6 +63,7 @@
$: entryPath =
fullPath ??
(entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`);
$: folderCard = viewMode === 'grid' && entry.type === 'directory';
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
@ -75,6 +86,22 @@
let dragOverFolder = false;
let expandTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = false;
let openPreview = false;
let dragging = false;
const closeHoverPreview = () => (openPreview = false);
$: hoverDisabled =
entry.type !== 'file' ||
!terminalUrl ||
$mobile ||
renaming ||
menuOpen ||
dragging ||
selectionMode;
$: if (hoverDisabled) openPreview = false;
$: if (openPreview && closeActiveHoverPreview !== closeHoverPreview) {
closeActiveHoverPreview?.();
closeActiveHoverPreview = closeHoverPreview;
}
const clearExpandTimer = () => {
if (!expandTimer) return;
@ -82,6 +109,32 @@
expandTimer = null;
};
let folderHasContents: boolean | null = null;
const observeFolderContents = (node: Element) => {
folderHasContents = null;
if (!terminalUrl) return;
let disposed = false;
const url = terminalUrl;
const key = terminalKey;
const session = terminalSessionId;
const path = directoryPath;
const observer = new IntersectionObserver((observations) => {
if (!observations.some((observation) => observation.isIntersecting)) return;
observer.disconnect();
// Only inspect visible folders; failed reads leave their contents unknown.
void listFiles(url, key, path, session).then((result) => {
if (!disposed && result) folderHasContents = result.entries.length > 0;
});
});
observer.observe(node);
return {
destroy() {
disposed = true;
observer.disconnect();
}
};
};
// ── Rename state ─────────────────────────────────────────────────────
let renaming = false;
let renameValue = '';
@ -142,12 +195,14 @@
};
onDestroy(() => {
if (closeActiveHoverPreview === closeHoverPreview) closeActiveHoverPreview = null;
if (longPressTimer) clearTimeout(longPressTimer);
clearExpandTimer();
});
// ── Click handler ────────────────────────────────────────────────────
const handleClick = (e: MouseEvent) => {
openPreview = false;
if (renaming) return;
if (didLongPress) {
didLongPress = false;
@ -172,13 +227,19 @@
};
</script>
<li class="group" data-file-row>
<li class="group min-w-0 {viewMode === 'grid' ? 'self-start' : ''}" data-file-row>
<div
class="w-full flex items-center transition-colors duration-75 {variant === 'workspace'
? 'rounded-xl'
: ''}
{selected || active ? 'bg-gray-100 dark:bg-white/8' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
{dragOverFolder
class="w-full flex items-center transition-colors duration-75 {viewMode === 'grid'
? folderCard
? 'relative h-18'
: 'relative h-[4.125rem] rounded-md border border-black/[0.06] bg-gray-50/40 dark:border-white/[0.06] dark:bg-gray-900'
: ''} {variant === 'workspace' ? 'rounded-xl' : ''}
{folderCard
? ''
: selected || active
? 'bg-gray-100 dark:bg-white/8'
: 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
{dragOverFolder && !folderCard
? 'bg-gray-100 dark:bg-white/8 ring-1 ring-black/15 dark:ring-white/15 ring-inset'
: ''}"
role="presentation"
@ -189,7 +250,7 @@
e.preventDefault();
e.stopPropagation();
dragOverFolder = true;
if (!expanded && !expandTimer) {
if (viewMode === 'list' && !expanded && !expandTimer) {
expandTimer = setTimeout(() => {
onToggleExpand(directoryPath);
expandTimer = null;
@ -222,9 +283,45 @@
} catch {}
}}
>
{#if variant === 'workspace'}
{#if folderCard}
{#key JSON.stringify( [terminalUrl, terminalKey, terminalSessionId, directoryPath, entry.modified, folderContentsVersion] )}
<svg
use:observeFolderContents
class="pointer-events-none absolute inset-0 h-full w-full {dragOverFolder
? 'text-black/25 dark:text-white/25'
: 'text-black/10 dark:text-white/10'}"
viewBox="0 0 200 72"
preserveAspectRatio="none"
fill="none"
stroke="currentColor"
stroke-width="1"
aria-hidden="true"
>
<!-- The back, sloped tab, and front are one folder silhouette. -->
<path
d="M1 19V8Q1 1 9 1H54Q58 1 61 4L65 7Q67 9 71 9H191Q199 9 199 15V19"
vector-effect="non-scaling-stroke"
/>
{#if folderHasContents}
<path
d="M7 13V12Q7 9 12 9H188Q193 9 193 12V13Z"
vector-effect="non-scaling-stroke"
class="fill-gray-50 text-black/10 dark:fill-[color-mix(in_srgb,var(--color-gray-900),white_4%)] dark:text-white/10"
/>
{/if}
<path
d="M1 20Q1 13 9 13H191Q199 13 199 20V64Q199 71 191 71H9Q1 71 1 64Z"
vector-effect="non-scaling-stroke"
class={selected || active || dragOverFolder
? 'fill-black/5 dark:fill-white/8'
: 'fill-transparent group-hover:fill-black/[0.02] dark:group-hover:fill-white/4'}
/>
</svg>
{/key}
{/if}
{#if viewMode === 'list' && variant === 'workspace'}
<span class="shrink-0" style:width={rowIndent}></span>
{:else if entry.type === 'directory'}
{:else if viewMode === 'list' && entry.type === 'directory'}
<button
type="button"
class="mr-1.5 flex w-3 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
@ -239,7 +336,7 @@
class={loadingChildren ? 'animate-pulse' : ''}
/>
</button>
{:else}
{:else if viewMode === 'list'}
<span class="mr-1.5 w-3 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
{/if}
@ -251,120 +348,179 @@
aria-label={$i18n.t('Select {{name}}', { name: entry.name })}
class="mr-2 flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors {selected
? 'bg-gray-900 border-gray-900 text-white dark:bg-gray-200 dark:border-gray-200 dark:text-gray-900'
: 'border-gray-300 dark:border-gray-600'}"
: 'border-gray-300 dark:border-gray-600'} {viewMode === 'grid'
? 'absolute bottom-2 left-2 z-10'
: ''}"
on:click|stopPropagation={(event) => onSelect(entry, event, entryPath, rowIndex)}
>
{#if selected}<Icon name="check" size={10} strokeWidth={2} />{/if}
</button>
{/if}
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pr-2 text-left"
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
e.preventDefault();
return;
}
const filePath = entryPath.replace(/\/$/, '');
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(dragType, 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:0.375rem;padding:0.25rem 0.625rem;border-radius:0.5rem;background:#374151;color:#fff;font-size:0.75rem;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(
dragType,
JSON.stringify({
path: entry.type === 'directory' ? directoryPath : filePath,
name: entry.name
})
);
}
if (entry.type === 'file' && terminalUrl) {
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}
aria-expanded={variant === 'workspace' && entry.type === 'directory' ? expanded : undefined}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
<LinkPreview.Root
openDelay={300}
closeDelay={0}
disabled={hoverDisabled}
bind:open={openPreview}
>
{#if variant === 'workspace'}
{#if entry.type === 'directory'}
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={12} strokeWidth={1.5} />
{:else}<span class="w-3 shrink-0"></span>{/if}
{/if}
<FileTypeIcon name={entry.name} type={entry.type} size={14} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="flex-1 text-xs bg-transparent outline-hidden text-gray-800 dark:text-gray-200 min-w-0 {variant ===
'workspace'
? 'h-4 p-0 leading-4'
: 'py-0.5'}"
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 !writable && !renaming && variant !== 'workspace'}
<span class="text-[0.625rem] text-gray-400 shrink-0">{$i18n.t('Read-only')}</span>
{/if}
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
{#if showDate && entry.modified}
<span class="text-[0.625rem] text-gray-400 shrink-0"
>{formatRelativeTime(entry.modified)}</span
<LinkPreview.Trigger>
{#snippet child({ props })}
<button
{...props}
type="button"
class="flex min-w-0 flex-1 items-center {viewMode === 'grid'
? folderCard
? 'h-18 flex-col !items-start justify-start gap-0.5 px-1.5 pt-4 pb-1.5 text-left'
: 'h-16 flex-col !items-start justify-start gap-0.5 p-1.5 text-left'
: 'gap-1.5 py-1.5 pr-2 text-left'}"
title={hoverDisabled ? entry.name : undefined}
draggable={canMutate}
on:dragend={() => (dragging = false)}
on:dragstart={(e) => {
openPreview = false;
if (!canMutate) {
e.preventDefault();
return;
}
dragging = true;
const filePath = entryPath.replace(/\/$/, '');
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(dragType, 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:0.375rem;padding:0.25rem 0.625rem;border-radius:0.5rem;background:#374151;color:#fff;font-size:0.75rem;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(
dragType,
JSON.stringify({
path: entry.type === 'directory' ? directoryPath : filePath,
name: entry.name
})
);
}
if (entry.type === 'file' && terminalUrl) {
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}
aria-expanded={variant === 'workspace' && entry.type === 'directory'
? expanded
: props['aria-expanded']}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
>
{/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-[0.625rem] text-gray-400 shrink-0"
>{formatRelativeTime(entry.modified)}</span
>
{#if variant === 'workspace'}
{#if entry.type === 'directory'}
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={12}
strokeWidth={1.5}
/>
{:else}<span class="w-3 shrink-0"></span>{/if}
{/if}
{#if viewMode === 'list'}
<FileTypeIcon name={entry.name} type={entry.type} size={14} />
{/if}
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="flex-1 text-xs bg-transparent outline-hidden text-gray-800 dark:text-gray-200 min-w-0 {variant ===
'workspace'
? 'h-4 p-0 leading-4'
: 'py-0.5'} {viewMode === 'grid' ? 'w-full pr-6' : ''}"
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="text-xs text-gray-800 dark:text-gray-200 {viewMode === 'grid'
? 'w-full shrink-0 line-clamp-2 break-words pr-5'
: 'flex-1 truncate'}"
>
{entry.name}
</span>
{/if}
{#if !writable && !renaming && variant !== 'workspace'}
<span class="text-[0.625rem] text-gray-400 shrink-0">{$i18n.t('Read-only')}</span>
{/if}
{#if entry.type === 'file' && viewMode === 'grid' && !renaming}
<span
class="mt-auto shrink-0 max-w-full truncate rounded bg-black/5 px-1 text-[0.5625rem] leading-3 uppercase text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{entry.name.includes('.')
? entry.name.split('.').pop() || $i18n.t('File')
: $i18n.t('File')}
</span>
{:else if entry.type === 'file' && entry.size !== undefined && !renaming}
{#if showDate && entry.modified}
<span class="text-[0.625rem] 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-[0.625rem] text-gray-400 shrink-0"
>{formatRelativeTime(entry.modified)}</span
>
{/if}
</button>
{/snippet}
</LinkPreview.Trigger>
{#if openPreview}
{#key JSON.stringify( [terminalUrl, terminalKey, terminalSessionId, entryPath, entry.modified, entry.size] )}
{#await import('./FileHoverPreview.svelte') then { default: FileHoverPreview }}
<FileHoverPreview
{entry}
path={entryPath}
{terminalUrl}
{terminalKey}
sessionId={terminalSessionId}
/>
{/await}
{/key}
{/if}
</button>
</LinkPreview.Root>
<Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
<button
type="button"
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-50/40 dark:hover:bg-white/4"
hover:bg-gray-50/40 dark:hover:bg-white/4 {viewMode === 'grid'
? folderCard
? 'absolute right-0 top-4'
: 'absolute right-0 top-1'
: ''}"
aria-label={$i18n.t('More')}
>
<Icon name="three-dots" size={12} strokeWidth={1.4} />
@ -372,26 +528,6 @@
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
{#if variant !== 'workspace' || entry.type !== 'directory'}
<button
type="button"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onOpen(entry);
}}
>
<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}
{#if entry.type === 'directory' && onNewFolder}
<button
type="button"
@ -403,7 +539,7 @@
onNewFolder?.(directoryPath);
}}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('New Folder')}</div>
</button>
{/if}
@ -418,10 +554,33 @@
onNewFile?.(directoryPath);
}}
>
<Icon name="plus" size={12} strokeWidth={1.4} />
<Icon name="file-plus" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('New File')}</div>
</button>
{/if}
{#if entry.type === 'directory' && (onNewFolder || onNewFile)}
<div class="my-1 border-t border-gray-100 dark:border-white/10" role="separator"></div>
{/if}
{#if variant !== 'workspace' || entry.type !== 'directory'}
<button
type="button"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onOpen(entry);
}}
>
{#if entry.type === 'directory'}
<FolderOpen className="size-3.5 shrink-0" />
{:else}
<Icon name="eye" size={14} strokeWidth={1.5} />
{/if}
<div class="flex items-center">
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
</div>
</button>
{/if}
{#if entry.type === 'file' && onSelectForComparison}
<button
type="button"
@ -431,7 +590,9 @@
onSelectForComparison?.();
}}
>
<Icon name="page" size={12} /><span>{$i18n.t('Select for Compare')}</span>
<Icon name="page" size={14} strokeWidth={1.5} /><span
>{$i18n.t('Select for Compare')}</span
>
</button>
{#if onCompareWithSelected}
<button
@ -443,31 +604,33 @@
onCompareWithSelected?.();
}}
>
<Icon name="split-horizontal" size={12} /><span
<Icon name="split-horizontal" size={14} strokeWidth={1.5} /><span
>{$i18n.t('Compare with Selected')}</span
>
</button>
{/if}
{/if}
{#if entry.type === 'directory'}
<button
type="button"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
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>
{#if viewMode === 'list'}
<button
type="button"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onToggleExpand(directoryPath);
}}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={14}
strokeWidth={1.5}
/>
<div class="flex items-center">
{expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
</div>
</button>
{/if}
{:else}
<button
type="button"
@ -478,7 +641,7 @@
onDownload(entryPath);
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<Icon name="download-tray" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
{/if}
@ -494,7 +657,7 @@
}
}}
>
<Icon name="copy" size={12} strokeWidth={1.4} />
<Icon name="copy" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
</button>
@ -509,7 +672,7 @@
startRename();
}}
>
<Icon name="pencil" size={12} strokeWidth={1.4} />
<Icon name="pencil" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</button>
@ -524,7 +687,7 @@
onDelete(entryPath.replace(/\/$/, ''), entry.name);
}}
>
<Icon name="trash" size={12} strokeWidth={1.4} />
<Icon name="trash" size={14} strokeWidth={1.5} />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</DropdownMenu>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import { LinkPreview } from 'bits-ui';
import { readFile, downloadFileBlob, type FileEntry } from '$lib/apis/terminal';
import { formatFileSize } from '$lib/utils';
import { supportsComments } from '$lib/utils/comments/types';
import FilePreview from './FilePreview.svelte';
import CommentFilePreview from './CommentFilePreview.svelte';
import FileTypeIcon from './FileTypeIcon.svelte';
const i18n: any = getContext('i18n');
export let entry: FileEntry;
export let path: string;
export let terminalUrl: string;
export let terminalKey: string;
export let sessionId: string | undefined = undefined;
let loading = true;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
let fileVideoUrl: string | null = null;
let fileAudioUrl: string | null = null;
let fileSqliteData: ArrayBuffer | null = null;
let disposed = false;
const controller = new AbortController();
const documentPreview = supportsComments(path);
// shortcut: Avoid large automatic downloads until bounded preview reads are available.
const withinPreviewLimit = entry.size !== undefined && entry.size <= 20 * 1024 * 1024;
onMount(async () => {
if (documentPreview || !withinPreviewLimit) {
loading = false;
return;
}
const extension = path.split('.').pop()?.toLowerCase() ?? '';
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'ico', 'avif', 'webp'].includes(extension);
const isVideo = ['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv'].includes(extension);
const isAudio = ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus'].includes(
extension
);
const isSqlite = ['db', 'sqlite', 'sqlite3', 'db3'].includes(extension);
try {
if (isImage || isVideo || isAudio || isSqlite) {
const result = await downloadFileBlob(
terminalUrl,
terminalKey,
path,
sessionId,
controller.signal
);
if (!result || disposed) return;
if (isSqlite) {
const bytes = await result.blob.arrayBuffer();
if (!disposed) fileSqliteData = bytes;
} else {
const url = URL.createObjectURL(result.blob);
if (isImage) fileImageUrl = url;
else if (isVideo) fileVideoUrl = url;
else fileAudioUrl = url;
}
} else if (entry.size! <= 1024 * 1024) {
const text = await readFile(terminalUrl, terminalKey, path, sessionId);
if (!disposed && text !== null && !text.startsWith('[Binary file:')) fileContent = text;
}
} catch {
// A failed preview must not prevent opening the file normally.
} finally {
if (!disposed) loading = false;
}
});
onDestroy(() => {
disposed = true;
controller.abort();
for (const url of [fileImageUrl, fileVideoUrl, fileAudioUrl]) {
if (url) URL.revokeObjectURL(url);
}
});
</script>
<LinkPreview.Portal>
<LinkPreview.Content
class="z-[9999] hidden w-[20rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 md:block dark:bg-gray-850 dark:text-white dark:ring-white/10"
side="top"
align="center"
sideOffset={12}
aria-label={$i18n.t('File preview')}
>
<div
class="flex items-center gap-1.5 border-b border-gray-50/60 px-2 py-1.5 dark:border-gray-800/25"
>
<FileTypeIcon name={entry.name} size={12} />
<div class="min-w-0 flex-1 truncate text-xs font-normal leading-4" title={path}>
{entry.name}
</div>
{#if entry.size !== undefined}
<span class="shrink-0 text-[0.625rem] text-gray-500 dark:text-gray-400"
>{formatFileSize(entry.size)}</span
>
{/if}
</div>
<div
class="h-60 max-h-[calc(100dvh-6rem)] overflow-hidden text-xs [&_.document-markdown]:!p-2 [&_.document-markdown_h1]:!text-sm [&_.document-markdown_h2]:!text-xs [&_.document-markdown_h3]:!text-xs [&_.document-markdown]:!text-xs [&_.cm-editor]:!text-[0.6875rem] [&_pre]:!text-[0.6875rem]"
>
{#if documentPreview && withinPreviewLimit}
<CommentFilePreview
{path}
baseUrl={terminalUrl}
apiKey={terminalKey}
chatId={sessionId ?? null}
/>
{:else if loading || fileContent !== null || fileImageUrl || fileVideoUrl || fileAudioUrl || fileSqliteData}
<FilePreview
selectedFile={path}
fileLoading={loading}
{fileContent}
{fileImageUrl}
{fileVideoUrl}
{fileAudioUrl}
{fileSqliteData}
baseUrl={terminalUrl}
apiKey={terminalKey}
chatId={sessionId ?? null}
readOnly={true}
allowScripts={false}
/>
{:else}
<div class="px-3 py-4 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Preview not available')}
</div>
{/if}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>

View file

@ -5,8 +5,7 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Icon from './Icon.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import Check from '$lib/components/icons/Check.svelte';
export let dragType = 'application/x-terminal-file-move';
const i18n: any = getContext('i18n');
@ -15,6 +14,8 @@
export let selectedFile: string | null = null;
export let loading = false;
export let writable = true;
export let viewMode: 'grid' | 'list' | null = null;
export let onViewModeChange: (mode: 'grid' | 'list') => void = () => {};
export let onNavigate: (path: string) => void = () => {};
export let onRefresh: () => void = () => {};
@ -254,7 +255,7 @@
}}
disabled={!writable}
>
<Plus className="size-3.5" />
<Icon name="file-plus" size={14} strokeWidth={1.5} />
<span>{$i18n.t('New File')}</span>
</button>
<button
@ -266,7 +267,7 @@
}}
disabled={!writable}
>
<Folder className="size-3.5" />
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
@ -278,7 +279,7 @@
}}
disabled={!writable}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<Icon name="upload-tray" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Upload')}</span>
</button>
{#if onUploadFolder}
@ -291,7 +292,7 @@
onUploadFolder?.();
}}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<Icon name="folder-upload" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Upload Folder')}</span>
</button>
{/if}
@ -303,7 +304,7 @@
actionsMenuOpen = false;
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<Icon name="download-tray" size={14} strokeWidth={1.5} />
<span>{$i18n.t('Download')}</span>
</button>
<button
@ -314,9 +315,31 @@
actionsMenuOpen = false;
}}
>
<Icon name="eye" size={12} strokeWidth={1.4} />
<Icon name={showHidden ? 'eye-off' : 'eye'} size={14} strokeWidth={1.5} />
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
</button>
{#if viewMode !== null}
<div class="my-1 border-t border-gray-100 dark:border-white/10" role="separator"></div>
{#each ['grid', 'list'] as mode}
<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"
aria-pressed={viewMode === mode}
on:click={() => {
onViewModeChange(mode === 'grid' ? 'grid' : 'list');
actionsMenuOpen = false;
}}
>
<Icon name={mode} size={12} strokeWidth={1.4} />
<span class="flex-1 text-left"
>{mode === 'grid' ? $i18n.t('Grid view') : $i18n.t('List view')}</span
>
{#if viewMode === mode}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
{/each}
{/if}
</DropdownMenu>
</div>
</Dropdown>

View file

@ -22,7 +22,7 @@
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
class={`shrink-0 ${className}`}
>
{#if name === 'folder'}
<path
@ -427,6 +427,11 @@
<path
d="M21.1667 18.5H21.4C21.7314 18.5 22 18.7686 22 19.1V21.4C22 21.7314 21.7314 22 21.4 22H17.6C17.2686 22 17 21.7314 17 21.4V19.1C17 18.7686 17.2686 18.5 17.6 18.5H17.8333M21.1667 18.5V16.75C21.1667 16.1667 20.8333 15 19.5 15C18.1667 15 17.8333 16.1667 17.8333 16.75V18.5M21.1667 18.5H17.8333"
/>
{:else if name === 'grid'}
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="15" y="3" width="6" height="6" rx="1" />
<rect x="3" y="15" width="6" height="6" rx="1" />
<rect x="15" y="15" width="6" height="6" rx="1" />
{:else if name === 'list'}
<path d="M8 6H20" />
<path d="M4 6.01L4.01 5.99889" />
@ -487,6 +492,27 @@
<path d="M15 6L9 12L15 18" />
{:else if name === 'check'}
<path d="M5 13L9 17L19 7" />
{:else if name === 'file-plus'}
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
<path d="M14 3v6h6M9 15h6M12 12v6" />
{:else if name === 'folder-plus' || name === 'folder-upload'}
<path d="M3 7V5a2 2 0 0 1 2-2h5l3 4h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
{#if name === 'folder-plus'}
<path d="M9 14h6M12 11v6" />
{:else}
<path d="M12 18v-7m-3 3 3-3 3 3" />
{/if}
{:else if name === 'upload-tray' || name === 'download-tray'}
<path d="M4 16v4h16v-4M12 3v12" />
{#if name === 'upload-tray'}
<path d="m8 7 4-4 4 4" />
{:else}
<path d="m8 11 4 4 4-4" />
{/if}
{:else if name === 'eye-off'}
<path
d="m3 3 18 18M10.6 6.1A8 8 0 0 1 12 6c4 0 7 3 9 6a18 18 0 0 1-3 3.5M6.4 6.4A21 21 0 0 0 3 12c2 3 5 6 9 6a9 9 0 0 0 5.6-2.1M10 10a3 3 0 0 0 4 4"
/>
{:else if name === 'upload'}
<path d="M6 20H18" />
<path d="M12 16V4M12 4L15.5 7.5M12 4L8.5 7.5" />

View file

@ -728,18 +728,18 @@
{:else if displayOption === 'grid'}
<div
class="{groupedNotes.length - 1 !== idx
? 'mb-5'
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
? 'mb-3'
: ''} grid grid-cols-[repeat(auto-fill,minmax(min(160px,100%),1fr))] gap-2.5 p-3"
>
{#each notesList as note, idx (note.id)}
<div
class="group flex min-h-32 w-full flex-col rounded-lg bg-gray-50/40 p-2.5 text-left transition hover:bg-gray-100/60 focus-within:bg-gray-100/60 dark:bg-gray-900/30 dark:hover:bg-gray-900 dark:focus-within:bg-gray-900"
class="group relative flex h-28 min-w-0 w-full flex-col rounded-md border border-black/[0.06] bg-gray-50/40 p-2 text-left transition-colors duration-75 hover:bg-gray-50/40 focus-within:bg-gray-50/40 dark:border-white/[0.06] dark:bg-gray-900 dark:hover:bg-white/4 dark:focus-within:bg-white/4"
>
<div class="flex items-start gap-2">
<a href={`/notes/${note.id}`} class="min-w-0 flex-1">
<div class="flex min-w-0 items-start">
<a href={`/notes/${note.id}`} class="min-w-0 flex-1 pr-5">
<Tooltip content={note.title} placement="top-start">
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
class="line-clamp-2 break-words text-xs text-gray-800 dark:text-gray-200"
>
{note.title}
</div>
@ -749,7 +749,7 @@
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-5 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
class="absolute right-1 top-1 flex size-5 shrink-0 items-center justify-center rounded text-gray-400 transition hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/4 dark:hover:text-gray-400"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
@ -795,29 +795,29 @@
}}
>
<button
class="flex size-5 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
class="absolute right-1 top-1 flex size-5 shrink-0 items-center justify-center rounded text-gray-400 transition hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/4 dark:hover:text-gray-400"
type="button"
aria-label={$i18n.t('Note Menu')}
>
<EllipsisHorizontal className="size-3.5" />
<EllipsisHorizontal className="size-3" />
</button>
</NoteMenu>
{/if}
</div>
<a href={`/notes/${note.id}`} class="mt-1 flex min-h-0 flex-1 flex-col">
<div
class="line-clamp-3 text-xs leading-5 text-gray-500 dark:text-gray-500"
>
{#if note.data?.content?.md}
{note.data?.content?.md}
{:else}
{$i18n.t('No content')}
{/if}
<a
href={`/notes/${note.id}`}
class="mt-2 min-h-0 flex-1 overflow-hidden text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-400"
style="mask-image: linear-gradient(to bottom, black 65%, transparent 100%);"
>
<div class="line-clamp-4 whitespace-pre-line break-words">
{note.data?.content?.md || $i18n.t('No content')}
</div>
</a>
<a href={`/notes/${note.id}`} class="mt-2 block min-w-0">
<div
class="mt-auto flex w-full items-center justify-between gap-2 pt-3 text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-500"
class="flex min-w-0 w-full items-center justify-between gap-1 text-[0.5625rem] leading-3 text-gray-500 dark:text-gray-400"
>
<Tooltip
content={note?.user?.email ?? $i18n.t('Deleted User')}
@ -836,7 +836,7 @@
.locale($i18n.language)
.format('LLLL')}
>
<div class="shrink-0">
<div class="max-w-full shrink-0 truncate">
{dayjs(note.updated_at / 1000000)
.locale($i18n.language)
.fromNow()}