From 2045d220e469dc6112fc7c905f7bf9ae75b7fac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Mart=C3=ADnez?= Date: Wed, 30 Sep 2026 12:35:34 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract=20?= =?UTF-8?q?the=20folder=20picker=20tree=20from=20FolderSelect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Move to…" dialog needs the same pickable folder tree the FolderSelect dropdown shows, so the tree helpers and rows move to a shared FolderTreeList. It starts with the path to the current folder expanded, replacing the dropdown's expand-on-open effect, and grows its rows below `lg` so they can be picked with a finger. The dropdown now repositions through a ResizeObserver, since it no longer knows when the list grows. --- src/ui/FolderSelect.tsx | 222 +++++++------------------------------- src/ui/FolderTreeList.tsx | 197 +++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 183 deletions(-) create mode 100644 src/ui/FolderTreeList.tsx diff --git a/src/ui/FolderSelect.tsx b/src/ui/FolderSelect.tsx index 4314a5a..4e5d4fe 100644 --- a/src/ui/FolderSelect.tsx +++ b/src/ui/FolderSelect.tsx @@ -3,60 +3,10 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { TOUCH_TARGET_Y } from "@/lib/constants/layout"; import { createPortal } from "react-dom"; -import { Check, ChevronDown, ChevronRight, Folder, FolderOpen } from "lucide-react"; +import { ChevronDown, Folder } from "lucide-react"; import type { FolderRecord } from "@/lib/types"; import type { Dictionary } from "@/i18n"; - -/* ── Tree helpers ──────────────────────────────────────────────────────────── */ - -interface TreeNode { - folder: FolderRecord; - children: TreeNode[]; -} - -function buildTree(folders: FolderRecord[], parentId: string | null = null): TreeNode[] { - const byParent = new Map(); - for (const folder of folders) { - const siblings = byParent.get(folder.parentId) ?? []; - siblings.push(folder); - byParent.set(folder.parentId, siblings); - } - - function build(currentParentId: string | null): TreeNode[] { - return (byParent.get(currentParentId) ?? []).map((folder) => ({ - folder, - children: build(folder.id), - })); - } - - return build(parentId); -} - -/** DFS: return nodes in display order, skipping collapsed subtrees */ -function flatVisible(nodes: TreeNode[], expanded: Set): Array<{ node: TreeNode; depth: number }> { - const result: Array<{ node: TreeNode; depth: number }> = []; - function walk(list: TreeNode[], depth: number) { - for (const node of list) { - result.push({ node, depth }); - if (expanded.has(node.folder.id) && node.children.length > 0) { - walk(node.children, depth + 1); - } - } - } - walk(nodes, 0); - return result; -} - -/** Return ancestor IDs of a given folder (excluding itself) */ -function ancestorIds(targetId: string, folders: FolderRecord[]): Set { - const ids = new Set(); - let current = folders.find((f) => f.id === targetId); - while (current?.parentId) { - ids.add(current.parentId); - current = folders.find((f) => f.id === current!.parentId); - } - return ids; -} +import { FolderTreeList } from "./FolderTreeList"; /* ── Component ──────────────────────────────────────────────────────────────── */ @@ -74,8 +24,6 @@ interface FolderSelectProps { blockOnTouch?: boolean; } -const INDENT = 16; - export function FolderSelect({ value, onChange, @@ -86,48 +34,44 @@ export function FolderSelect({ blockOnTouch = false, }: FolderSelectProps) { const [open, setOpen] = useState(false); - const [expanded, setExpanded] = useState>(new Set()); const triggerRef = useRef(null); const dropdownRef = useRef(null); - const tree = buildTree(folders); - const visible = flatVisible(tree, expanded); const selectedFolder = folders.find((f) => f.id === value); const displayLabel = value === "" ? rootLabel : (selectedFolder?.name ?? rootLabel); - /* Auto-expand path to selected folder when opening */ - useEffect(() => { - if (!open || !value) return; - // Intentional: expand the ancestor path each time the dropdown opens so the - // selected folder is visible. Synchronize-on-open, not a derivable value. - // eslint-disable-next-line react-hooks/set-state-in-effect - setExpanded((prev) => { - const next = new Set(prev); - for (const id of ancestorIds(value, folders)) next.add(id); - return next; - }); - }, [open, value, folders]); - - /* Position the dropdown below (or above) the trigger */ + /* Position the dropdown below (or above) the trigger — again whenever it + resizes, since expanding a folder can push it past the viewport's edge. */ useLayoutEffect(() => { - if (!open || !triggerRef.current || !dropdownRef.current) return; - const tr = triggerRef.current.getBoundingClientRect(); - const dr = dropdownRef.current.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const gap = 4; - const minW = Math.max(tr.width, 200); - - let left = tr.left; - if (left + minW > vw - 8) left = Math.max(8, tr.right - minW); - - let top = tr.bottom + gap; - if (top + dr.height > vh - 8) top = Math.max(8, tr.top - dr.height - gap); + const trigger = triggerRef.current; + const dropdown = dropdownRef.current; + if (!open || !trigger || !dropdown) return; + + function position() { + if (!trigger || !dropdown) return; + const tr = trigger.getBoundingClientRect(); + const dr = dropdown.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const gap = 4; + const minW = Math.max(tr.width, 200); + + let left = tr.left; + if (left + minW > vw - 8) left = Math.max(8, tr.right - minW); + + let top = tr.bottom + gap; + if (top + dr.height > vh - 8) top = Math.max(8, tr.top - dr.height - gap); + + dropdown.style.left = `${left}px`; + dropdown.style.top = `${top}px`; + dropdown.style.minWidth = `${minW}px`; + } - dropdownRef.current.style.left = `${left}px`; - dropdownRef.current.style.top = `${top}px`; - dropdownRef.current.style.minWidth = `${minW}px`; - }, [open, visible.length]); + position(); + const observer = new ResizeObserver(position); + observer.observe(dropdown); + return () => observer.disconnect(); + }, [open]); /* Dismiss on outside click / Escape */ useEffect(() => { @@ -151,15 +95,6 @@ export function FolderSelect({ }; }, [open]); - function toggleExpand(id: string) { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } - function select(val: string) { onChange(val); setOpen(false); @@ -222,92 +157,13 @@ export function FolderSelect({ }} >
- {/* Root option */} - - - {/* Folder tree */} - {visible.map(({ node, depth }) => { - const { folder, children } = node; - const hasChildren = children.length > 0; - const isExpanded = expanded.has(folder.id); - const isSelected = folder.id === value; - - return ( -
- {/* Expand/collapse chevron */} - {hasChildren ? ( - - ) : ( -
- ); - })} - - {folders.length === 0 && ( -

{copy.noFolders}

- )} +
, document.body, diff --git a/src/ui/FolderTreeList.tsx b/src/ui/FolderTreeList.tsx new file mode 100644 index 0000000..dcd47e6 --- /dev/null +++ b/src/ui/FolderTreeList.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronRight, Folder, FolderOpen } from "lucide-react"; + +import type { Dictionary } from "@/i18n"; +import type { FolderRecord } from "@/lib/types"; + +/* ── Tree helpers ──────────────────────────────────────────────────────────── */ + +interface TreeNode { + folder: FolderRecord; + children: TreeNode[]; +} + +function buildTree(folders: FolderRecord[]): TreeNode[] { + const byParent = new Map(); + for (const folder of folders) { + const siblings = byParent.get(folder.parentId) ?? []; + siblings.push(folder); + byParent.set(folder.parentId, siblings); + } + + function build(currentParentId: string | null): TreeNode[] { + return (byParent.get(currentParentId) ?? []).map((folder) => ({ + folder, + children: build(folder.id), + })); + } + + return build(null); +} + +/** DFS: return nodes in display order, skipping collapsed subtrees */ +function flatVisible(nodes: TreeNode[], expanded: Set): Array<{ node: TreeNode; depth: number }> { + const result: Array<{ node: TreeNode; depth: number }> = []; + function walk(list: TreeNode[], depth: number) { + for (const node of list) { + result.push({ node, depth }); + if (expanded.has(node.folder.id) && node.children.length > 0) { + walk(node.children, depth + 1); + } + } + } + walk(nodes, 0); + return result; +} + +/** Return ancestor IDs of a given folder (excluding itself) */ +function ancestorIds(targetId: string, folders: FolderRecord[]): Set { + const ids = new Set(); + let current = folders.find((f) => f.id === targetId); + while (current?.parentId) { + ids.add(current.parentId); + current = folders.find((f) => f.id === current!.parentId); + } + return ids; +} + +/* ── Component ──────────────────────────────────────────────────────────────── */ + +const INDENT = 16; + +interface FolderTreeListProps { + folders: FolderRecord[]; + /** "" = root, a folder id otherwise, undefined = nothing is marked current. */ + value: string | undefined; + onSelect: (value: string) => void; + rootLabel: string; + copy: Dictionary["folderSelect"]; + /** Folders that can't be picked (e.g. a folder being moved into itself). */ + isDisabled?: (folderId: string) => boolean; +} + +/** + * A pickable folder tree: a root entry, then every folder with collapsible + * children. Shared by every "choose a destination" surface — the + * {@link FolderSelect} dropdown and the "Move to…" dialog — so expanding, + * marking the current folder and the touch sizing only exist once. + * + * Mount it when it opens: the path to `value` starts expanded, so the current + * folder is always in view. + */ +export function FolderTreeList({ + folders, + value, + onSelect, + rootLabel, + copy, + isDisabled, +}: FolderTreeListProps) { + const [expanded, setExpanded] = useState>(() => + value ? ancestorIds(value, folders) : new Set(), + ); + + const visible = flatVisible(buildTree(folders), expanded); + + function toggleExpand(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + // Rows grow below `lg`: on touch the list is picked with a finger, not a cursor. + const rowClass = (isSelected: boolean) => + [ + "flex min-w-0 items-center gap-2 rounded-lg text-left text-[13px] leading-none", + "transition-colors duration-75 max-lg:py-3", + "disabled:pointer-events-none disabled:opacity-30", + isSelected + ? "bg-ink/[0.08] text-ink" + : "text-ink/60 hover:bg-ink/[0.06] hover:text-ink/90 active:bg-ink/[0.06]", + ].join(" "); + + return ( + <> + {/* Root option */} + + + {/* Folder tree */} + {visible.map(({ node, depth }) => { + const { folder, children } = node; + const hasChildren = children.length > 0; + const isExpanded = expanded.has(folder.id); + const isSelected = folder.id === value; + + return ( +
+ {/* Expand/collapse chevron */} + {hasChildren ? ( + + ) : ( +
+ ); + })} + + {folders.length === 0 && ( +

{copy.noFolders}

+ )} + + ); +} From 78de4d9ab07122242c2218571d37e395342044a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Mart=C3=ADnez?= Date: Wed, 30 Sep 2026 12:36:57 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20feat:=20move=20items=20to=20a?= =?UTF-8?q?=20folder=20from=20the=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Move to…" to the shared row menu, opening a folder picker dialog. On touch there is no drag & drop, so moving used to mean cut and paste; on desktop it also works on a multi-selection. Picking a folder moves straight away; folders an item can't go into are disabled. --- src/components/Aside/useContextMenuGroups.ts | 11 +- src/components/KlipCodeApp.tsx | 19 ++- src/components/MoveDialog/MoveDialog.tsx | 121 +++++++++++++++++++ src/components/Workspace/WorkspaceTree.tsx | 2 + src/components/Workspace/types.ts | 2 + src/i18n/en.ts | 5 + src/i18n/es.ts | 5 + 7 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/components/MoveDialog/MoveDialog.tsx diff --git a/src/components/Aside/useContextMenuGroups.ts b/src/components/Aside/useContextMenuGroups.ts index 501d37e..546d1cb 100644 --- a/src/components/Aside/useContextMenuGroups.ts +++ b/src/components/Aside/useContextMenuGroups.ts @@ -4,6 +4,7 @@ import { Copy, ExternalLink, FilePlus, + FolderInput, FolderPlus, PenLine, Pin, @@ -32,6 +33,8 @@ interface UseContextMenuGroupsArgs { onDeleteMany: (items: SelectedItem[]) => Promise; onCut: (entry: ClipboardEntry) => void; onCopy: (entry: ClipboardEntry) => void; + /** Open the "Move to…" picker for these items. */ + onMove: (items: SelectedItem[]) => void; setRenamingId: (id: string | null) => void; setCreatingFolderParentId: (id: string | null | undefined) => void; onOpenCreateModal: (folderId: string | null) => void; @@ -57,6 +60,7 @@ export function useContextMenuGroups({ onDeleteMany, onCut, onCopy, + onMove, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, @@ -74,8 +78,9 @@ export function useContextMenuGroups({ * keyboard shortcuts and drag-move. (selectForMenu has already collapsed * the selection to this row if it was clicked from outside the set.) */ const batchActive = !!id && selectedIds.has(id) && selectedIds.size > 1; + const targetItems = (fallback: SelectedItem) => (batchActive ? getSelectedItems() : [fallback]); const clipboardItems = (fallback: SelectedItem) => - (batchActive ? getSelectedItems() : [fallback]).map((i) => ({ itemType: i.type, id: i.id })); + targetItems(fallback).map((i) => ({ itemType: i.type, id: i.id })); const deleteSelection = () => { const items = getSelectedItems(); clearSelection(); @@ -158,6 +163,7 @@ export function useContextMenuGroups({ }, { items: [ + { id: "move", label: cm.moveTo, Icon: FolderInput, onClick: () => onMove(targetItems({ id, type: "folder" })) }, { id: "cut", label: cm.cut, Icon: Scissors, onClick: () => onCut({ type: "cut", items: clipboardItems({ id, type: "folder" }) }) }, { id: "copy", label: cm.copy, Icon: Copy, onClick: () => onCopy({ type: "copy", items: clipboardItems({ id, type: "folder" }) }) }, ...(clipboard ? [{ id: "paste", label: cm.paste, Icon: Clipboard, onClick: () => void onPaste(id) }] : []), @@ -215,6 +221,7 @@ export function useContextMenuGroups({ }, { items: [ + { id: "move", label: cm.moveTo, Icon: FolderInput, onClick: () => onMove(targetItems({ id, type: "snippet" })) }, { id: "cut", label: cm.cut, Icon: Scissors, onClick: () => onCut({ type: "cut", items: clipboardItems({ id, type: "snippet" }) }) }, { id: "copy", label: cm.copy, Icon: Copy, onClick: () => onCopy({ type: "copy", items: clipboardItems({ id, type: "snippet" }) }) }, ...(clipboard ? [{ id: "paste", label: cm.paste, Icon: Clipboard, onClick: () => void onPaste(snippet.folderId) }] : []), @@ -234,6 +241,6 @@ export function useContextMenuGroups({ return []; }, - [clipboard, copy.contextMenu, folders, snippets, onPaste, onPinFolder, onPinSnippet, onDeleteFolder, onDeleteSnippet, onDeleteMany, onCut, onCopy, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, selectedIds, getSelectedItems, clearSelection], + [clipboard, copy.contextMenu, folders, snippets, onPaste, onPinFolder, onPinSnippet, onDeleteFolder, onDeleteSnippet, onDeleteMany, onCut, onCopy, onMove, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, selectedIds, getSelectedItems, clearSelection], ); } diff --git a/src/components/KlipCodeApp.tsx b/src/components/KlipCodeApp.tsx index d530afe..601f574 100644 --- a/src/components/KlipCodeApp.tsx +++ b/src/components/KlipCodeApp.tsx @@ -7,7 +7,7 @@ import { ArrowLeft } from "lucide-react"; import { readTrash } from "@/lib/db"; import { readInitialWorkspace } from "@/lib/seed"; -import type { ClipboardEntry, SnippetRecord, WorkspaceSnapshot } from "@/lib/types"; +import type { ClipboardEntry, SelectedItem, SnippetRecord, WorkspaceSnapshot } from "@/lib/types"; import { getDictionary } from "@/i18n"; import { localeHref, LOCALE_COOKIE, type Locale } from "@/lib/locale"; import { SPACE_ROOT_ID, TRASH_ROOT_ID } from "@/lib/navigation"; @@ -29,6 +29,7 @@ import type { WorkspaceShellProps } from "@/components/Aside/types"; import { MobileHome } from "@/components/MobileHome/MobileHome"; import { useMobileFeedState } from "@/components/MobileHome/useMobileFeedState"; import { ConfirmDialog } from "@/components/ConfirmDialog/ConfirmDialog"; +import { MoveDialog } from "@/components/MoveDialog/MoveDialog"; import { CreateSnippetModal } from "@/components/CreateSnippetModal/CreateSnippetModal"; import { CreatedSnippetToast } from "@/components/CreatedSnippetToast/CreatedSnippetToast"; import { DragProvider } from "@/components/DragContext"; @@ -138,6 +139,8 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { const [copyNonce, setCopyNonce] = useState(0); const [undoNonce, setUndoNonce] = useState(0); const [pendingEmptyTrash, setPendingEmptyTrash] = useState(false); + /** Items waiting on a destination in the "Move to…" dialog. */ + const [pendingMove, setPendingMove] = useState(null); /* ── AI title generation state ──────────────────────────────────────────── Snippets whose name Workers AI is currently inferring in the background, so @@ -292,7 +295,7 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { }); }, hasOpenSnippet: !!selectedSnippet, - overlayOpen: searchOpen || helpOpen || prefsOpen || pendingEmptyTrash || createModalOpen, + overlayOpen: searchOpen || helpOpen || prefsOpen || pendingEmptyTrash || createModalOpen || !!pendingMove, }); /** @@ -347,6 +350,7 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { onCut: setClipboard, onCopy: (entry) => setClipboard({ ...entry, type: "copy" }), onPaste: mutations.handlePaste, + onMove: setPendingMove, onOpenSearch: () => setSearchOpen(true), onOpenPreferences: () => setPrefsOpen(true), onSignIn: auth.handleGitHubSignIn, @@ -647,6 +651,17 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { onOpen={(id) => navigate(`${base}?snippet=${id}`)} /> + {pendingMove && ( + void mutations.handleMoveMany(pendingMove, targetFolderId)} + onClose={() => setPendingMove(null)} + /> + )} + {pendingEmptyTrash && ( void; + onClose: () => void; +} + +/** + * "Move to…": pick a destination folder for one or more items. + * + * The menu's way of doing what drag & drop does on the desktop tree, and the + * only way on touch, where there is no drag. Picking a folder moves straight + * away — the choice is the confirmation, and a wrong one is undone by moving + * back. Folders an item can't go into (itself, its own subfolders) are disabled + * rather than hidden, so the tree keeps its shape. + */ +export function MoveDialog({ copy, folders, snippets, items, onMove, onClose }: MoveDialogProps) { + const panelRef = useDialogA11y({ onClose }); + const titleId = useId(); + + const movingFolderIds = items.filter((i) => i.type === "folder").map((i) => i.id); + const parents = new Set(items.map((item) => locationOf(item, folders, snippets))); + // Mark where the items already live — only meaningful when they share it. + const current = parents.size === 1 ? ([...parents][0] ?? "") : undefined; + + const subtitle = + items.length === 1 + ? nameOf(items[0], folders, snippets, copy.snippetCard.untitled) + : copy.moveDialog.itemCount(items.length); + + return createPortal( + <> +