From e9fb0c554d9729f291e785f4227e6d64ac165448 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 16:54:18 +0200 Subject: [PATCH 1/4] refac --- cptr/utils/git.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cptr/utils/git.py b/cptr/utils/git.py index d9ebede..25499ad 100644 --- a/cptr/utils/git.py +++ b/cptr/utils/git.py @@ -78,7 +78,10 @@ async def status(root: str) -> dict[str, Any]: behind = abs(int(parts[3].lstrip("-"))) elif line.startswith("1 ") or line.startswith("2 "): # Changed entry - parts = line.split(" ", 8) + # type-1: 1 XY sub mH mI mW hH hI path (9 fields) + # type-2: 1 XY sub mH mI mW hH hI Xscore path\torigPath (10 fields) + nsplits = 9 if line.startswith("2 ") else 8 + parts = line.split(" ", nsplits) xy = parts[1] path = parts[-1] # "2" entries (rename/copy) have original path after tab @@ -261,7 +264,8 @@ async def discard(root: str, files: list[str]) -> None: if path in requested: to_delete.append(path) elif line.startswith("1 ") or line.startswith("2 "): - parts = line.split(" ", 8) + nsplits = 9 if line.startswith("2 ") else 8 + parts = line.split(" ", nsplits) xy = parts[1] path = parts[-1] if line.startswith("2 "): From b00a5ad35bf3479b6fecf332f2846d3b28321ad9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 16:58:31 +0200 Subject: [PATCH 2/4] refac --- .../src/lib/components/DropdownMenu.svelte | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/cptr/frontend/src/lib/components/DropdownMenu.svelte b/cptr/frontend/src/lib/components/DropdownMenu.svelte index 6740d98..67d60bf 100644 --- a/cptr/frontend/src/lib/components/DropdownMenu.svelte +++ b/cptr/frontend/src/lib/components/DropdownMenu.svelte @@ -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(); let ready = $state(false); @@ -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; @@ -192,21 +193,25 @@ 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) + }; } } @@ -214,9 +219,6 @@ 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; } @@ -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 From bdfd7e3d6df1ed71efb131791b9ba1e36eddafb9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 17:11:33 +0200 Subject: [PATCH 3/4] refac --- .../src/lib/components/FileBrowser.svelte | 191 ++++++++++++++---- cptr/frontend/src/lib/i18n/locales/en.json | 3 + 2 files changed, 160 insertions(+), 34 deletions(-) diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte index 2cacaa1..0ff0c1e 100644 --- a/cptr/frontend/src/lib/components/FileBrowser.svelte +++ b/cptr/frontend/src/lib/components/FileBrowser.svelte @@ -39,6 +39,7 @@ let initialLoad = $state(true); let fetchTimer: ReturnType | null = null; let fetching = false; + let dragExpandTimer: ReturnType | null = null; let error = $state(null); let searchQuery = $state(''); let dragOverDir = $state(null); @@ -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); } @@ -398,34 +400,113 @@ } // ── 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; } @@ -433,11 +514,12 @@ // 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; @@ -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; } @@ -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); @@ -805,13 +909,16 @@ {:else} {@const isSelected = selectedPaths.has(entry.path)} + {@const isDragTarget = dragOverDir !== null && (entry.path === dragOverDir || entry.path.startsWith(dragOverDir + '/'))}