-
Notifications
You must be signed in to change notification settings - Fork 512
feat(files): multi-select + bulk Copy Markdown; support folders (with '# Folder: <name>' headers); toasts and dedupe #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ dist-ssr | |
| dist-electron | ||
| release | ||
| *.local | ||
| AGENTS.md | ||
|
|
||
| # Editor directories and files | ||
| .vscode/.debug.env | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,14 +23,26 @@ const FileItemRows: React.FC<ListChildComponentProps> = ({ index, style, data }) | |
| selectedDirectory, | ||
| setSelectedDirectory, | ||
| renameFile, | ||
| selectedFilePaths, | ||
| selectSingleFile, | ||
| toggleSelectFile, | ||
| selectRangeTo, | ||
| vaultFilesFlattened, | ||
| } = useFileContext() | ||
| const { openContent, createUntitledNote } = useContentContext() | ||
| const [isNewDirectoryModalOpen, setIsNewDirectoryModalOpen] = useState(false) | ||
| const [parentDirectoryPathForNewDirectory, setParentDirectoryPathForNewDirectory] = useState<string | undefined>() | ||
| 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<ListChildComponentProps> = ({ 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<ListChildComponentProps> = ({ index, style, data }) | |
| New file | ||
| </ContextMenuItem> | ||
| <ContextMenuItem onClick={openNewDirectoryModal}>New folder</ContextMenuItem> | ||
| <ContextMenuItem | ||
| onClick={async () => { | ||
| 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<string>() | ||
| 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 || '') | ||
| } | ||
| } | ||
|
Comment on lines
+188
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Another sequential await loop that could benefit from Promise.all() for better performance. |
||
|
|
||
| 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' | ||
| })()} | ||
| </ContextMenuItem> | ||
| <ContextMenuItem onClick={() => setNoteToBeRenamed(file.path)}>Rename</ContextMenuItem> | ||
| <ContextMenuItem onClick={handleDelete}>Delete</ContextMenuItem> | ||
| </> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -48,6 +48,11 @@ type FileContextType = { | |||||
| deleteFile: (path: string | undefined) => Promise<boolean> | ||||||
| selectedDirectory: string | null | ||||||
| setSelectedDirectory: React.Dispatch<React.SetStateAction<string | null>> | ||||||
| selectedFilePaths: string[] | ||||||
| selectSingleFile: (path: string) => void | ||||||
| toggleSelectFile: (path: string) => void | ||||||
| selectRangeTo: (path: string) => void | ||||||
| clearSelection: () => void | ||||||
| } | ||||||
|
|
||||||
| export const FileContext = createContext<FileContextType | undefined>(undefined) | ||||||
|
|
@@ -73,6 +78,8 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => | |||||
| const [noteToBeRenamed, setNoteToBeRenamed] = useState<string>('') | ||||||
| const [fileDirToBeRenamed, setFileDirToBeRenamed] = useState<string>('') | ||||||
| const [currentlyChangingFilePath, setCurrentlyChangingFilePath] = useState(false) | ||||||
| const [selectedFilePaths, setSelectedFilePaths] = useState<string[]>([]) | ||||||
| const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null) | ||||||
| // TODO: Add highlighting data on search | ||||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||||
| const [highlightData, setHighlightData] = useState<HighlightData>({ | ||||||
|
|
@@ -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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Variable name 'onlyFiles' is misleading - this array contains both files and directories based on vaultFilesFlattened
Suggested change
|
||||||
| 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( | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Multiple await calls in loop can cause performance issues with many selected files. Consider using Promise.all() to parallelize these operations.