This repository was archived by the owner on Mar 7, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 530
feat(files): multi-select + bulk Copy Markdown; support folders (with '# Folder: <name>' headers); toasts and dedupe #525
Open
danhilse
wants to merge
1
commit into
reorproject:main
Choose a base branch
from
danhilse:feature/batch-copy-markdown
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| </> | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||||||
|
|
||||||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.