diff --git a/.gitignore b/.gitignore index 700c6cccf..bb9c7e5b6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr dist-electron release *.local +AGENTS.md # Editor directories and files .vscode/.debug.env diff --git a/src/components/Sidebars/FileSideBar/FileItemRows.tsx b/src/components/Sidebars/FileSideBar/FileItemRows.tsx index ef671d090..97a973914 100644 --- a/src/components/Sidebars/FileSideBar/FileItemRows.tsx +++ b/src/components/Sidebars/FileSideBar/FileItemRows.tsx @@ -4,8 +4,9 @@ import posthog from 'posthog-js' import { isFileNodeDirectory } from '@shared/utils' import { XStack, Text } from 'tamagui' import { ChevronRight, ChevronDown } from '@tamagui/lucide-icons' +import { toast } from 'react-toastify' import { useFileContext } from '@/contexts/FileContext' -import { removeFileExtension } from '@/lib/file' +import { getFilesInDirectory, removeFileExtension } from '@/lib/file' import { useContentContext } from '@/contexts/ContentContext' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' import NewDirectoryComponent from '@/components/File/NewDirectory' @@ -22,6 +23,11 @@ const FileItemRows: React.FC = ({ index, style, data }) selectedDirectory, setSelectedDirectory, renameFile, + selectedFilePaths, + selectSingleFile, + toggleSelectFile, + selectRangeTo, + vaultFilesFlattened, } = useFileContext() const { openContent, createUntitledNote } = useContentContext() const [isNewDirectoryModalOpen, setIsNewDirectoryModalOpen] = useState(false) @@ -29,7 +35,14 @@ const FileItemRows: React.FC = ({ index, style, data }) const [isDragOver, setIsDragOver] = useState(false) const isDirectory = isFileNodeDirectory(file) - const isSelected = isDirectory ? file.path === selectedDirectory : file.path === currentlyOpenFilePath + let isSelected = false + if (isDirectory) { + isSelected = selectedFilePaths.includes(file.path) || file.path === selectedDirectory + } else if (selectedFilePaths.length > 0) { + isSelected = selectedFilePaths.includes(file.path) + } else { + isSelected = file.path === currentlyOpenFilePath + } const indentationPadding = indentation ? 10 * indentation : 0 const isExpanded = expandedDirectories.get(file.path) @@ -65,17 +78,40 @@ const FileItemRows: React.FC = ({ index, style, data }) const clickOnFileOrDirectory = useCallback( (event: any) => { - const e = event.nativeEvent + const e = event.nativeEvent || event if (isDirectory) { - handleDirectoryToggle(file.path) - setSelectedDirectory(file.path) + if (e.metaKey || e.ctrlKey) { + toggleSelectFile(file.path) + } else { + handleDirectoryToggle(file.path) + setSelectedDirectory(file.path) + } + e.stopPropagation() + return + } + + if (e.shiftKey) { + selectRangeTo(file.path) + } else if (e.metaKey || e.ctrlKey) { + toggleSelectFile(file.path) } else { + selectSingleFile(file.path) openContent(file.path) posthog.capture('open_file_from_sidebar') } + e.stopPropagation() }, - [file.path, isDirectory, handleDirectoryToggle, openContent, setSelectedDirectory], + [ + file.path, + isDirectory, + handleDirectoryToggle, + openContent, + setSelectedDirectory, + selectRangeTo, + toggleSelectFile, + selectSingleFile, + ], ) const openNewDirectoryModal = useCallback(async () => { @@ -108,6 +144,75 @@ const FileItemRows: React.FC = ({ index, style, data }) New file New folder + { + try { + // Determine current selection scope + const baseSelection = + selectedFilePaths.length > 1 && selectedFilePaths.includes(file.path) ? selectedFilePaths : [file.path] + + // Partition into directories and files + const dirPaths: string[] = [] + const filePaths: string[] = [] + for (const p of baseSelection) { + // eslint-disable-next-line no-await-in-loop + const isDir = await window.fileSystem.isDirectory(p) + if (isDir) dirPaths.push(p) + else filePaths.push(p) + } + + // Build folder sections with H1 headers + const seenFiles = new Set() + const parts: string[] = [] + for (const dir of dirPaths) { + // eslint-disable-next-line no-await-in-loop + const folderName = await window.path.basename(dir) + // eslint-disable-next-line no-await-in-loop + const files = await getFilesInDirectory(dir, vaultFilesFlattened) + const folderContents: string[] = [] + for (const f of files) { + if (!seenFiles.has(f.path)) { + seenFiles.add(f.path) + // eslint-disable-next-line no-await-in-loop + const c = await window.fileSystem.readFile(f.path, 'utf-8') + folderContents.push(c || '') + } + } + if (folderContents.length > 0) { + parts.push(`# Folder: ${folderName}`) + parts.push(folderContents.join('\n\n')) + } + } + + // Add loose files not already included via folders + for (const p of filePaths) { + if (!seenFiles.has(p)) { + seenFiles.add(p) + // eslint-disable-next-line no-await-in-loop + const c = await window.fileSystem.readFile(p, 'utf-8') + parts.push(c || '') + } + } + + if (parts.length === 0) { + toast.info('No markdown files found') + return + } + + const output = parts.join('\n\n') + await navigator.clipboard.writeText(output) + const totalNotes = seenFiles.size + toast.success(totalNotes > 1 ? `Copied ${totalNotes} notes to clipboard` : 'Markdown copied to clipboard') + } catch (err) { + toast.error('Failed to copy markdown') + } + }} + > + {(() => { + const inMulti = selectedFilePaths.length > 1 && selectedFilePaths.includes(file.path) + return inMulti ? `Copy Markdown (${selectedFilePaths.length})` : 'Copy Markdown' + })()} + setNoteToBeRenamed(file.path)}>Rename Delete diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 2d49e91bc..5aec101c5 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -48,6 +48,11 @@ type FileContextType = { deleteFile: (path: string | undefined) => Promise selectedDirectory: string | null setSelectedDirectory: React.Dispatch> + selectedFilePaths: string[] + selectSingleFile: (path: string) => void + toggleSelectFile: (path: string) => void + selectRangeTo: (path: string) => void + clearSelection: () => void } export const FileContext = createContext(undefined) @@ -73,6 +78,8 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [noteToBeRenamed, setNoteToBeRenamed] = useState('') const [fileDirToBeRenamed, setFileDirToBeRenamed] = useState('') const [currentlyChangingFilePath, setCurrentlyChangingFilePath] = useState(false) + const [selectedFilePaths, setSelectedFilePaths] = useState([]) + const [lastSelectedFilePath, setLastSelectedFilePath] = useState(null) // TODO: Add highlighting data on search // eslint-disable-next-line @typescript-eslint/no-unused-vars const [highlightData, setHighlightData] = useState({ @@ -326,6 +333,37 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => deleteFile, selectedDirectory, setSelectedDirectory, + selectedFilePaths, + selectSingleFile: (path: string) => { + setSelectedFilePaths([path]) + setLastSelectedFilePath(path) + }, + toggleSelectFile: (path: string) => { + setSelectedFilePaths((prev) => (prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path])) + setLastSelectedFilePath(path) + }, + selectRangeTo: (path: string) => { + if (!lastSelectedFilePath) { + setSelectedFilePaths([path]) + setLastSelectedFilePath(path) + return + } + const onlyFiles = vaultFilesFlattened + const startIdx = onlyFiles.findIndex((f) => f.path === lastSelectedFilePath) + const endIdx = onlyFiles.findIndex((f) => f.path === path) + if (startIdx === -1 || endIdx === -1) { + setSelectedFilePaths([path]) + setLastSelectedFilePath(path) + return + } + const [from, to] = startIdx <= endIdx ? [startIdx, endIdx] : [endIdx, startIdx] + const range = onlyFiles.slice(from, to + 1).map((f) => f.path) + setSelectedFilePaths(range) + }, + clearSelection: () => { + setSelectedFilePaths([]) + setLastSelectedFilePath(null) + }, } const contextValuesMemo: FileContextType = React.useMemo(