Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.10] - 2026-06-15

### Changed

- 📁 **File browser drag-and-drop completely reworked.** You can now freely drag and drop files and folders anywhere — into folders, onto breadcrumbs, or between nested directories. Multi-select drag, auto-expand on hover, and full expanded-area highlighting are all supported.

### Fixed

- 📋 **Dropdown menus no longer get cut off by the keyboard on mobile.** Menus that open upward (like the model picker) now anchor from the bottom of the screen instead of the top, so they stay fully visible even when the on-screen keyboard is open.
- 🔄 **Renamed files no longer break git operations.** Renaming or moving a file could cause errors when staging, discarding, or viewing changes — the file list would show garbled entries like "R100 old-name.txt" instead of the actual file. This is now handled correctly.

## [0.4.9] - 2026-06-15

### Fixed
Expand Down
24 changes: 13 additions & 11 deletions cptr/frontend/src/lib/components/DropdownMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
}: Props = $props();

let menuEl: HTMLDivElement | undefined = $state();
let pos = $state<{ x: number; top: number }>({ x: -9999, top: -9999 });
let pos = $state<{ x: number; top?: number; bottom?: number }>({ x: -9999, top: -9999 });
let anchorWidth = $state(0);
let menuMaxHeight = $state<number | undefined>();
let ready = $state(false);
Expand Down Expand Up @@ -177,6 +177,7 @@
const viewport = visualViewportRect();
const viewportRight = viewport.left + viewport.width;
const viewportBottom = viewport.top + viewport.height;
const layoutViewportHeight = window.innerHeight;
const pad = 8;
const gap = 4;

Expand All @@ -192,31 +193,32 @@
const spaceAbove = anchorTop - viewport.top - gap - pad;
const spaceBelow = viewportBottom - anchorBottom - gap - pad;

let nextTop: number;
let availableHeight: number;

if (forceAbove || (preferAbove && (mh <= spaceAbove || spaceAbove >= spaceBelow))) {
availableHeight = spaceAbove;
const visibleMenuHeight = Math.min(mh, Math.max(0, availableHeight));
nextTop = anchorTop - gap - visibleMenuHeight;
pos = {
x: ax,
bottom: Math.max(pad, layoutViewportHeight - anchorTop + gap)
};
} else {
if (mh <= spaceBelow) {
availableHeight = spaceBelow;
nextTop = anchorBottom + gap;
const nextTop = Math.min(anchorBottom + gap, viewportBottom - pad - mh);
pos = { x: ax, top: Math.max(nextTop, viewport.top + pad) };
} else {
availableHeight = spaceAbove;
const visibleMenuHeight = Math.min(mh, Math.max(0, availableHeight));
nextTop = anchorTop - gap - visibleMenuHeight;
pos = {
x: ax,
bottom: Math.max(pad, layoutViewportHeight - anchorTop + gap)
};
}
}

menuMaxHeight =
availableHeight >= 0 && (forceAbove || mh > availableHeight || menuMaxHeight != null)
? Math.max(0, availableHeight)
: undefined;
const maxTop = viewportBottom - pad - (menuMaxHeight ?? mh);
nextTop = Math.min(Math.max(nextTop, viewport.top + pad), Math.max(viewport.top + pad, maxTop));
pos = { x: ax, top: nextTop };
ready = true;
}

Expand Down Expand Up @@ -330,7 +332,7 @@
: 'fixed'} z-[1001] min-w-36 rounded-xl bg-white dark:bg-[#1a1a1a] border border-gray-150 dark:border-white/6 shadow-xl p-0.5 flex flex-col overflow-hidden {className}"
style="{inlineAbove
? ''
: `left: ${pos.x}px; top: ${pos.top}px; ${menuMaxHeight
: `left: ${pos.x}px; ${pos.bottom != null ? `bottom: ${pos.bottom}px;` : `top: ${pos.top ?? -9999}px;`} ${menuMaxHeight
? `max-height: ${menuMaxHeight}px;`
: ''}`} {anchorWidth ? `width: ${anchorWidth}px;` : ''} opacity: {ready
? 1
Expand Down
191 changes: 157 additions & 34 deletions cptr/frontend/src/lib/components/FileBrowser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
let initialLoad = $state(true);
let fetchTimer: ReturnType<typeof setTimeout> | null = null;
let fetching = false;
let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;
let error = $state<string | null>(null);
let searchQuery = $state('');
let dragOverDir = $state<string | null>(null);
Expand Down Expand Up @@ -281,7 +282,8 @@
selectedPaths = new Set();
lastClickedIndex = index;
if (entry.type === 'directory') {
toggleDir(entry.path);
// Navigate into the folder
setFileBrowserCwd(entry.path);
} else {
openFileTab(entry.path);
}
Expand Down Expand Up @@ -398,46 +400,126 @@
}

// ── Drag to move ────────────────────────────────────────────

/** Collect all paths being dragged: either selected items (if the dragged item is selected) or just the single item */
function getDraggedPaths(entry: TreeEntry): string[] {
if (selectedPaths.size > 0 && selectedPaths.has(entry.path)) {
return [...selectedPaths];
}
return [entry.path];
}

function onDragStart(e: DragEvent, entry: TreeEntry) {
const paths = getDraggedPaths(entry);
draggedItem = entry.path;
e.dataTransfer?.setData('text/plain', entry.path);
e.dataTransfer?.setData('text/plain', paths.join('\n'));
e.dataTransfer?.setData('application/x-filebrowser-paths', JSON.stringify(paths));
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}

/** For a given entry, find the drop-target directory.
* - If the entry IS a directory, target it directly.
* - Otherwise, find its nearest parent expanded directory.
* Returns null if the entry is a root-level file (parent === cwd). */
function resolveDropTarget(entry: TreeEntry): string | null {
if (entry.type === 'directory') return entry.path;
// Walk up to find the nearest expanded parent
const parentDir = entry.path.substring(0, entry.path.lastIndexOf('/'));
const cwdNorm = cwd.replace(/\/$/, '');
if (parentDir === cwdNorm || parentDir === cwd) return null; // root-level file, no folder highlight
// The parent must be expanded (otherwise the entry wouldn't be visible)
if (expandedDirs.has(parentDir)) return parentDir;
return null;
}

function onDragOverDir(e: DragEvent, entry: TreeEntry) {
if (entry.type !== 'directory') return;
// Determine the target folder for this entry
let targetDir: string | null;
if (entry.type === 'directory') {
targetDir = entry.path;
} else {
// Non-directory: target the nearest parent expanded folder
targetDir = resolveDropTarget(entry);
if (!targetDir) return; // root-level file, let the container dropzone handle it
}

// Don't allow dropping onto self or a child of a dragged dir
if (draggedItem && (targetDir === draggedItem || targetDir.startsWith(draggedItem + '/'))) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverDir = entry.path;

if (dragOverDir !== targetDir) {
dragOverDir = targetDir;
// Auto-expand directory after hovering for 600ms (only for actual directories)
if (dragExpandTimer) clearTimeout(dragExpandTimer);
if (entry.type === 'directory') {
dragExpandTimer = setTimeout(() => {
if (dragOverDir === targetDir && !expandedDirs.has(targetDir!)) {
toggleDir(targetDir!);
}
dragExpandTimer = null;
}, 600);
}
}
}

function onDragLeaveDir() {
if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }
dragOverDir = null;
}

/** Move one or more paths into a target directory */
async function moveItemsToDir(paths: string[], targetDir: string) {
for (const src of paths) {
// Skip if trying to move into itself or a parent path
if (src === targetDir || targetDir.startsWith(src + '/')) continue;
// Skip if already in that directory
const parentDir = src.substring(0, src.lastIndexOf('/'));
if (parentDir === targetDir || parentDir === targetDir.replace(/\/$/, '')) continue;
try {
await moveFile(src, targetDir);
} catch {}
}
// Refresh current dir + any expanded parents
fetchDirectory(cwd);
for (const dir of expandedDirs) {
fetchSubdir(dir);
}
clearSelection();
}

async function onDropOnDir(e: DragEvent, entry: TreeEntry) {
e.preventDefault();
if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }
dragOverDir = null;

// Handle file browser item drag
// Resolve actual target directory (entry itself if dir, or parent expanded dir)
const targetDir = resolveDropTarget(entry) ?? (entry.type === 'directory' ? entry.path : null);
if (!targetDir) return; // root-level file drop — container handles it

// Handle file browser item drag (single or multi)
if (draggedItem) {
try {
await moveFile(draggedItem, entry.path);
fetchDirectory(cwd);
refreshParentDir(draggedItem);
} catch {}
const rawPaths = e.dataTransfer?.getData('application/x-filebrowser-paths');
let paths: string[] = [];
if (rawPaths) {
try { paths = JSON.parse(rawPaths); } catch { paths = [draggedItem]; }
} else {
paths = [draggedItem];
}
await moveItemsToDir(paths, targetDir);
draggedItem = null;
return;
}

// Handle external file upload
const files = e.dataTransfer?.files;
if (files && files.length) {
await uploadFiles(files, entry.path);
await uploadFiles(files, targetDir);
}
}

function onDragEnd() {
if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }
draggedItem = null;
dragOverDir = null;
dragOverBreadcrumb = null;
Expand All @@ -459,18 +541,25 @@
e.preventDefault();
dragOverBreadcrumb = null;
if (!draggedItem) return;
try {
await moveFile(draggedItem, path);
fetchDirectory(cwd);
} catch {}
const rawPaths = e.dataTransfer?.getData('application/x-filebrowser-paths');
let paths: string[] = [];
if (rawPaths) {
try { paths = JSON.parse(rawPaths); } catch { paths = [draggedItem]; }
} else {
paths = [draggedItem];
}
await moveItemsToDir(paths, path);
draggedItem = null;
}

// ── Drop zone for uploads ───────────────────────────────────
function onDropzoneOver(e: DragEvent) {
// Only show dropzone for external files (not internal drags)
if (draggedItem) return;
e.preventDefault();
if (draggedItem) {
// Internal drag — allow drop to move to current directory
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
return;
}
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
dropzoneActive = true;
}
Expand All @@ -485,6 +574,21 @@
async function onDropzoneDrop(e: DragEvent) {
e.preventDefault();
dropzoneActive = false;

// Handle internal drag-and-drop to empty area (move to cwd)
if (draggedItem) {
const rawPaths = e.dataTransfer?.getData('application/x-filebrowser-paths');
let paths: string[] = [];
if (rawPaths) {
try { paths = JSON.parse(rawPaths); } catch { paths = [draggedItem]; }
} else {
paths = [draggedItem];
}
await moveItemsToDir(paths, cwd);
draggedItem = null;
return;
}

const files = e.dataTransfer?.files;
if (files && files.length) {
await uploadFiles(files, cwd);
Expand Down Expand Up @@ -805,13 +909,16 @@
</div>
{:else}
{@const isSelected = selectedPaths.has(entry.path)}
{@const isDragTarget = dragOverDir !== null && (entry.path === dragOverDir || entry.path.startsWith(dragOverDir + '/'))}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
class="group flex items-center gap-1 w-full h-7 rounded-lg text-left transition-colors duration-75
{isSelected
? 'bg-blue-50 dark:bg-blue-500/10'
: dragOverDir === entry.path
? 'bg-blue-100 dark:bg-blue-500/20'
: isDragTarget
? entry.path === dragOverDir
? 'bg-blue-100 dark:bg-blue-500/20'
: 'bg-blue-50/60 dark:bg-blue-500/8'
: 'hover:bg-gray-100 dark:hover:bg-white/4'}"
style="padding-left: {8 + entry.depth * 16}px; padding-right: 8px;"
onclick={(e) => handleClick(e, entry, i)}
Expand All @@ -824,8 +931,21 @@
ondragend={onDragEnd}
>
{#if entry.type === 'directory'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="flex items-center justify-center w-4 shrink-0 text-gray-400 dark:text-gray-600"
class="flex items-center justify-center w-4 shrink-0 text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400 cursor-pointer"
role="button"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
toggleDir(entry.path);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
toggleDir(entry.path);
}
}}
>
<Icon
name={expandedDirs.has(entry.path) ? 'chevron-down' : 'chevron-right'}
Expand All @@ -843,21 +963,9 @@
<Icon name={fileIconName(entry.name, entry.type)} size={14} strokeWidth={1.4} />
</span>
{#if entry.type === 'directory'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate hover:underline cursor-pointer"
role="button"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
setFileBrowserCwd(entry.path);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
setFileBrowserCwd(entry.path);
}
}}>{entry.name}</span
class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate"
>{entry.name}</span
>
{:else}
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate"
Expand Down Expand Up @@ -980,6 +1088,21 @@
<DropdownMenu
anchor={contextMenu.anchor ?? { x: contextMenu.x, y: contextMenu.y }}
items={[
...(contextMenu.entry.type === 'directory'
? [
{
label: $t('files.openFolder'),
icon: 'folder',
onclick: () => { setFileBrowserCwd(contextMenu!.entry.path); closeMenu(); }
},
{
label: expandedDirs.has(contextMenu.entry.path) ? $t('files.collapse') : $t('files.expand'),
icon: expandedDirs.has(contextMenu.entry.path) ? 'chevron-down' : 'chevron-right',
onclick: () => { toggleDir(contextMenu!.entry.path); closeMenu(); }
},
{ label: '', divider: true, onclick: () => {} }
]
: []),
{ label: $t('files.rename'), icon: 'pencil', onclick: () => startRename(contextMenu!.entry) },
...(contextMenu.entry.type !== 'directory'
? [
Expand Down
3 changes: 3 additions & 0 deletions cptr/frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"files.showHidden": "Show dotfiles",
"files.hideHidden": "Hide dotfiles",
"files.failedToLoad": "Failed to load directory",
"files.openFolder": "Open folder",
"files.expand": "Expand",
"files.collapse": "Collapse",

"quickOpen.searchFiles": "Search files...",
"quickOpen.noFiles": "No files found",
Expand Down
Loading
Loading